refactor to use external parser

This commit is contained in:
Graham McIntire 2025-06-24 14:22:09 -05:00
parent 2ba9f11656
commit ce7c50caa1
No known key found for this signature in database
202 changed files with 794 additions and 5100 deletions

View file

@ -26,7 +26,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
tags: aprs:latest
tags: aprsme:latest
load: true
no-cache: true
pull: true
@ -36,7 +36,7 @@ jobs:
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: aprs:latest
image-ref: aprsme:latest
format: "sarif"
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH"
@ -59,7 +59,7 @@ jobs:
echo "### OS Package Vulnerabilities" >> dep-update-report.md
echo "```" >> dep-update-report.md
trivy image --severity HIGH,CRITICAL --no-progress aprs:latest >> dep-update-report.md
trivy image --severity HIGH,CRITICAL --no-progress aprsme:latest >> dep-update-report.md
echo "```" >> dep-update-report.md
- name: Upload vulnerability summary

View file

@ -45,7 +45,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
tags: aprs:${{ github.sha }}
tags: aprsme:${{ github.sha }}
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
@ -53,7 +53,7 @@ jobs:
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: aprs:${{ github.sha }}
image-ref: aprsme:${{ github.sha }}
format: "sarif"
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH"
@ -70,7 +70,7 @@ jobs:
- name: Run Trivy for outdated OS packages
uses: aquasecurity/trivy-action@master
with:
image-ref: aprs:${{ github.sha }}
image-ref: aprsme:${{ github.sha }}
format: "table"
output: "os-packages.txt"
severity: "HIGH,CRITICAL"
@ -79,7 +79,7 @@ jobs:
- name: Run Trivy for outdated application dependencies
uses: aquasecurity/trivy-action@master
with:
image-ref: aprs:${{ github.sha }}
image-ref: aprsme:${{ github.sha }}
format: "table"
output: "app-packages.txt"
severity: "HIGH,CRITICAL"
@ -99,7 +99,7 @@ jobs:
uses: docker/scout-action@v1
with:
command: quickview,cves
image: aprs:${{ github.sha }}
image: aprsme:${{ github.sha }}
output-format: sarif
only-severities: critical,high
sarif-file: scout-results.sarif
@ -116,7 +116,7 @@ jobs:
if: always()
run: |
echo "# Docker Image Security Report" > security-summary.md
echo "## Image: aprs:${{ github.sha }}" >> security-summary.md
echo "## Image: aprsme:${{ github.sha }}" >> security-summary.md
echo "## Date: $(date)" >> security-summary.md
echo "### OS Package Vulnerabilities" >> security-summary.md

2
.gitignore vendored
View file

@ -20,7 +20,7 @@ erl_crash.dump
*.ez
# Ignore package tarball (built via "mix hex.build").
aprs-*.tar
aprsme-*.tar
# Ignore assets that are produced by build tools.
/priv/static/assets/

View file

@ -121,7 +121,7 @@ WORKDIR /app
# Copy the Elixir release from builder stage
# The distroless nonroot user has UID 65532, so we use that
COPY --from=builder --chown=65532:65532 /app/_build/prod/rel/aprs ./
COPY --from=builder --chown=65532:65532 /app/_build/prod/rel/aprsme ./
# Create required directories with proper permissions
USER 0

View file

@ -83,19 +83,19 @@ The workflow is defined in `.github/workflows/deploy-k3s.yaml`. It will:
* **Database Migrations:** The Kubernetes deployment for the application includes an init container that automatically runs database migrations (`/app/bin/migrate`) before the main application container starts.
* **Health Check Endpoint:** The application deployment (`k8s/app-deployment.yaml`) is configured with readiness and liveness probes that expect a health check endpoint at `/health` (returning HTTP 200). You may need to add this to your Phoenix application's router and controller:
* In `lib/aprs_web/router.ex`:
* In `lib/aprsme_web/router.ex`:
```elixir
scope "/", AprsWeb do
scope "/", AprsmeWeb do
pipe_through :browser
# ... other routes
get "/health", PageController, :health
end
```
* In `lib/aprs_web/controllers/page_controller.ex` (or another suitable controller):
* In `lib/aprsme_web/controllers/page_controller.ex` (or another suitable controller):
```elixir
def health(conn, _params) do
# You can add more sophisticated checks here if needed
json(conn, %{status: "ok", version: Application.spec(:aprs, :vsn)})
json(conn, %{status: "ok", version: Application.spec(:aprsme, :vsn)})
end
```
* **Image Name:** The application deployment manifest (`k8s/app-deployment.yaml`) uses a placeholder image name `your-ghcr-username/aprs-app:latest`. The CI/CD pipeline automatically replaces this with the correct image name from GHCR (e.g., `ghcr.io/your-github-owner/your-repo-name:<git-sha>`).

View file

@ -14,36 +14,36 @@ import Config
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :aprs, Aprs.Mailer, adapter: Swoosh.Adapters.Local
config :aprsme, Aprsme.Mailer, adapter: Swoosh.Adapters.Local
# Configures the endpoint
config :aprs, AprsWeb.Endpoint,
config :aprsme, AprsmeWeb.Endpoint,
url: [host: "localhost"],
render_errors: [
formats: [html: AprsWeb.ErrorHTML, json: AprsWeb.ErrorJSON],
formats: [html: AprsmeWeb.ErrorHTML, json: AprsmeWeb.ErrorJSON],
layout: false
],
pubsub_server: Aprs.PubSub,
pubsub_server: Aprsme.PubSub,
live_view: [signing_salt: "ees098qG"]
# Configure Oban for background jobs
config :aprs, Oban,
repo: Aprs.Repo,
config :aprsme, Oban,
repo: Aprsme.Repo,
plugins: [
{Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7},
{Oban.Plugins.Cron,
crontab: [
{"0 0 * * *", Aprs.Workers.PacketCleanupWorker},
{"0 3 * * 1", Aprs.DeviceIdentification.Worker}
{"0 0 * * *", Aprsme.Workers.PacketCleanupWorker},
{"0 3 * * 1", Aprsme.DeviceIdentification.Worker}
]}
],
queues: [default: 10, maintenance: 2]
config :aprs,
ecto_repos: [Aprs.Repo]
config :aprsme,
ecto_repos: [Aprsme.Repo]
config :aprs,
ecto_repos: [Aprs.Repo],
config :aprsme,
ecto_repos: [Aprsme.Repo],
aprs_is_server: System.get_env("APRS_SERVER", "dallas.aprs2.net"),
aprs_is_port: 10_152,
aprs_is_default_filter: System.get_env("APRS_FILTER"),

View file

@ -1,7 +1,7 @@
import Config
# Configure your database
config :aprs, Aprs.Repo,
config :aprsme, Aprsme.Repo,
# For development, we disable any cache and enable
# debugging and code reloading.
#
@ -11,14 +11,14 @@ config :aprs, Aprs.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "aprs_dev",
database: "aprsme_dev",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10,
log: false,
types: Aprs.PostgresTypes
types: Aprsme.PostgresTypes
config :aprs, AprsWeb.Endpoint,
config :aprsme, AprsmeWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}, port: 4000],
@ -37,13 +37,13 @@ config :aprs, AprsWeb.Endpoint,
]
# Watch static and templates for browser reloading.
config :aprs, AprsWeb.Endpoint,
config :aprsme, AprsmeWeb.Endpoint,
live_reload: [
patterns: [
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/aprs_web/(live|views)/.*(ex)$",
~r"lib/aprs_web/templates/.*(eex)$",
~r"lib/aprsme_web/(live|views)/.*(ex)$",
~r"lib/aprsme_web/templates/.*(eex)$",
~r"assets/vendor/.*(js|css)$"
]
]
@ -53,7 +53,7 @@ config :aprs, AprsWeb.Endpoint,
# mix phx.gen.cert
#
# Run `mix help phx.gen.cert` for more information.
config :aprs, dev_routes: true
config :aprsme, dev_routes: true
# Do not include metadata nor timestamps in development logs
#

View file

@ -9,7 +9,7 @@ import Config
# manifest is generated by the `mix phx.digest` task,
# which you should run after static files are built and
# before starting your production server.
config :aprs, AprsWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
config :aprsme, AprsmeWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
# Runtime production configuration, including reading
@ -26,4 +26,4 @@ config :esbuild,
config :logger, level: :info
# Configures Swoosh API Client
config :swoosh, :api_client, Aprs.Finch
config :swoosh, :api_client, Aprsme.Finch

View file

@ -12,13 +12,13 @@ import Config
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/aprs start
# PHX_SERVER=true bin/aprsme start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
# Always start the server in production/docker environments
if System.get_env("PHX_SERVER") || config_env() == :prod do
config :aprs, AprsWeb.Endpoint, server: true
config :aprsme, AprsmeWeb.Endpoint, server: true
end
if config_env() == :prod do
@ -46,14 +46,14 @@ if config_env() == :prod do
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
config :aprs, Aprs.Repo,
config :aprsme, Aprsme.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: maybe_ipv6,
types: Aprs.PostgresTypes
types: Aprsme.PostgresTypes
config :aprs, AprsWeb.Endpoint,
config :aprsme, AprsmeWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Bind on all IPv4 interfaces for better container compatibility
@ -66,10 +66,10 @@ if config_env() == :prod do
secret_key_base: secret_key_base,
server: true
# config :aprs, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
# config :aprsme, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :aprs,
ecto_repos: [Aprs.Repo],
config :aprsme,
ecto_repos: [Aprsme.Repo],
aprs_is_server: System.get_env("APRS_SERVER", "dallas.aprs2.net"),
aprs_is_port: 14_580,
aprs_is_default_filter: System.get_env("APRS_FILTER"),
@ -94,7 +94,7 @@ if config_env() == :prod do
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :aprs, AprsWeb.Endpoint,
# config :aprsme, AprsmeWeb.Endpoint,
# https: [
# ...,
# port: 443,
@ -116,7 +116,7 @@ if config_env() == :prod do
# We also recommend setting `force_ssl` in your endpoint, ensuring
# no data is ever sent via http, always redirecting to https:
#
# config :aprs, AprsWeb.Endpoint,
# config :aprsme, AprsmeWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
@ -127,7 +127,7 @@ if config_env() == :prod do
# Also, you may need to configure the Swoosh API client of your choice if you
# are not using SMTP. Here is an example of the configuration:
#
# config :aprs, Aprs.Mailer,
# config :aprsme, Aprsme.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")

View file

@ -1,40 +1,40 @@
import Config
# In test we don't send emails.
config :aprs, Aprs.Mailer, adapter: Swoosh.Adapters.Test
config :aprsme, Aprsme.Mailer, adapter: Swoosh.Adapters.Test
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :aprs, Aprs.Repo,
config :aprsme, Aprsme.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "aprs_test#{System.get_env("MIX_TEST_PARTITION")}",
database: "aprsme_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2,
types: Aprs.PostgresTypes
types: Aprsme.PostgresTypes
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :aprs, AprsWeb.Endpoint,
config :aprsme, AprsmeWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "IV9+ENaw9i8xjReRk4sULRvRgsmFVTGQwQGGrf4G+Q/SFMeHBCNWRlPXQ2YvT36R",
server: false
# Disable Oban during tests to prevent background job execution
config :aprs, Oban, testing: :inline
config :aprsme, Oban, testing: :inline
# Disable initialize replay delay in test environment
config :aprs, :initialize_replay_delay, 0
config :aprsme, :initialize_replay_delay, 0
# Configure the packets module to use the mock in tests
config :aprs, :packets_module, Aprs.PacketsMock
config :aprsme, :packets_module, Aprsme.PacketsMock
# Disable APRS-IS external connections in test environment
config :aprs,
config :aprsme,
aprs_is_server: "mock.aprs.test",
aprs_is_port: 14_580,
aprs_is_default_filter: "r/33/-96/100",
@ -43,7 +43,7 @@ config :aprs,
disable_aprs_connection: true
# Disable automatic migrations during tests
config :aprs, auto_migrate: false
config :aprsme, auto_migrate: false
# Only in tests, remove the complexity from the password hashing algorithm
config :bcrypt_elixir, :log_rounds, 1

View file

@ -49,5 +49,5 @@ spec:
initialDelaySeconds: 30
periodSeconds: 15
# Note: For the health endpoint, you might need to create a simple one in your Phoenix router
# e.g., scope "/", AprsWeb do; get "/health", PageController, :health; end
# e.g., scope "/", AprsmeWeb do; get "/health", PageController, :health; end
# and a `def health(conn, _params), do: json(conn, %{status: "ok"})` in PageController.

View file

@ -1,9 +0,0 @@
defmodule Aprs do
@moduledoc """
Aprs keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end

View file

@ -1,4 +0,0 @@
defmodule Aprs.Mailer do
@moduledoc false
use Swoosh.Mailer, otp_app: :aprs
end

View file

@ -1,4 +0,0 @@
defmodule Aprs.Presence do
@moduledoc false
use Phoenix.Presence, otp_app: :aprs, pubsub_server: Aprs.PubSub
end

View file

@ -1,20 +1,20 @@
defmodule AprsWeb do
defmodule AprsmeWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
use AprsWeb, :controller
use AprsWeb, :html
use AprsmeWeb, :controller
use AprsmeWeb, :html
The definitions below will be executed for every controller,
The definitions below will be executed for every view,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
below. Instead, define additional functions and do the
imports in your modules.
"""
def static_paths, do: ~w(assets fonts images aprs-symbols favicon.ico robots.txt)
@ -23,9 +23,10 @@ defmodule AprsWeb do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Phoenix.Controller
import Phoenix.LiveView.Router
# Import common connection and controller functions to use in pipelines
import Plug.Conn
end
end
@ -33,17 +34,18 @@ defmodule AprsWeb do
def channel do
quote do
use Phoenix.Channel
import AprsmeWeb.Gettext
end
end
def controller do
quote do
use Phoenix.Controller,
namespace: AprsWeb,
formats: [:html, :json],
layouts: [html: AprsWeb.Layouts]
layouts: [html: AprsmeWeb.Layouts]
import AprsWeb.Gettext
import AprsmeWeb.Gettext
import Plug.Conn
unquote(verified_routes())
@ -53,7 +55,7 @@ defmodule AprsWeb do
def live_view do
quote do
use Phoenix.LiveView,
layout: {AprsWeb.Layouts, :app}
layout: {AprsmeWeb.Layouts, :app}
unquote(html_helpers())
end
@ -82,11 +84,11 @@ defmodule AprsWeb do
defp html_helpers do
quote do
import AprsmeWeb.CoreComponents
import AprsmeWeb.Gettext
# HTML escaping functionality
# Core UI components and translation
import AprsWeb.CoreComponents
import AprsWeb.Gettext
import Phoenix.HTML
# Core UI components and translation
# Shortcut for generating JS commands
alias Phoenix.LiveView.JS
@ -99,9 +101,9 @@ defmodule AprsWeb do
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: AprsWeb.Endpoint,
router: AprsWeb.Router,
statics: AprsWeb.static_paths()
endpoint: AprsmeWeb.Endpoint,
router: AprsmeWeb.Router,
statics: AprsmeWeb.static_paths()
end
end

5
lib/aprsme.ex Normal file
View file

@ -0,0 +1,5 @@
defmodule Aprsme do
@moduledoc """
The main application module for Aprsme.
"""
end

View file

@ -1,14 +1,14 @@
defmodule Aprs.Accounts do
defmodule Aprsme.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Aprs.Accounts.User
alias Aprs.Accounts.UserNotifier
alias Aprs.Accounts.UserToken
alias Aprs.Repo
alias Aprsme.Accounts.User
alias Aprsme.Accounts.UserNotifier
alias Aprsme.Accounts.UserToken
alias Aprsme.Repo
## Database getters

View file

@ -1,4 +1,4 @@
defmodule Aprs.Accounts.User do
defmodule Aprsme.Accounts.User do
@moduledoc false
use Ecto.Schema
@ -89,7 +89,7 @@ defmodule Aprs.Accounts.User do
defp do_validate_unique_email(changeset, true) do
changeset
|> unsafe_validate_unique(:email, Aprs.Repo)
|> unsafe_validate_unique(:email, Aprsme.Repo)
|> unique_constraint(:email)
end
@ -143,7 +143,7 @@ defmodule Aprs.Accounts.User do
If there is no user or the user doesn't have a password, we call
`Bcrypt.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%Aprs.Accounts.User{hashed_password: hashed_password}, password)
def valid_password?(%Aprsme.Accounts.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Bcrypt.verify_pass(password, hashed_password)
end

View file

@ -1,8 +1,8 @@
defmodule Aprs.Accounts.UserNotifier do
defmodule Aprsme.Accounts.UserNotifier do
@moduledoc false
import Swoosh.Email
alias Aprs.Mailer
alias Aprsme.Mailer
# Delivers the email using the application mailer.
defp deliver(recipient, subject, body) do

View file

@ -1,10 +1,10 @@
defmodule Aprs.Accounts.UserToken do
defmodule Aprsme.Accounts.UserToken do
@moduledoc false
use Ecto.Schema
import Ecto.Query
alias Aprs.Accounts.UserToken
alias Aprsme.Accounts.UserToken
@hash_algorithm :sha256
@rand_size 32
@ -20,7 +20,7 @@ defmodule Aprs.Accounts.UserToken do
field(:token, :binary)
field(:context, :string)
field(:sent_to, :string)
belongs_to(:user, Aprs.Accounts.User)
belongs_to(:user, Aprsme.Accounts.User)
timestamps(updated_at: false)
end

View file

@ -1,4 +1,4 @@
defmodule Aprs.Application do
defmodule Aprsme.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
@ -16,35 +16,40 @@ defmodule Aprs.Application do
children = [
# Start the Telemetry supervisor
AprsWeb.Telemetry,
AprsmeWeb.Telemetry,
# Start the Ecto repository
Aprs.Repo,
Aprsme.Repo,
# Start the PubSub system
{Phoenix.PubSub, name: Aprs.PubSub},
{Phoenix.PubSub, name: Aprsme.PubSub},
# Start Finch
{Finch, name: Aprs.Finch},
{Finch, name: Aprsme.Finch},
# Start the Endpoint (http/https)
AprsWeb.Endpoint,
# Start a worker by calling: Aprs.Worker.start_link(arg)
# {Aprs.Worker, arg}
AprsmeWeb.Endpoint,
# Start a worker by calling: Aprsme.Worker.start_link(arg)
# {Aprsme.Worker, arg}
{Registry, keys: :duplicate, name: Registry.PubSub, partitions: System.schedulers_online()},
{Cluster.Supervisor, [topologies, [name: Aprs.ClusterSupervisor]]},
{Cluster.Supervisor, [topologies, [name: Aprsme.ClusterSupervisor]]},
# Start Oban for background jobs
{Oban, :aprs |> Application.get_env(Oban, []) |> Keyword.put(:queues, default: 10, maintenance: 2)},
Aprs.Presence,
Aprs.AprsIsConnection,
Aprs.PostgresNotifier
{Oban, :aprsme |> Application.get_env(Oban, []) |> Keyword.put(:queues, default: 10, maintenance: 2)},
Aprsme.Presence,
Aprsme.AprsIsConnection,
Aprsme.PostgresNotifier
]
children = maybe_add_is_supervisor(children, Application.get_env(:aprs, :env))
children = maybe_add_is_supervisor(children, Application.get_env(:aprsme, :env))
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Aprs.Supervisor]
opts = [strategy: :one_for_one, name: Aprsme.Supervisor]
{:ok, sup} = Supervisor.start_link(children, opts)
# Now that the Repo is started, run the refresh in a background task
Task.start(fn -> Aprs.DeviceIdentification.maybe_refresh_devices() end)
# Skip in test environment to avoid DBConnection.OwnershipError
env = Application.get_env(:aprsme, :env)
if env != :test do
Task.start(fn -> Aprsme.DeviceIdentification.maybe_refresh_devices() end)
end
{:ok, sup}
end
@ -53,12 +58,12 @@ defmodule Aprs.Application do
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
AprsWeb.Endpoint.config_change(changed, removed)
AprsmeWeb.Endpoint.config_change(changed, removed)
:ok
end
defp migrate do
auto_migrate = Application.get_env(:aprs, :auto_migrate, true)
auto_migrate = Application.get_env(:aprsme, :auto_migrate, true)
do_migrate(auto_migrate)
rescue
error ->
@ -70,7 +75,7 @@ defmodule Aprs.Application do
end
defp maybe_add_is_supervisor(children, env) when env in [:prod, :dev] do
children ++ [Aprs.Is.IsSupervisor]
children ++ [Aprsme.Is.IsSupervisor]
end
defp maybe_add_is_supervisor(children, _env), do: children
@ -79,7 +84,7 @@ defmodule Aprs.Application do
require Logger
Logger.info("Running database migrations...")
Aprs.Release.migrate()
Aprsme.Release.migrate()
Logger.info("Database migrations completed")
end

View file

@ -1,4 +1,4 @@
defmodule Aprs.AprsIsConnection do
defmodule Aprsme.AprsIsConnection do
@moduledoc """
Maintains a supervised TCP connection to APRS-IS, with reconnection logic and
exponential backoff. Broadcasts each received line via Phoenix.PubSub and emits
@ -43,13 +43,13 @@ defmodule Aprs.AprsIsConnection do
case connect_aprs_is() do
{:ok, socket} ->
Logger.info("Connected to APRS-IS")
:telemetry.execute([:aprs, :is, :connected], %{}, %{})
:telemetry.execute([:aprsme, :is, :connected], %{}, %{})
{:noreply, %{state | socket: socket, backoff: @reconnect_initial}}
{:error, reason} ->
Logger.error("APRS-IS connection failed: #{inspect(reason)}. Retrying in #{state.backoff} ms.")
:telemetry.execute([:aprs, :is, :connect_error], %{}, %{reason: reason})
:telemetry.execute([:aprsme, :is, :connect_error], %{}, %{reason: reason})
schedule_connect(state.backoff)
{:noreply, %{state | socket: nil, backoff: min(state.backoff * 2, @reconnect_max)}}
end
@ -58,15 +58,15 @@ defmodule Aprs.AprsIsConnection do
@impl true
def handle_info({:tcp, _socket, data}, state) do
# Each line received from APRS-IS
Phoenix.PubSub.broadcast(Aprs.PubSub, @pubsub_topic, {:aprs_is_line, data})
:telemetry.execute([:aprs, :is, :packet], %{size: byte_size(data)}, %{data: data})
Phoenix.PubSub.broadcast(Aprsme.PubSub, @pubsub_topic, {:aprsme_is_line, data})
:telemetry.execute([:aprsme, :is, :packet], %{size: byte_size(data)}, %{data: data})
{:noreply, state}
end
@impl true
def handle_info({:tcp_closed, _socket}, state) do
Logger.warning("APRS-IS connection closed. Reconnecting...")
:telemetry.execute([:aprs, :is, :disconnected], %{}, %{})
:telemetry.execute([:aprsme, :is, :disconnected], %{}, %{})
schedule_connect(@reconnect_initial)
{:noreply, %{state | socket: nil, backoff: @reconnect_initial}}
end
@ -74,7 +74,7 @@ defmodule Aprs.AprsIsConnection do
@impl true
def handle_info({:tcp_error, _socket, reason}, state) do
Logger.error("APRS-IS TCP error: #{inspect(reason)}. Reconnecting...")
:telemetry.execute([:aprs, :is, :tcp_error], %{}, %{reason: reason})
:telemetry.execute([:aprsme, :is, :tcp_error], %{}, %{reason: reason})
schedule_connect(@reconnect_initial)
{:noreply, %{state | socket: nil, backoff: @reconnect_initial}}
end
@ -102,11 +102,11 @@ defmodule Aprs.AprsIsConnection do
end
defp connect_aprs_is do
host = Application.get_env(:aprs, :aprs_is_host, ~c"rotate.aprs2.net")
port = Application.get_env(:aprs, :aprs_is_port, 14_580)
callsign = Application.get_env(:aprs, :aprs_is_callsign, "N0CALL")
passcode = Application.get_env(:aprs, :aprs_is_passcode, "00000")
filter = Application.get_env(:aprs, :aprs_is_filter, "")
host = Application.get_env(:aprsme, :aprsme_is_host, ~c"rotate.aprs2.net")
port = Application.get_env(:aprsme, :aprsme_is_port, 14_580)
callsign = Application.get_env(:aprsme, :aprsme_is_callsign, "N0CALL")
passcode = Application.get_env(:aprsme, :aprsme_is_passcode, "00000")
filter = Application.get_env(:aprsme, :aprsme_is_filter, "")
opts = [:binary, active: true, packet: :line, keepalive: true]

View file

@ -1,13 +1,13 @@
defmodule Aprs.Archiver do
defmodule Aprsme.Archiver do
@moduledoc false
use GenServer
alias AprsWeb.Endpoint
alias AprsmeWeb.Endpoint
require Jason
require Logger
# alias Aprs.{Packet, Repo}
# alias Aprsme.{Packet, Repo}
@topic "call"
# API

View file

@ -1,4 +1,4 @@
defmodule Aprs.BadPacket do
defmodule Aprsme.BadPacket do
@moduledoc false
use Ecto.Schema
@ -17,7 +17,7 @@ defmodule Aprs.BadPacket do
@type t :: %__MODULE__{}
@doc false
@spec changeset(Aprs.BadPacket.t(), map()) :: Ecto.Changeset.t()
@spec changeset(Aprsme.BadPacket.t(), map()) :: Ecto.Changeset.t()
def changeset(bad_packet, attrs) do
bad_packet
|> cast(attrs, [:raw_packet, :error_message, :error_type, :attempted_at])

View file

@ -1,4 +1,4 @@
defmodule Aprs.Convert do
defmodule Aprsme.Convert do
@moduledoc false
@spec wind(number(), :ultimeter, :mph) :: float()

View file

@ -1,13 +1,13 @@
defmodule Aprs.DataExtended do
defmodule Aprsme.DataExtended do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
alias Aprs.DataExtended
alias Aprsme.DataExtended
embedded_schema do
field :aprs_messaging, :boolean, default: false
field :aprsme_messaging, :boolean, default: false
field :comment, :string
field :data_type, :string
field :latitude, :decimal
@ -19,11 +19,11 @@ defmodule Aprs.DataExtended do
@type t :: %__MODULE__{}
@doc false
@spec changeset(Aprs.DataExtended.t(), map()) :: Ecto.Changeset.t()
@spec changeset(Aprsme.DataExtended.t(), map()) :: Ecto.Changeset.t()
def changeset(%DataExtended{} = data_extended, attrs) do
data_extended
|> cast(attrs, [
:aprs_messaging,
:aprsme_messaging,
:comment,
:data_type,
:latitude,
@ -32,7 +32,7 @@ defmodule Aprs.DataExtended do
:symbol_table_id
])
|> validate_required([
:aprs_messaging,
:aprsme_messaging,
:comment,
:data_type,
:symbol_code,

View file

@ -1,12 +1,12 @@
defmodule Aprs.DeviceIdentification do
defmodule Aprsme.DeviceIdentification do
@moduledoc """
Handles APRS device identification based on the APRS device identification database.
"""
import Ecto.Query
alias Aprs.Devices
alias Aprs.Repo
alias Aprsme.Devices
alias Aprsme.Repo
@device_patterns [
{~r/^ \x00\x00$/, "Original MIC-E"},
@ -41,13 +41,13 @@ defmodule Aprs.DeviceIdentification do
## Examples
iex> Aprs.DeviceIdentification.identify_device(">" <> <<0>> <> "^")
iex> Aprsme.DeviceIdentification.identify_device(">" <> <<0>> <> "^")
"Kenwood TH-D74"
iex> Aprs.DeviceIdentification.identify_device("`_#")
iex> Aprsme.DeviceIdentification.identify_device("`_#")
"Yaesu VX-8G"
iex> Aprs.DeviceIdentification.identify_device("not-a-match")
iex> Aprsme.DeviceIdentification.identify_device("not-a-match")
"Unknown"
"""
@spec identify_device(String.t()) :: String.t()
@ -64,10 +64,10 @@ defmodule Aprs.DeviceIdentification do
## Examples
iex> "Kenwood" in Aprs.DeviceIdentification.known_manufacturers()
iex> "Kenwood" in Aprsme.DeviceIdentification.known_manufacturers()
true
iex> Enum.member?(Aprs.DeviceIdentification.known_manufacturers(), "AP510")
iex> Enum.member?(Aprsme.DeviceIdentification.known_manufacturers(), "AP510")
true
"""
@spec known_manufacturers() :: [String.t()]
@ -93,10 +93,10 @@ defmodule Aprs.DeviceIdentification do
## Examples
iex> Aprs.DeviceIdentification.known_models("Kenwood")
iex> Aprsme.DeviceIdentification.known_models("Kenwood")
["TH-D74", "TH-D74A", "DM-710", "DM-700"]
iex> Aprs.DeviceIdentification.known_models("Unknown")
iex> Aprsme.DeviceIdentification.known_models("Unknown")
[]
"""
@spec known_models(String.t()) :: [String.t()]
@ -131,7 +131,7 @@ defmodule Aprs.DeviceIdentification do
def fetch_and_upsert_devices do
req = Finch.build(:get, @url)
case Finch.request(req, Aprs.Finch) do
case Finch.request(req, Aprsme.Finch) do
{:ok, %Finch.Response{status: 200, body: body}} ->
{:ok, json} = Jason.decode(body)
upsert_devices(json)
@ -170,7 +170,7 @@ defmodule Aprs.DeviceIdentification do
# Helper to enqueue the job
def enqueue_refresh_job do
Oban.insert!(Aprs.DeviceIdentification.Worker.new(%{}))
Oban.insert!(Aprsme.DeviceIdentification.Worker.new(%{}))
end
@doc """
@ -179,6 +179,8 @@ defmodule Aprs.DeviceIdentification do
Note: This function requires the devices table to be seeded and a running Repo context.
"""
def lookup_device_by_identifier(nil), do: nil
def lookup_device_by_identifier(identifier) when is_binary(identifier) do
# Fetch all device patterns from DB
devices = Repo.all(Devices)
@ -218,13 +220,13 @@ defmodule Aprs.DeviceIdentification do
end
end
defmodule Aprs.DeviceIdentification.Worker do
defmodule Aprsme.DeviceIdentification.Worker do
@moduledoc false
use Oban.Worker, queue: :default, max_attempts: 1
@impl true
def perform(_job) do
Aprs.DeviceIdentification.maybe_refresh_devices()
Aprsme.DeviceIdentification.maybe_refresh_devices()
:ok
end
end

View file

@ -0,0 +1,32 @@
defmodule Aprsme.DeviceParser do
@moduledoc """
Extracts device identifiers from APRS packet data.
"""
@doc """
Extracts a device identifier from packet data.
Returns the device identifier string or nil if not found.
"""
@spec extract_device_identifier(map()) :: String.t() | nil
def extract_device_identifier(packet_data) do
# Try to extract from various possible locations
packet_data
|> Map.get(:device_identifier)
|> case do
nil -> extract_from_data_extended(packet_data)
identifier -> identifier
end
end
defp extract_from_data_extended(packet_data) do
data_extended = Map.get(packet_data, :data_extended) || %{}
# Try to extract from symbol information
symbol_table_id = Map.get(data_extended, :symbol_table_id) || Map.get(data_extended, "symbol_table_id")
symbol_code = Map.get(data_extended, :symbol_code) || Map.get(data_extended, "symbol_code")
if symbol_table_id && symbol_code do
"#{symbol_table_id}#{symbol_code}"
end
end
end

View file

@ -1,4 +1,4 @@
defmodule Aprs.Devices do
defmodule Aprsme.Devices do
@moduledoc false
use Ecto.Schema

View file

@ -1,9 +1,9 @@
# test/support/devices_seeder.exs
alias Aprs.Devices
alias Aprs.Repo
defmodule DevicesSeeder do
defmodule Aprsme.DevicesSeeder do
@moduledoc false
alias Aprsme.Devices
alias Aprsme.Repo
def seed_from_json(path \\ "test/support/test_devices.json") do
{:ok, body} = File.read(path)
{:ok, json} = Jason.decode(body)

View file

@ -1,4 +1,4 @@
defmodule Aprs.EncodingUtils do
defmodule Aprsme.EncodingUtils do
@moduledoc """
Utilities for handling encoding issues in APRS packet data.
@ -7,7 +7,7 @@ defmodule Aprs.EncodingUtils do
to web clients.
"""
alias Parser.Types.MicE
alias Aprs.Types.MicE
@doc """
Sanitizes a binary to ensure it can be safely JSON encoded.
@ -18,10 +18,10 @@ defmodule Aprs.EncodingUtils do
## Examples
iex> Aprs.EncodingUtils.sanitize_string("Hello World")
iex> Aprsme.EncodingUtils.sanitize_string("Hello World")
"Hello World"
iex> Aprs.EncodingUtils.sanitize_string(<<72, 101, 108, 108, 111, 211, 87, 111, 114, 108, 100>>)
iex> Aprsme.EncodingUtils.sanitize_string(<<72, 101, 108, 108, 111, 211, 87, 111, 114, 108, 100>>)
"HelloÓWorld"
"""
@spec sanitize_string(binary() | nil | any()) :: binary() | nil | any()
@ -49,7 +49,7 @@ defmodule Aprs.EncodingUtils do
Sanitizes all string fields in an APRS packet to ensure safe JSON encoding.
"""
@spec sanitize_packet(struct() | map()) :: struct() | map()
def sanitize_packet(%Aprs.Packet{} = packet) do
def sanitize_packet(%Aprsme.Packet{} = packet) do
%{
packet
| information_field: sanitize_string(packet.information_field),
@ -97,7 +97,7 @@ defmodule Aprs.EncodingUtils do
## Examples
iex> Aprs.EncodingUtils.to_hex(<<72, 101, 108, 108, 111>>)
iex> Aprsme.EncodingUtils.to_hex(<<72, 101, 108, 108, 111>>)
"48656C6C6F"
"""
@spec to_hex(binary()) :: String.t()
@ -113,10 +113,10 @@ defmodule Aprs.EncodingUtils do
## Examples
iex> Aprs.EncodingUtils.encoding_info("Hello")
iex> Aprsme.EncodingUtils.encoding_info("Hello")
%{valid_utf8: true, byte_count: 5, char_count: 5}
iex> Aprs.EncodingUtils.encoding_info(<<72, 101, 211, 108, 111>>)
iex> Aprsme.EncodingUtils.encoding_info(<<72, 101, 211, 108, 111>>)
%{valid_utf8: false, byte_count: 5, invalid_at: 2}
"""
@spec encoding_info(binary()) :: map()

View file

@ -1,4 +1,4 @@
defmodule Aprs.Is do
defmodule Aprsme.Is do
@moduledoc false
use GenServer
@ -29,8 +29,8 @@ defmodule Aprs.Is do
@impl true
def init(_opts) do
env = Application.get_env(:aprs, :env)
disable_connection = Application.get_env(:aprs, :disable_aprs_connection, false)
env = Application.get_env(:aprsme, :env)
disable_connection = Application.get_env(:aprsme, :disable_aprs_connection, false)
do_init(env, disable_connection)
end
@ -53,11 +53,11 @@ defmodule Aprs.Is do
Process.sleep(2000)
# Get startup parameters
server = Application.get_env(:aprs, :aprs_is_server, ~c"rotate.aprs2.net")
port = Application.get_env(:aprs, :aprs_is_port, 14_580)
default_filter = Application.get_env(:aprs, :aprs_is_default_filter, "r/33/-96/100")
aprs_user_id = Application.get_env(:aprs, :aprs_is_login_id, "W5ISP")
aprs_passcode = Application.get_env(:aprs, :aprs_is_password, "-1")
server = Application.get_env(:aprsme, :aprsme_is_server, ~c"rotate.aprs2.net")
port = Application.get_env(:aprsme, :aprsme_is_port, 14_580)
default_filter = Application.get_env(:aprsme, :aprsme_is_default_filter, "r/33/-96/100")
aprs_user_id = Application.get_env(:aprsme, :aprsme_is_login_id, "W5ISP")
aprs_passcode = Application.get_env(:aprsme, :aprsme_is_password, "-1")
# Record connection start time
connected_at = DateTime.utc_now()
@ -95,7 +95,7 @@ defmodule Aprs.Is do
else
_ ->
Logger.error("Unable to establish connection or log in to APRS-IS")
{:stop, :aprs_connection_failed}
{:stop, :aprsme_connection_failed}
end
end
@ -110,8 +110,8 @@ defmodule Aprs.Is do
case Process.whereis(__MODULE__) do
nil ->
# GenServer is not running (disconnected)
server = Application.get_env(:aprs, :aprs_is_server, ~c"rotate.aprs2.net")
port = Application.get_env(:aprs, :aprs_is_port, 14_580)
server = Application.get_env(:aprsme, :aprsme_is_server, nil)
port = Application.get_env(:aprsme, :aprsme_is_port, 14_580)
%{
connected: false,
@ -119,10 +119,10 @@ defmodule Aprs.Is do
port: port,
connected_at: nil,
uptime_seconds: 0,
login_id: Application.get_env(:aprs, :aprs_is_login_id, "W5ISP"),
filter: Application.get_env(:aprs, :aprs_is_default_filter, "r/33/-96/100"),
login_id: Application.get_env(:aprsme, :aprsme_is_login_id, "W5ISP"),
filter: Application.get_env(:aprsme, :aprsme_is_default_filter, "r/33/-96/100"),
packet_stats: default_packet_stats(),
stored_packet_count: Aprs.Packets.get_total_packet_count()
stored_packet_count: Aprsme.Packets.get_total_packet_count()
}
_pid ->
@ -131,8 +131,8 @@ defmodule Aprs.Is do
catch
:exit, _ ->
# GenServer exists but not responding
server = Application.get_env(:aprs, :aprs_is_server, ~c"rotate.aprs2.net")
port = Application.get_env(:aprs, :aprs_is_port, 14_580)
server = Application.get_env(:aprsme, :aprsme_is_server, ~c"rotate.aprs2.net")
port = Application.get_env(:aprsme, :aprsme_is_port, 14_580)
%{
connected: false,
@ -140,10 +140,10 @@ defmodule Aprs.Is do
port: port,
connected_at: nil,
uptime_seconds: 0,
login_id: Application.get_env(:aprs, :aprs_is_login_id, "W5ISP"),
filter: Application.get_env(:aprs, :aprs_is_default_filter, "r/33/-96/100"),
login_id: Application.get_env(:aprsme, :aprsme_is_login_id, "W5ISP"),
filter: Application.get_env(:aprsme, :aprsme_is_default_filter, "r/33/-96/100"),
packet_stats: default_packet_stats(),
stored_packet_count: Aprs.Packets.get_total_packet_count()
stored_packet_count: Aprsme.Packets.get_total_packet_count()
}
end
end
@ -167,8 +167,8 @@ defmodule Aprs.Is do
{:ok, :ssl.sslsocket()} | {:error, any()}
defp connect_to_aprs_is(server, port) do
# Additional safeguard: prevent connections in test environment
env = Application.get_env(:aprs, :env)
disable_connection = Application.get_env(:aprs, :disable_aprs_connection, false)
env = Application.get_env(:aprsme, :env)
disable_connection = Application.get_env(:aprsme, :disable_aprs_connection, false)
do_connect_to_aprs_is(server, port, env, disable_connection)
end
@ -186,7 +186,8 @@ defmodule Aprs.Is do
defp do_connect_to_aprs_is(server, port, _env, false) do
Logger.debug("Connecting to: #{server}:#{port}")
opts = [:binary, active: true]
:gen_tcp.connect(String.to_charlist(server), port, opts)
server_string = if is_list(server), do: List.to_string(server), else: server
:gen_tcp.connect(String.to_charlist(server_string), port, opts)
end
@spec send_login_string(:ssl.sslsocket(), String.t(), String.t(), String.t()) ::
@ -202,7 +203,7 @@ defmodule Aprs.Is do
@spec create_timer(non_neg_integer()) :: reference()
defp create_timer(timeout) do
Process.send_after(self(), :aprs_no_message_timeout, timeout)
Process.send_after(self(), :aprsme_no_message_timeout, timeout)
end
@spec create_keepalive_timer(non_neg_integer()) :: reference()
@ -212,7 +213,7 @@ defmodule Aprs.Is do
@impl true
def handle_call({:send_message, message}, _from, state) do
next_ack_number = :ets.update_counter(:aprs, :message_number, 1)
next_ack_number = :ets.update_counter(:aprsme, :message_number, 1)
# Append ack number
message = message <> "{" <> to_string(next_ack_number) <> "\r"
@ -231,16 +232,16 @@ defmodule Aprs.Is do
login_id: state.login_params.user_id,
filter: state.login_params.filter,
packet_stats: state.packet_stats,
stored_packet_count: Aprs.Packets.get_total_packet_count()
stored_packet_count: Aprsme.Packets.get_total_packet_count()
}
{:reply, status, state}
end
@impl true
def handle_info(:aprs_no_message_timeout, state) do
def handle_info(:aprsme_no_message_timeout, state) do
Logger.error("Socket timeout detected. Killing genserver.")
{:stop, :aprs_timeout, state}
{:stop, :aprsme_timeout, state}
end
def handle_info(:send_keepalive, state) do
@ -283,7 +284,7 @@ defmodule Aprs.Is do
end)
# Start a new timer
timer = Process.send_after(self(), :aprs_no_message_timeout, @aprs_timeout)
timer = Process.send_after(self(), :aprsme_no_message_timeout, @aprs_timeout)
state =
state
@ -318,7 +319,7 @@ defmodule Aprs.Is do
end)
# Start a new timer
timer = Process.send_after(self(), :aprs_no_message_timeout, @aprs_timeout)
timer = Process.send_after(self(), :aprsme_no_message_timeout, @aprs_timeout)
state =
state
@ -408,7 +409,7 @@ defmodule Aprs.Is do
def dispatch(""), do: nil
def dispatch(message) do
case Parser.parse(message) do
case Aprs.parse(message) do
{:ok, parsed_message} ->
# Store the packet in the database for future replay
# Use Task to avoid slowing down the main process
@ -424,13 +425,13 @@ defmodule Aprs.Is do
attrs = struct_to_map(packet_data)
# Extract additional data from the parsed packet including raw packet
attrs = Aprs.Packet.extract_additional_data(attrs, message)
attrs = Aprsme.Packet.extract_additional_data(attrs, message)
# Normalize data_type to string if it's an atom
attrs = normalize_data_type(attrs)
# Store in database through the Packets context
case Aprs.Packets.store_packet(attrs) do
case Aprsme.Packets.store_packet(attrs) do
{:ok, _packet} ->
# Packet stored successfully
:ok
@ -455,19 +456,19 @@ defmodule Aprs.Is do
end)
# Broadcast to live clients
AprsWeb.Endpoint.broadcast("aprs_messages", "packet", parsed_message)
AprsmeWeb.Endpoint.broadcast("aprs_messages", "packet", parsed_message)
{:error, :invalid_packet} ->
Logger.debug("PARSE ERROR: invalid packet")
Aprs.Packets.store_bad_packet(message, %{
Aprsme.Packets.store_bad_packet(message, %{
message: "Invalid packet format",
type: "ParseError"
})
{:error, error} ->
Logger.debug("PARSE ERROR: " <> error)
Aprs.Packets.store_bad_packet(message, %{message: error, type: "ParseError"})
Aprsme.Packets.store_bad_packet(message, %{message: error, type: "ParseError"})
end
end

View file

@ -1,4 +1,4 @@
defmodule Aprs.Is.IsSupervisor do
defmodule Aprsme.Is.IsSupervisor do
@moduledoc false
use Supervisor
@ -9,7 +9,7 @@ defmodule Aprs.Is.IsSupervisor do
@impl true
def init(:ok) do
children = [
{Aprs.Is, []}
{Aprsme.Is, []}
]
# Supervisor will restart children max 3 times in 5 seconds

4
lib/aprsme/mailer.ex Normal file
View file

@ -0,0 +1,4 @@
defmodule Aprsme.Mailer do
@moduledoc false
use Swoosh.Mailer, otp_app: :aprsme
end

View file

@ -1,11 +1,11 @@
defmodule Aprs.Packet do
defmodule Aprsme.Packet do
@moduledoc false
use Aprs.Schema
use Aprsme.Schema
import Ecto.Changeset
alias Aprs.DataExtended
alias Parser.Types.MicE
alias Aprs.Types.MicE
alias Aprsme.DataExtended
schema "packets" do
field(:base_callsign, :string)
@ -30,7 +30,7 @@ defmodule Aprs.Packet do
# Additional packet data
field(:comment, :string)
field(:timestamp, :string)
field(:aprs_messaging, :boolean, default: false)
field(:aprsme_messaging, :boolean, default: false)
# Weather data
field(:temperature, :float)
@ -65,7 +65,7 @@ defmodule Aprs.Packet do
@type t :: %__MODULE__{}
@doc false
@spec changeset(Aprs.Packet.t(), map()) :: Ecto.Changeset.t()
@spec changeset(Aprsme.Packet.t(), map()) :: Ecto.Changeset.t()
def changeset(packet, attrs) do
# Convert atom data_type to string
attrs = normalize_data_type(attrs)
@ -90,7 +90,7 @@ defmodule Aprs.Packet do
:symbol_table_id,
:comment,
:timestamp,
:aprs_messaging,
:aprsme_messaging,
:temperature,
:humidity,
:wind_speed,
@ -277,8 +277,8 @@ defmodule Aprs.Packet do
|> maybe_put(:comment, data_extended[:comment] || data_extended["comment"])
|> maybe_put(:timestamp, data_extended[:timestamp] || data_extended["timestamp"])
|> maybe_put(
:aprs_messaging,
data_extended[:aprs_messaging?] || data_extended["aprs_messaging?"]
:aprsme_messaging,
data_extended[:aprsme_messaging?] || data_extended["aprs_messaging?"]
)
end
@ -519,14 +519,14 @@ defmodule Aprs.Packet do
@doc """
Get latitude from a packet's location geometry.
"""
@spec lat(Aprs.Packet.t()) :: number() | nil
@spec lat(Aprsme.Packet.t()) :: number() | nil
def lat(%__MODULE__{location: %Geo.Point{coordinates: {_lon, lat}}}), do: lat
def lat(_), do: nil
@doc """
Get longitude from a packet's location geometry.
"""
@spec lon(Aprs.Packet.t()) :: number() | nil
@spec lon(Aprsme.Packet.t()) :: number() | nil
def lon(%__MODULE__{location: %Geo.Point{coordinates: {lon, _lat}}}), do: lon
def lon(_), do: nil

View file

@ -1,4 +1,4 @@
defmodule Aprs.PacketReplay do
defmodule Aprsme.PacketReplay do
@moduledoc """
Handles replaying of historical APRS packets alongside live packets.
@ -11,8 +11,8 @@ defmodule Aprs.PacketReplay do
use GenServer
alias Aprs.Packets
alias AprsWeb.Endpoint
alias Aprsme.Packets
alias AprsmeWeb.Endpoint
require Logger
@ -393,7 +393,7 @@ defmodule Aprs.PacketReplay do
@spec via_tuple(String.t()) :: {:via, Registry, {atom(), String.t()}}
defp via_tuple(user_id) do
{:via, Registry, {Aprs.ReplayRegistry, "replay:#{user_id}"}}
{:via, Registry, {Aprsme.ReplayRegistry, "replay:#{user_id}"}}
end
@spec sanitize_packet_for_transport(any()) :: map()

View file

@ -1,10 +1,10 @@
defmodule Aprs.PacketReplayBehaviour do
defmodule Aprsme.PacketReplayBehaviour do
@moduledoc """
Behavior definition for the PacketReplay module.
This allows us to mock the PacketReplay module in tests.
"""
@callback start_replay(pid(), list(Aprs.Packet.t()), keyword()) :: {:ok, reference()} | {:error, term()}
@callback start_replay(pid(), list(Aprsme.Packet.t()), keyword()) :: {:ok, reference()} | {:error, term()}
@callback stop_replay(reference()) :: :ok
@callback pause_replay(reference()) :: :ok | {:error, :not_found}
@callback resume_replay(reference()) :: :ok | {:error, :not_found}

View file

@ -1,16 +1,16 @@
defmodule Aprs.Packets do
defmodule Aprsme.Packets do
@moduledoc """
The Packets context.
"""
@behaviour Aprs.PacketsBehaviour
@behaviour Aprsme.PacketsBehaviour
import Ecto.Query, warn: false
alias Aprs.BadPacket
alias Aprs.Packet
alias Aprs.Repo
alias Parser.Types.MicE
alias Aprs.Types.MicE
alias Aprsme.BadPacket
alias Aprsme.Packet
alias Aprsme.Repo
@doc """
Stores a packet in the database.
@ -34,8 +34,8 @@ defmodule Aprs.Packets do
end)
|> normalize_ssid()
|> then(fn attrs ->
device_identifier = Parser.DeviceParser.extract_device_identifier(packet_data)
matched_device = Aprs.DeviceIdentification.lookup_device_by_identifier(device_identifier)
device_identifier = Aprsme.DeviceParser.extract_device_identifier(packet_data)
matched_device = Aprsme.DeviceIdentification.lookup_device_by_identifier(device_identifier)
canonical_identifier = if matched_device, do: matched_device.identifier, else: device_identifier
Map.put(attrs, :device_identifier, canonical_identifier)
end)
@ -460,7 +460,7 @@ defmodule Aprs.Packets do
"""
@impl true
def clean_old_packets do
retention_days = Application.get_env(:aprs, :packet_retention_days, 365)
retention_days = Application.get_env(:aprsme, :packet_retention_days, 365)
cutoff_time = DateTime.add(DateTime.utc_now(), -retention_days * 86_400, :second)
{deleted_count, _} = Repo.delete_all(from(p in Packet, where: p.received_at < ^cutoff_time))
@ -533,7 +533,7 @@ defmodule Aprs.Packets do
defp sanitize_packet_strings(list) when is_list(list), do: Enum.map(list, &sanitize_packet_strings/1)
defp sanitize_packet_strings(binary) when is_binary(binary) do
s = Aprs.EncodingUtils.sanitize_string(binary)
s = Aprsme.EncodingUtils.sanitize_string(binary)
if is_binary(s), do: s, else: ""
end

View file

@ -1,4 +1,4 @@
defmodule Aprs.PacketsBehaviour do
defmodule Aprsme.PacketsBehaviour do
@moduledoc """
Behaviour for Packets module to allow mocking in tests
"""

View file

@ -1,4 +1,4 @@
defmodule Aprs.Passcode do
defmodule Aprsme.Passcode do
@moduledoc """
Module for generating APRS passcodes from callsigns.
The passcode is a hash of the callsign used for authentication with APRS-IS servers.
@ -14,7 +14,7 @@ defmodule Aprs.Passcode do
- The generated passcode as an integer
## Examples
iex> Aprs.Passcode.generate("W5ISP")
iex> Aprsme.Passcode.generate("W5ISP")
15748
"""
@spec generate(String.t()) :: non_neg_integer()

View file

@ -1,4 +1,4 @@
defmodule Aprs.PostgresNotifier do
defmodule Aprsme.PostgresNotifier do
@moduledoc """
Listens to PostgreSQL NOTIFY events on the "aprs_events" and "aprs_packets" channels and broadcasts
them via Phoenix.PubSub for reactive, event-driven updates.
@ -6,9 +6,9 @@ defmodule Aprs.PostgresNotifier do
use GenServer
@event_channel "aprs_events"
@event_topic "postgres:aprs_events"
@event_topic "postgres:aprsme_events"
@packet_channel "aprs_packets"
@packet_topic "postgres:aprs_packets"
@packet_topic "postgres:aprsme_packets"
def start_link(_opts) do
GenServer.start_link(__MODULE__, nil, name: __MODULE__)
@ -16,7 +16,7 @@ defmodule Aprs.PostgresNotifier do
@impl true
def init(_) do
{:ok, conn} = Postgrex.Notifications.start_link(Aprs.Repo.config())
{:ok, conn} = Postgrex.Notifications.start_link(Aprsme.Repo.config())
{:ok, _ref1} = Postgrex.Notifications.listen(conn, @event_channel)
{:ok, _ref2} = Postgrex.Notifications.listen(conn, @packet_channel)
{:ok, %{conn: conn}}
@ -24,14 +24,14 @@ defmodule Aprs.PostgresNotifier do
@impl true
def handle_info({:notification, _conn, _pid, @event_channel, payload}, state) do
Phoenix.PubSub.broadcast(Aprs.PubSub, @event_topic, {:postgres_notify, payload})
Phoenix.PubSub.broadcast(Aprsme.PubSub, @event_topic, {:postgres_notify, payload})
{:noreply, state}
end
def handle_info({:notification, _conn, _pid, @packet_channel, payload}, state) do
case Jason.decode(payload) do
{:ok, packet} ->
Phoenix.PubSub.broadcast(Aprs.PubSub, @packet_topic, {:postgres_packet, packet})
Phoenix.PubSub.broadcast(Aprsme.PubSub, @packet_topic, {:postgres_packet, packet})
_ ->
:noop

View file

@ -1,5 +1,5 @@
Postgrex.Types.define(
Aprs.PostgresTypes,
Aprsme.PostgresTypes,
[Geo.PostGIS.Extension] ++ Ecto.Adapters.Postgres.extensions(),
json: Jason
)

4
lib/aprsme/presence.ex Normal file
View file

@ -0,0 +1,4 @@
defmodule Aprsme.Presence do
@moduledoc false
use Phoenix.Presence, otp_app: :aprsme, pubsub_server: Aprsme.PubSub
end

View file

@ -1,9 +1,9 @@
defmodule Aprs.Release do
defmodule Aprsme.Release do
@moduledoc """
Used for executing DB release tasks when run in production without Mix
installed.
"""
@app :aprs
@app :aprsme
def migrate do
require Logger
@ -12,7 +12,7 @@ defmodule Aprs.Release do
Logger.info("Deployment timestamp: #{deployed_at}")
# Run migrations
{:ok, _, _} = Ecto.Migrator.with_repo(Aprs.Repo, &Ecto.Migrator.run(&1, :up, all: true))
{:ok, _, _} = Ecto.Migrator.with_repo(Aprsme.Repo, &Ecto.Migrator.run(&1, :up, all: true))
end
def rollback(repo, version) do
@ -33,7 +33,7 @@ defmodule Aprs.Release do
deployed_at = read_deployment_timestamp()
# Add to application config
Application.put_env(:aprs, :deployed_at, deployed_at)
Application.put_env(:aprsme, :deployed_at, deployed_at)
deployed_at
end
@ -42,7 +42,7 @@ defmodule Aprs.Release do
Get the deployment timestamp.
"""
def deployed_at do
Application.get_env(:aprs, :deployed_at) || DateTime.utc_now()
Application.get_env(:aprsme, :deployed_at) || DateTime.utc_now()
end
defp read_deployment_timestamp do

View file

@ -1,5 +1,5 @@
defmodule Aprs.Repo do
defmodule Aprsme.Repo do
use Ecto.Repo,
otp_app: :aprs,
otp_app: :aprsme,
adapter: Ecto.Adapters.Postgres
end

View file

@ -1,4 +1,4 @@
defmodule Aprs.Schema do
defmodule Aprsme.Schema do
@moduledoc false
defmacro __using__(_) do
quote do

View file

@ -1,4 +1,4 @@
defmodule Aprs.Workers.PacketCleanupWorker do
defmodule Aprsme.Workers.PacketCleanupWorker do
@moduledoc """
Oban worker for cleaning up old APRS packet data.
@ -18,8 +18,8 @@ defmodule Aprs.Workers.PacketCleanupWorker do
# Import modules needed for database operations
alias Aprs.Packet
alias Aprs.Repo
alias Aprsme.Packet
alias Aprsme.Repo
require Logger
@ -52,7 +52,7 @@ defmodule Aprs.Workers.PacketCleanupWorker do
deleted_count = cleanup_old_packets()
# Log results
retention_days = Application.get_env(:aprs, :packet_retention_days, 365)
retention_days = Application.get_env(:aprsme, :packet_retention_days, 365)
Logger.info("APRS packet cleanup complete: removed #{deleted_count} packets older than #{retention_days} days")
@ -92,6 +92,6 @@ defmodule Aprs.Workers.PacketCleanupWorker do
end
defp packets_module do
Application.get_env(:aprs, :packets_module, Aprs.Packets)
Application.get_env(:aprsme, :packets_module, Aprsme.Packets)
end
end

View file

@ -1,4 +1,4 @@
defmodule AprsWeb.CoreComponents do
defmodule AprsmeWeb.CoreComponents do
@moduledoc """
Provides core UI components.
@ -10,7 +10,7 @@ defmodule AprsWeb.CoreComponents do
[heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project.
"""
use Phoenix.Component
use Gettext, backend: AprsWeb.Gettext
use Gettext, backend: AprsmeWeb.Gettext
alias Phoenix.HTML.Form
alias Phoenix.LiveView.JS
@ -620,9 +620,9 @@ defmodule AprsWeb.CoreComponents do
# should be written to the errors.po file. The :count option is
# set by Ecto and indicates we should also apply plural rules.
if count = opts[:count] do
Gettext.dngettext(AprsWeb.Gettext, "errors", msg, msg, count, opts)
Gettext.dngettext(AprsmeWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(AprsWeb.Gettext, "errors", msg, opts)
Gettext.dgettext(AprsmeWeb.Gettext, "errors", msg, opts)
end
end

View file

@ -1,6 +1,6 @@
defmodule AprsWeb.Layouts do
defmodule AprsmeWeb.Layouts do
@moduledoc false
use AprsWeb, :html
use AprsmeWeb, :html
embed_templates "layouts/*"

View file

@ -1,13 +1,13 @@
defmodule AprsWeb.Api.V1.CallsignController do
defmodule AprsmeWeb.Api.V1.CallsignController do
@moduledoc """
API v1 controller for callsign-related endpoints.
"""
use AprsWeb, :controller
use AprsmeWeb, :controller
alias Aprs.Packets
alias AprsWeb.Api.V1.CallsignJSON
alias Aprsme.Packets
alias AprsmeWeb.Api.V1.CallsignJSON
action_fallback AprsWeb.Api.V1.FallbackController
action_fallback AprsmeWeb.Api.V1.FallbackController
@doc """
Get the most recent packet for a given callsign.

View file

@ -1,18 +1,18 @@
defmodule AprsWeb.Api.V1.FallbackController do
defmodule AprsmeWeb.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
use AprsmeWeb, :controller
alias AprsWeb.Api.V1.ErrorJSON
alias AprsmeWeb.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)
|> put_view(json: AprsmeWeb.Api.V1.ChangesetJSON)
|> render(:error, changeset: changeset)
end

View file

@ -1,9 +1,9 @@
defmodule AprsWeb.Api.V1.CallsignJSON do
defmodule AprsmeWeb.Api.V1.CallsignJSON do
@moduledoc """
Renders callsign and packet data for API v1.
"""
alias Aprs.Packet
alias Aprsme.Packet
def render("show.json", %{packet: packet}) do
%{data: packet_json(packet)}
@ -38,7 +38,7 @@ defmodule AprsWeb.Api.V1.CallsignJSON do
symbol: symbol_json(packet),
comment: packet.comment,
timestamp: packet.timestamp,
aprs_messaging: packet.aprs_messaging,
aprs_messaging: packet.aprsme_messaging,
weather: weather_json(packet),
equipment: equipment_json(packet),
message: message_json(packet),

View file

@ -1,4 +1,4 @@
defmodule AprsWeb.Api.V1.ChangesetJSON do
defmodule AprsmeWeb.Api.V1.ChangesetJSON do
@moduledoc """
Renders changeset errors for API v1.
"""

View file

@ -1,4 +1,4 @@
defmodule AprsWeb.Api.V1.ErrorJSON do
defmodule AprsmeWeb.Api.V1.ErrorJSON do
@moduledoc """
Renders error responses for API v1.
"""

View file

@ -1,6 +1,6 @@
defmodule AprsWeb.ErrorHTML do
defmodule AprsmeWeb.ErrorHTML do
@moduledoc false
use AprsWeb, :html
use AprsmeWeb, :html
# If you want to customize your error pages,
# uncomment the embed_templates/1 call below

View file

@ -1,4 +1,4 @@
defmodule AprsWeb.ErrorJSON do
defmodule AprsmeWeb.ErrorJSON do
@moduledoc false
# If you want to customize a particular status code,
# you may add your own clauses, such as:

View file

@ -1,6 +1,6 @@
defmodule AprsWeb.PageController do
defmodule AprsmeWeb.PageController do
@moduledoc false
use AprsWeb, :controller
use AprsmeWeb, :controller
def home(conn, _params) do
render(conn, :home)
@ -14,14 +14,14 @@ defmodule AprsWeb.PageController do
# Check database connection
db_healthy =
try do
Aprs.Repo.query!("SELECT 1")
Aprsme.Repo.query!("SELECT 1")
true
rescue
_ -> false
end
# Get application version
version = :aprs |> Application.spec(:vsn) |> List.to_string()
version = :aprsme |> Application.spec(:vsn) |> List.to_string()
if db_healthy do
json(conn, %{
@ -45,7 +45,7 @@ defmodule AprsWeb.PageController do
def ready(conn, _params) do
# Simple readiness check without database dependency
# This is faster for startup health checks
version = :aprs |> Application.spec(:vsn) |> List.to_string()
version = :aprsme |> Application.spec(:vsn) |> List.to_string()
json(conn, %{
status: "ready",
@ -56,10 +56,10 @@ defmodule AprsWeb.PageController do
def status_json(conn, _params) do
# Get APRS-IS connection status
aprs_status = Aprs.Is.get_status()
aprs_status = Aprsme.Is.get_status()
# Get application version
version = :aprs |> Application.spec(:vsn) |> List.to_string()
version = :aprsme |> Application.spec(:vsn) |> List.to_string()
# Calculate uptime in a human-readable format
uptime_display = format_uptime(aprs_status.uptime_seconds)

View file

@ -1,6 +1,6 @@
defmodule AprsWeb.PageHTML do
defmodule AprsmeWeb.PageHTML do
@moduledoc false
use AprsWeb, :html
use AprsmeWeb, :html
embed_templates "page_html/*"
end

View file

@ -1,9 +1,9 @@
defmodule AprsWeb.UserSessionController do
defmodule AprsmeWeb.UserSessionController do
@moduledoc false
use AprsWeb, :controller
use AprsmeWeb, :controller
alias Aprs.Accounts
alias AprsWeb.UserAuth
alias Aprsme.Accounts
alias AprsmeWeb.UserAuth
def create(conn, %{"_action" => "registered"} = params) do
create(conn, params, "Account created successfully!")

View file

@ -1,7 +1,7 @@
defmodule AprsWeb.Endpoint do
defmodule AprsmeWeb.Endpoint do
@moduledoc false
use Phoenix.Endpoint, otp_app: :aprs
use Phoenix.Endpoint, otp_app: :aprsme
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
@ -21,7 +21,7 @@ defmodule AprsWeb.Endpoint do
# when deploying your static files in production.
plug Plug.Static,
at: "/",
from: :aprs,
from: :aprsme,
gzip: false,
only: ~w(assets fonts images aprs-symbols favicon.ico robots.txt)
@ -31,7 +31,7 @@ defmodule AprsWeb.Endpoint do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :aprs
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :aprsme
end
plug Phoenix.LiveDashboard.RequestLogger,
@ -49,5 +49,5 @@ defmodule AprsWeb.Endpoint do
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug AprsWeb.Router
plug AprsmeWeb.Router
end

View file

@ -1,11 +1,11 @@
defmodule AprsWeb.Gettext do
defmodule AprsmeWeb.Gettext do
@moduledoc """
A module providing Internationalization with a gettext-based API.
By using [Gettext](https://hexdocs.pm/gettext),
your module gains a set of macros for translations, for example:
import AprsWeb.Gettext
import AprsmeWeb.Gettext
# Simple translation
gettext("Here is the string to translate")
@ -20,5 +20,5 @@ defmodule AprsWeb.Gettext do
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
"""
use Gettext.Backend, otp_app: :aprs
use Gettext.Backend, otp_app: :aprsme
end

View file

@ -1,6 +1,6 @@
defmodule AprsWeb.AboutLive do
defmodule AprsmeWeb.AboutLive do
@moduledoc false
use AprsWeb, :live_view
use AprsmeWeb, :live_view
@impl true
def mount(_params, _session, socket) do

View file

@ -1,8 +1,8 @@
defmodule AprsWeb.ApiDocsLive do
defmodule AprsmeWeb.ApiDocsLive do
@moduledoc """
LiveView for API documentation page.
"""
use AprsWeb, :live_view
use AprsmeWeb, :live_view
@impl true
def mount(_params, _session, socket) do
@ -60,7 +60,7 @@ defmodule AprsWeb.ApiDocsLive do
end
defp make_http_request(callsign) do
alias Aprs.Packets
alias Aprsme.Packets
# Validate callsign format (same as controller)
callsign_regex = ~r/^[A-Z0-9]{1,3}[0-9][A-Z]{1,4}(-[0-9]{1,2})?$/
@ -112,7 +112,7 @@ defmodule AprsWeb.ApiDocsLive do
"symbol" => format_symbol(packet),
"comment" => packet.comment,
"timestamp" => packet.timestamp,
"aprs_messaging" => packet.aprs_messaging,
"aprsme_messaging" => packet.aprsme_messaging,
"weather" => format_weather(packet),
"equipment" => format_equipment(packet),
"message" => format_message(packet),
@ -373,7 +373,7 @@ defmodule AprsWeb.ApiDocsLive do
},
"comment": "Mobile Station",
"timestamp": null,
"aprs_messaging": false,
"aprsme_messaging": false,
"weather": null,
"equipment": {
"manufacturer": "Kenwood",

View file

@ -1,17 +1,17 @@
defmodule AprsWeb.BadPacketsLive.Index do
defmodule AprsmeWeb.BadPacketsLive.Index do
@moduledoc false
use AprsWeb, :live_view
use AprsmeWeb, :live_view
import Ecto.Query
alias Aprs.BadPacket
alias Aprs.Repo
alias Aprsme.BadPacket
alias Aprsme.Repo
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
# Subscribe to Postgres notifications for bad packets
Phoenix.PubSub.subscribe(Aprs.PubSub, "postgres:aprs_events")
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_events")
# Load initial bad packets
bad_packets = fetch_bad_packets()
# Extra safeguard to ensure we never show more than 100

View file

@ -1,9 +1,9 @@
defmodule AprsWeb.InfoLive.Show do
defmodule AprsmeWeb.InfoLive.Show do
@moduledoc false
use AprsWeb, :live_view
use AprsmeWeb, :live_view
alias Aprs.Packets
alias AprsWeb.MapLive.PacketUtils
alias Aprsme.Packets
alias AprsmeWeb.MapLive.PacketUtils
@neighbor_radius_km 10
@neighbor_limit 10

View file

@ -1,4 +1,4 @@
<% {symbol_table_id, symbol_code} = AprsWeb.MapLive.PacketUtils.get_symbol_info(@packet || %{}) %>
<% {symbol_table_id, symbol_code} = AprsmeWeb.MapLive.PacketUtils.get_symbol_info(@packet || %{}) %>
<% symbol_table = if symbol_table_id in ["/", "\\", "]"], do: symbol_table_id, else: "/" %>
<% symbol_code = symbol_code || ">" %>
<% symbol_table_num =
@ -13,14 +13,14 @@
|> String.to_charlist()
|> List.first()
|> (fn c -> if is_integer(c), do: c, else: 63 end).() %>
<% symbol_img = "/aprs-symbols/aprs-symbols-24-#{symbol_table_num}@2x.png" %>
<% symbol_index = symbol_code_ord - 33 %>
<% _symbol_img = "/aprs-symbols/aprs-symbols-24-#{symbol_table_num}@2x.png" %>
<% _symbol_index = symbol_code_ord - 33 %>
<div class="max-w-3xl mx-auto mt-10 p-8 bg-white rounded-xl shadow-lg">
<h1 class="text-3xl font-bold mb-2 flex items-center gap-2">
APRS station <span class="text-blue-700">{@callsign}</span>
<%= if @packet do %>
<% {symbol_table_id, symbol_code} = AprsWeb.MapLive.PacketUtils.get_symbol_info(@packet) %>
<% {symbol_table_id, symbol_code} = AprsmeWeb.MapLive.PacketUtils.get_symbol_info(@packet) %>
<% symbol_table = if symbol_table_id in ["/", "\\", "]"], do: symbol_table_id, else: "/" %>
<% symbol_code = symbol_code || ">" %>
<% symbol_table_num =
@ -57,7 +57,7 @@
<span class="font-semibold">Location:</span> {@packet.lat}, {@packet.lon}
</div>
<div class="mb-2">
<span class="font-semibold">Last position:</span> {AprsWeb.MapLive.PacketUtils.get_timestamp(
<span class="font-semibold">Last position:</span> {AprsmeWeb.MapLive.PacketUtils.get_timestamp(
@packet
)}
</div>
@ -92,7 +92,7 @@
</.link>
<%= if neighbor.packet do %>
<% {symbol_table_id, symbol_code} =
AprsWeb.MapLive.PacketUtils.get_symbol_info(neighbor.packet) %>
AprsmeWeb.MapLive.PacketUtils.get_symbol_info(neighbor.packet) %>
<% symbol_table =
if symbol_table_id in ["/", "\\", "]"], do: symbol_table_id, else: "/" %>
<% symbol_code = symbol_code || ">" %>

View file

@ -1,11 +1,11 @@
defmodule AprsWeb.MapLive.CallsignView do
defmodule AprsmeWeb.MapLive.CallsignView do
@moduledoc false
use AprsWeb, :live_view
use AprsmeWeb, :live_view
alias Aprs.Packets
alias AprsWeb.Endpoint
alias AprsWeb.MapLive.MapHelpers
alias AprsWeb.MapLive.PacketUtils
alias Aprsme.Packets
alias AprsmeWeb.Endpoint
alias AprsmeWeb.MapLive.MapHelpers
alias AprsmeWeb.MapLive.PacketUtils
@default_center %{lat: 39.0, lng: -98.0}
@default_zoom 4

View file

@ -1,34 +1,34 @@
defmodule AprsWeb.MapLive.Index do
defmodule AprsmeWeb.MapLive.Index do
@moduledoc """
LiveView for displaying real-time APRS packets on a map
"""
use AprsWeb, :live_view
use AprsmeWeb, :live_view
import AprsWeb.TimeHelpers, only: [time_ago_in_words: 1]
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
alias AprsWeb.Endpoint
alias AprsWeb.MapLive.MapHelpers
alias AprsWeb.MapLive.PacketUtils
alias AprsmeWeb.Endpoint
alias AprsmeWeb.MapLive.MapHelpers
alias AprsmeWeb.MapLive.PacketUtils
alias Phoenix.LiveView.Socket
@default_center %{lat: 39.8283, lng: -98.5795}
@default_zoom 5
@finch_name Aprs.Finch
@initialize_replay_delay Application.compile_env(:aprs, :initialize_replay_delay, 500)
@finch_name Aprsme.Finch
@initialize_replay_delay Application.compile_env(:aprsme, :initialize_replay_delay, 500)
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
# Subscribe to packet updates
Phoenix.PubSub.subscribe(Aprs.PubSub, "packets")
Phoenix.PubSub.subscribe(Aprs.PubSub, "bad_packets")
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets")
Phoenix.PubSub.subscribe(Aprsme.PubSub, "bad_packets")
# Schedule periodic cleanup of old packets
Process.send_after(self(), :cleanup_old_packets, 60_000)
end
# Get deployment timestamp from config (set during application startup)
deployed_at = Aprs.Release.deployed_at()
deployed_at = Aprsme.Release.deployed_at()
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
@ -38,7 +38,7 @@ defmodule AprsWeb.MapLive.Index do
if connected?(socket) do
Endpoint.subscribe("aprs_messages")
Phoenix.PubSub.subscribe(Aprs.PubSub, "postgres:aprs_packets")
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
maybe_start_geolocation(socket)
end
@ -99,7 +99,7 @@ defmodule AprsWeb.MapLive.Index do
defp maybe_start_geolocation(socket) do
if geolocation_enabled?() do
ip_for_geolocation =
if Application.get_env(:aprs, AprsWeb.Endpoint)[:code_reloader] do
if Application.get_env(:aprsme, AprsmeWeb.Endpoint)[:code_reloader] do
# For testing geolocation in dev environment, use a public IP address.
# This will be geolocated to Mountain View, CA.
"8.8.8.8"
@ -116,7 +116,7 @@ defmodule AprsWeb.MapLive.Index do
end
defp geolocation_enabled? do
Application.get_env(:aprs, :disable_aprs_connection, false) != true
Application.get_env(:aprsme, :disable_aprs_connection, false) != true
end
defp extract_ip(socket) do
@ -1175,7 +1175,7 @@ defmodule AprsWeb.MapLive.Index do
}
# Call the database through the Packets context
packets_module = Application.get_env(:aprs, :packets_module, Aprs.Packets)
packets_module = Application.get_env(:aprsme, :packets_module, Aprsme.Packets)
packets = packets_module.get_packets_for_replay(packets_params)
# Sort packets by received_at timestamp to ensure chronological replay

View file

@ -1,7 +1,7 @@
defmodule AprsWeb.MapLive.MapHelpers do
defmodule AprsmeWeb.MapLive.MapHelpers do
@moduledoc false
alias Parser.Types.MicE
alias Aprs.Types.MicE
@spec get_coordinates(map() | struct()) :: {number() | nil, number() | nil, map() | nil}
def get_coordinates(%{data_extended: %MicE{} = mic_e}) do

View file

@ -1,10 +1,10 @@
defmodule AprsWeb.MapLive.PacketUtils do
defmodule AprsmeWeb.MapLive.PacketUtils do
@moduledoc """
Shared utilities for extracting and processing packet data in map views.
"""
alias AprsWeb.MapLive.MapHelpers
alias AprsWeb.TimeHelpers
alias AprsmeWeb.MapLive.MapHelpers
alias AprsmeWeb.TimeHelpers
@doc """
Safely extracts a value from a packet or data_extended map with fallback support.

View file

@ -1,14 +1,14 @@
defmodule AprsWeb.PacketsLive.CallsignView do
defmodule AprsmeWeb.PacketsLive.CallsignView do
@moduledoc false
use AprsWeb, :live_view
use AprsmeWeb, :live_view
import Ecto.Query
alias Aprs.EncodingUtils
alias Aprs.Packet
alias Aprs.Repo
alias AprsWeb.Endpoint
alias Parser.DeviceParser
alias Aprsme.DeviceParser
alias Aprsme.EncodingUtils
alias Aprsme.Packet
alias Aprsme.Repo
alias AprsmeWeb.Endpoint
@impl true
def mount(%{"callsign" => callsign}, _session, socket) do
@ -66,7 +66,7 @@ defmodule AprsWeb.PacketsLive.CallsignView do
canonical_identifier =
if is_binary(device_identifier) do
matched_device = Aprs.DeviceIdentification.lookup_device_by_identifier(device_identifier)
matched_device = Aprsme.DeviceIdentification.lookup_device_by_identifier(device_identifier)
if matched_device, do: matched_device.identifier, else: device_identifier
else
device_identifier
@ -208,7 +208,10 @@ defmodule AprsWeb.PacketsLive.CallsignView do
defp enrich_packet_with_device_info(packet) do
device_identifier = Map.get(packet, :device_identifier) || Map.get(packet, "device_identifier")
device = if is_binary(device_identifier), do: Aprs.DeviceIdentification.lookup_device_by_identifier(device_identifier)
device =
if is_binary(device_identifier), do: Aprsme.DeviceIdentification.lookup_device_by_identifier(device_identifier)
model = if device, do: device.model
vendor = if device, do: device.vendor

View file

@ -1,15 +1,15 @@
defmodule AprsWeb.PacketsLive.Index do
defmodule AprsmeWeb.PacketsLive.Index do
@moduledoc false
use AprsWeb, :live_view
use AprsmeWeb, :live_view
alias Aprs.EncodingUtils
alias AprsWeb.Endpoint
alias Aprsme.EncodingUtils
alias AprsmeWeb.Endpoint
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Endpoint.subscribe("aprs_messages")
Phoenix.PubSub.subscribe(Aprs.PubSub, "postgres:aprs_packets")
Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets")
end
{:ok, assign(socket, :packets, [])}
@ -26,7 +26,7 @@ defmodule AprsWeb.PacketsLive.Index do
@impl true
def handle_info({:postgres_packet, payload}, socket) do
sanitized_payload = Aprs.EncodingUtils.sanitize_packet(payload)
sanitized_payload = Aprsme.EncodingUtils.sanitize_packet(payload)
packets = Enum.take([sanitized_payload | socket.assigns.packets], 100)
socket = assign(socket, :packets, packets)
{:noreply, socket}

View file

@ -51,8 +51,8 @@
Map.get(packet, :lat) ||
if(
Map.has_key?(packet, :location) and not is_nil(packet.location) and
function_exported?(Aprs.Packet, :lat, 1),
do: Aprs.Packet.lat(packet),
function_exported?(Aprsme.Packet, :lat, 1),
do: Aprsme.Packet.lat(packet),
else: nil
) ||
case Map.get(packet, :data_extended) do
@ -86,8 +86,8 @@
Map.get(packet, :lon) ||
if(
Map.has_key?(packet, :location) and not is_nil(packet.location) and
function_exported?(Aprs.Packet, :lon, 1),
do: Aprs.Packet.lon(packet),
function_exported?(Aprsme.Packet, :lon, 1),
do: Aprsme.Packet.lon(packet),
else: nil
) ||
case Map.get(packet, :data_extended) do

View file

@ -1,8 +1,8 @@
defmodule AprsWeb.StatusLive.Index do
defmodule AprsmeWeb.StatusLive.Index do
@moduledoc """
LiveView for displaying real-time APRS-IS connection status
"""
use AprsWeb, :live_view
use AprsmeWeb, :live_view
# 30 seconds
@refresh_interval 1_000
@ -327,11 +327,11 @@ defmodule AprsWeb.StatusLive.Index do
# Private functions
defp get_aprs_status do
Aprs.Is.get_status()
Aprsme.Is.get_status()
end
defp get_app_version do
:aprs |> Application.spec(:vsn) |> List.to_string()
:aprsme |> Application.spec(:vsn) |> List.to_string()
end
defp refresh_status(socket) do

View file

@ -1,8 +1,8 @@
defmodule AprsWeb.UserConfirmationInstructionsLive do
defmodule AprsmeWeb.UserConfirmationInstructionsLive do
@moduledoc false
use AprsWeb, :live_view
use AprsmeWeb, :live_view
alias Aprs.Accounts
alias Aprsme.Accounts
def render(assigns) do
~H"""

View file

@ -1,8 +1,8 @@
defmodule AprsWeb.UserConfirmationLive do
defmodule AprsmeWeb.UserConfirmationLive do
@moduledoc false
use AprsWeb, :live_view
use AprsmeWeb, :live_view
alias Aprs.Accounts
alias Aprsme.Accounts
def render(%{live_action: :edit} = assigns) do
~H"""

View file

@ -1,8 +1,8 @@
defmodule AprsWeb.UserForgotPasswordLive do
defmodule AprsmeWeb.UserForgotPasswordLive do
@moduledoc false
use AprsWeb, :live_view
use AprsmeWeb, :live_view
alias Aprs.Accounts
alias Aprsme.Accounts
def render(assigns) do
~H"""

View file

@ -1,9 +1,9 @@
defmodule AprsWeb.UserLoginLive do
defmodule AprsmeWeb.UserLoginLive do
@moduledoc false
use AprsWeb, :live_view
use AprsmeWeb, :live_view
alias Aprs.Accounts
alias Aprs.Accounts.User
alias Aprsme.Accounts
alias Aprsme.Accounts.User
def render(assigns) do
~H"""

View file

@ -1,9 +1,9 @@
defmodule AprsWeb.UserRegistrationLive do
defmodule AprsmeWeb.UserRegistrationLive do
@moduledoc false
use AprsWeb, :live_view
use AprsmeWeb, :live_view
alias Aprs.Accounts
alias Aprs.Accounts.User
alias Aprsme.Accounts
alias Aprsme.Accounts.User
def render(assigns) do
~H"""

View file

@ -1,8 +1,8 @@
defmodule AprsWeb.UserResetPasswordLive do
defmodule AprsmeWeb.UserResetPasswordLive do
@moduledoc false
use AprsWeb, :live_view
use AprsmeWeb, :live_view
alias Aprs.Accounts
alias Aprsme.Accounts
def render(assigns) do
~H"""

View file

@ -1,8 +1,8 @@
defmodule AprsWeb.UserSettingsLive do
defmodule AprsmeWeb.UserSettingsLive do
@moduledoc false
use AprsWeb, :live_view
use AprsmeWeb, :live_view
alias Aprs.Accounts
alias Aprsme.Accounts
def render(assigns) do
~H"""

View file

@ -1,9 +1,9 @@
defmodule AprsWeb.WeatherLive.CallsignView do
defmodule AprsmeWeb.WeatherLive.CallsignView do
@moduledoc false
use AprsWeb, :live_view
use AprsmeWeb, :live_view
alias Aprs.Packets
alias AprsWeb.MapLive.PacketUtils
alias Aprsme.Packets
alias AprsmeWeb.MapLive.PacketUtils
@impl true
def mount(%{"callsign" => callsign}, _session, socket) do

View file

@ -1,14 +1,14 @@
defmodule AprsWeb.Router do
use AprsWeb, :router
defmodule AprsmeWeb.Router do
use AprsmeWeb, :router
import AprsWeb.UserAuth
import AprsmeWeb.UserAuth
import Phoenix.LiveDashboard.Router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {AprsWeb.Layouts, :root}
plug :put_root_layout, {AprsmeWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
@ -18,17 +18,17 @@ defmodule AprsWeb.Router do
plug :accepts, ["json"]
end
scope "/", AprsWeb do
scope "/", AprsmeWeb do
pipe_through :api
get "/health", PageController, :health
get "/ready", PageController, :ready
get "/status.json", PageController, :status_json
end
scope "/", AprsWeb do
scope "/", AprsmeWeb do
pipe_through :browser
live_dashboard "/dashboard", metrics: AprsWeb.Telemetry
live_dashboard "/dashboard", metrics: AprsmeWeb.Telemetry
live_session :regular_pages do
live "/status", StatusLive.Index, :index
@ -41,21 +41,21 @@ defmodule AprsWeb.Router do
live "/info/:callsign", InfoLive.Show, :show
end
live_session :map_pages, layout: {AprsWeb.Layouts, :map} do
live_session :map_pages, layout: {AprsmeWeb.Layouts, :map} do
live "/", MapLive.Index, :index
live "/:callsign", MapLive.CallsignView, :index
end
end
# API v1 routes
scope "/api/v1", AprsWeb.Api.V1, as: :api_v1 do
scope "/api/v1", AprsmeWeb.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
if Application.compile_env(:aprsme, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
@ -65,18 +65,18 @@ defmodule AprsWeb.Router do
scope "/dev" do
pipe_through :browser
# live_dashboard "/dashboard", metrics: AprsWeb.Telemetry
# live_dashboard "/dashboard", metrics: AprsmeWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
## Authentication routes
scope "/", AprsWeb do
scope "/", AprsmeWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
live_session :redirect_if_user_is_authenticated,
on_mount: [{AprsWeb.UserAuth, :redirect_if_user_is_authenticated}] do
on_mount: [{AprsmeWeb.UserAuth, :redirect_if_user_is_authenticated}] do
live "/users/register", UserRegistrationLive, :new
live "/users/log_in", UserLoginLive, :new
live "/users/reset_password", UserForgotPasswordLive, :new
@ -86,23 +86,23 @@ defmodule AprsWeb.Router do
post "/users/log_in", UserSessionController, :create
end
scope "/", AprsWeb do
scope "/", AprsmeWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{AprsWeb.UserAuth, :ensure_authenticated}] do
on_mount: [{AprsmeWeb.UserAuth, :ensure_authenticated}] do
live "/users/settings", UserSettingsLive, :edit
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
end
end
scope "/", AprsWeb do
scope "/", AprsmeWeb do
pipe_through [:browser]
delete "/users/log_out", UserSessionController, :delete
live_session :current_user,
on_mount: [{AprsWeb.UserAuth, :mount_current_user}] do
on_mount: [{AprsmeWeb.UserAuth, :mount_current_user}] do
live "/users/confirm/:token", UserConfirmationLive, :edit
live "/users/confirm", UserConfirmationInstructionsLive, :new
end

View file

@ -1,4 +1,4 @@
defmodule AprsWeb.Telemetry do
defmodule AprsmeWeb.Telemetry do
@moduledoc false
use Supervisor
@ -54,23 +54,23 @@ defmodule AprsWeb.Telemetry do
),
# Database Metrics
summary("aprs.repo.query.total_time",
summary("aprsme.repo.query.total_time",
unit: {:native, :millisecond},
description: "The sum of the other measurements"
),
summary("aprs.repo.query.decode_time",
summary("aprsme.repo.query.decode_time",
unit: {:native, :millisecond},
description: "The time spent decoding the data received from the database"
),
summary("aprs.repo.query.query_time",
summary("aprsme.repo.query.query_time",
unit: {:native, :millisecond},
description: "The time spent executing the query"
),
summary("aprs.repo.query.queue_time",
summary("aprsme.repo.query.queue_time",
unit: {:native, :millisecond},
description: "The time spent waiting for a database connection"
),
summary("aprs.repo.query.idle_time",
summary("aprsme.repo.query.idle_time",
unit: {:native, :millisecond},
description: "The time the connection spent waiting before being checked out for the query"
),
@ -87,7 +87,7 @@ defmodule AprsWeb.Telemetry do
[
# A module, function and arguments to be invoked periodically.
# This function must call :telemetry.execute/3 and a metric must be added above.
# {AprsWeb, :count_users, []}
# {AprsmeWeb, :count_users, []}
]
end
end

View file

@ -1,4 +1,4 @@
defmodule AprsWeb.TimeHelpers do
defmodule AprsmeWeb.TimeHelpers do
@moduledoc """
Shared helpers for formatting time and dates in the web layer.
"""

View file

@ -1,11 +1,11 @@
defmodule AprsWeb.UserAuth do
defmodule AprsmeWeb.UserAuth do
@moduledoc false
use AprsWeb, :verified_routes
use AprsmeWeb, :verified_routes
import Phoenix.Controller
import Plug.Conn
alias Aprs.Accounts
alias Aprsme.Accounts
# Make the remember me cookie valid for 60 days.
# If you want bump or reduce this value, also change
@ -74,7 +74,7 @@ defmodule AprsWeb.UserAuth do
user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
AprsWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
AprsmeWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
@ -131,16 +131,16 @@ defmodule AprsWeb.UserAuth do
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
the current_user:
defmodule AprsWeb.PageLive do
use AprsWeb, :live_view
defmodule AprsmeWeb.PageLive do
use AprsmeWeb, :live_view
on_mount {AprsWeb.UserAuth, :mount_current_user}
on_mount {AprsmeWeb.UserAuth, :mount_current_user}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{AprsWeb.UserAuth, :ensure_authenticated}] do
live_session :authenticated, on_mount: [{AprsmeWeb.UserAuth, :ensure_authenticated}] do
live "/profile", ProfileLive, :index
end
"""

View file

@ -1,970 +0,0 @@
defmodule Parser do
@moduledoc """
Main parsing library
"""
alias Aprs.Convert
alias Parser.Item
alias Parser.MicE
alias Parser.Object
alias Parser.PHG
alias Parser.Status
alias Parser.Telemetry
alias Parser.Weather
# Simple APRS position parsing to replace parse_aprs_position
defp parse_aprs_position(lat, lon) do
# Regex for latitude: 2 deg, 2+ min, 1 dir (N/S)
# Regex for longitude: 3 deg, 2+ min, 1 dir (E/W)
lat_re = ~r/^(\d{2})(\d{2}\.\d+)([NS])$/
lon_re = ~r/^(\d{3})(\d{2}\.\d+)([EW])$/
with [_, lat_deg, lat_min, lat_dir] <- Regex.run(lat_re, lat),
[_, lon_deg, lon_min, lon_dir] <- Regex.run(lon_re, lon) do
lat_val =
Decimal.add(Decimal.new(lat_deg), Decimal.div(Decimal.new(lat_min), Decimal.new("60")))
lon_val =
Decimal.add(Decimal.new(lon_deg), Decimal.div(Decimal.new(lon_min), Decimal.new("60")))
lat = if lat_dir == "S", do: Decimal.negate(lat_val), else: lat_val
lon = if lon_dir == "W", do: Decimal.negate(lon_val), else: lon_val
%{latitude: lat, longitude: lon}
else
_ -> %{latitude: nil, longitude: nil}
end
end
@type packet :: %{
id: String.t(),
sender: String.t(),
path: String.t(),
destination: String.t(),
information_field: String.t(),
data_type: atom(),
base_callsign: String.t(),
ssid: String.t(),
data_extended: map() | nil,
received_at: DateTime.t()
}
@type parse_result :: {:ok, packet()} | {:error, atom() | String.t()}
@type position_ambiguity :: 0..4
@spec parse(String.t()) :: parse_result()
def parse(message) when is_binary(message) do
do_parse(message)
rescue
_ ->
{:error, :invalid_packet}
end
def parse(_), do: {:error, :invalid_packet}
@spec do_parse(String.t()) :: parse_result()
defp do_parse(message) do
with {:ok, [sender, path, data]} <- split_packet(message),
{:ok, callsign_parts} <- parse_callsign(sender),
{:ok, data_type} <- parse_datatype_safe(data),
{:ok, [destination, path]} <- split_path(path),
:ok <- validate_path(path) do
data_trimmed = String.trim(data)
data_without_type = String.slice(data_trimmed, 1..-1//1)
data_extended = parse_data(data_type, destination, data_without_type)
base_callsign = List.first(callsign_parts)
ssid =
case List.last(callsign_parts) do
nil ->
nil
s when is_binary(s) ->
s
i when is_integer(i) ->
to_string(i)
_ ->
nil
end
{:ok,
%{
id: 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower),
sender: sender,
path: path,
destination: destination,
information_field: data_trimmed,
data_type: data_type,
base_callsign: base_callsign,
ssid: ssid,
data_extended: data_extended,
received_at: DateTime.truncate(DateTime.utc_now(), :microsecond)
}}
end
rescue
_ ->
{:error, :invalid_packet}
end
# Validate path for too many components
def validate_path(path) when is_binary(path) and path != "" do
if length(String.split(path, ",")) > 8 do
{:error, "Too many path components"}
else
:ok
end
end
def validate_path(_), do: :ok
# Safely split packet into components
@spec split_packet(String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
def split_packet(message) do
split_packet_parts(String.split(message, [">", ":"], parts: 3))
end
@spec split_packet_parts([String.t()]) :: {:ok, [String.t()]} | {:error, String.t()}
defp split_packet_parts([sender, path, data]) when byte_size(sender) > 0 and byte_size(path) > 0 do
{:ok, [sender, path, data]}
end
@spec split_packet_parts(list()) :: {:error, String.t()}
defp split_packet_parts(_), do: {:error, "Invalid packet format"}
# Safely split path into destination and digipeater path
@spec split_path(String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
def split_path(path) when is_binary(path) do
split = String.split(path, ",", parts: 2)
split_path_parts(split)
end
defp split_path_parts([destination, digi_path]), do: {:ok, [destination, digi_path]}
defp split_path_parts([destination]), do: {:ok, [destination, ""]}
defp split_path_parts(_), do: {:error, "Invalid path format"}
# Safe version of parse_datatype that returns {:ok, type} or {:error, reason}
@spec parse_datatype_safe(String.t()) :: {:ok, atom()} | {:error, String.t()}
def parse_datatype_safe(""), do: {:error, "Empty data"}
def parse_datatype_safe(data), do: {:ok, parse_datatype(data)}
@spec parse_callsign(String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
def parse_callsign(callsign) do
case Parser.AX25.parse_callsign(callsign) do
{:ok, {base, ssid}} -> {:ok, [base, ssid]}
{:error, reason} -> {:error, reason}
end
end
# One of the nutty exceptions in the APRS protocol has to do with this
# data type indicator. It's usually the first character of the message.
# However, in some rare cases, the ! indicator can be anywhere in the
# first 40 characters of the message. I'm not going to deal with that
# weird case right now. It seems like its for a specific type of old
# TNC hardware that probably doesn't even exist anymore.
@spec parse_datatype(String.t()) :: atom()
def parse_datatype(<<":", _::binary>>), do: :message
def parse_datatype(<<">", _::binary>>), do: :status
def parse_datatype("!" <> _), do: :position
def parse_datatype("/" <> _), do: :timestamped_position
def parse_datatype("=" <> _), do: :position_with_message
def parse_datatype("@" <> _), do: :timestamped_position_with_message
def parse_datatype(";" <> _), do: :object
def parse_datatype("`" <> _), do: :mic_e_old
def parse_datatype("'" <> _), do: :mic_e_old
def parse_datatype("_" <> _), do: :weather
def parse_datatype("T" <> _), do: :telemetry
def parse_datatype("$" <> _), do: :raw_gps_ultimeter
def parse_datatype("<" <> _), do: :station_capabilities
def parse_datatype("?" <> _), do: :query
def parse_datatype("{" <> _), do: :user_defined
def parse_datatype("}" <> _), do: :third_party_traffic
def parse_datatype("%" <> _), do: :item
def parse_datatype(")" <> _), do: :item
def parse_datatype("*" <> _), do: :peet_logging
def parse_datatype("," <> _), do: :invalid_test_data
def parse_datatype("#DFS" <> _), do: :df_report
def parse_datatype("#PHG" <> _), do: :phg_data
def parse_datatype("#" <> _), do: :phg_data
def parse_datatype(_), do: :unknown_datatype
@spec parse_data(atom(), String.t(), String.t()) :: map() | nil
def parse_data(:mic_e, destination, data), do: MicE.parse(data, destination)
def parse_data(:mic_e_old, destination, data), do: MicE.parse(data, destination)
def parse_data(:object, _destination, data), do: Object.parse(data)
def parse_data(:item, _destination, data), do: Item.parse(data)
def parse_data(:weather, _destination, data), do: Weather.parse(data)
def parse_data(:telemetry, _destination, data), do: Telemetry.parse(data)
def parse_data(:status, _destination, data), do: Status.parse(data)
def parse_data(:phg_data, _destination, data), do: PHG.parse(data)
def parse_data(:peet_logging, _destination, data), do: Parser.SpecialDataHelpers.parse_peet_logging(data)
def parse_data(:invalid_test_data, _destination, data), do: Parser.SpecialDataHelpers.parse_invalid_test_data(data)
def parse_data(:raw_gps_ultimeter, _destination, data) do
case Parser.NMEAHelpers.parse_nmea_sentence(data) do
{:error, error} ->
%{
data_type: :raw_gps_ultimeter,
error: error,
nmea_type: nil,
raw_data: data,
latitude: nil,
longitude: nil
}
end
end
def parse_data(:df_report, _destination, data) do
if String.starts_with?(data, "DFS") and byte_size(data) >= 7 do
<<"DFS", s, h, g, d, rest::binary>> = data
%{
df_strength: Parser.PHGHelpers.parse_df_strength(s),
height: Parser.PHGHelpers.parse_phg_height(h),
gain: Parser.PHGHelpers.parse_phg_gain(g),
directivity: Parser.PHGHelpers.parse_phg_directivity(d),
comment: rest,
data_type: :df_report
}
else
%{
df_data: data,
data_type: :df_report
}
end
end
def parse_data(:user_defined, _destination, data), do: parse_user_defined(data)
def parse_data(:third_party_traffic, _destination, data), do: parse_third_party_traffic(data)
def parse_data(:message, _destination, data) do
case Regex.run(~r/^:([^:]+):(.+?)(\{(\d+)\})?$/s, data) do
[_, addressee, message_text, _full_ack, message_number] ->
%{
data_type: :message,
addressee: String.trim(addressee),
message_text: String.trim(message_text),
message_number: message_number
}
[_, addressee, message_text] ->
%{
data_type: :message,
addressee: String.trim(addressee),
message_text: String.trim(message_text)
}
_ ->
nil
end
end
def parse_data(:position, destination, <<"!", rest::binary>>) do
parse_data(:position, destination, rest)
end
def parse_data(:position, _destination, <<"/", _::binary>> = data) do
result = parse_position_without_timestamp(data)
if result.data_type == :malformed_position, do: result, else: %{result | data_type: :position}
end
def parse_data(:position, _destination, data) do
result = parse_position_without_timestamp(data)
if result.data_type == :malformed_position, do: result, else: %{result | data_type: :position}
end
def parse_data(:position_with_message, _destination, data) do
result = parse_position_with_message_without_timestamp(data)
%{result | data_type: :position}
end
def parse_data(:timestamped_position, _destination, data) do
parse_position_with_timestamp(false, data, :timestamped_position)
end
def parse_data(:timestamped_position_with_message, _destination, data) do
case data do
<<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9),
symbol_code::binary-size(1), rest::binary>> ->
weather_start = String.starts_with?(rest, "_")
if weather_start do
result =
Parser.parse_position_with_datetime_and_weather(
true,
time,
latitude,
sym_table_id,
longitude,
symbol_code,
rest
)
add_has_location(result)
else
result = parse_position_with_timestamp(true, data, :timestamped_position_with_message)
add_has_location(result)
end
_ ->
result = parse_position_with_timestamp(true, data, :timestamped_position_with_message)
add_has_location(result)
end
end
def parse_data(:station_capabilities, _destination, data), do: parse_station_capabilities(data)
def parse_data(:query, _destination, data), do: parse_query(data)
# Catch-all for unknown or unsupported types
def parse_data(_type, _destination, _data), do: nil
defp add_has_location(result) do
Map.put(
result,
:has_location,
(is_number(result[:latitude]) or is_struct(result[:latitude], Decimal)) and
(is_number(result[:longitude]) or is_struct(result[:longitude], Decimal))
)
end
@spec parse_position_with_datetime_and_weather(
boolean(),
binary(),
binary(),
binary(),
binary(),
binary(),
binary()
) :: map()
def parse_position_with_datetime_and_weather(
aprs_messaging?,
time,
latitude,
sym_table_id,
longitude,
symbol_code,
weather_report
) do
pos = parse_aprs_position(latitude, longitude)
weather_data = Parser.Weather.parse_weather_data(weather_report)
%{
latitude: pos.latitude,
longitude: pos.longitude,
timestamp: time,
symbol_table_id: sym_table_id,
symbol_code: symbol_code,
weather: weather_data,
data_type: :position_with_datetime_and_weather,
aprs_messaging?: aprs_messaging?
}
end
@spec decode_compressed_position(binary()) :: map()
def decode_compressed_position(
<<"/", latitude::binary-size(4), longitude::binary-size(4), symbol_code::binary-size(1), _cs::binary-size(2),
_compression_type::binary-size(2), _rest::binary>>
) do
lat = Parser.CompressedPositionHelpers.convert_to_base91(latitude)
lon = Parser.CompressedPositionHelpers.convert_to_base91(longitude)
%{
latitude: lat,
longitude: lon,
symbol_code: symbol_code
}
end
@spec convert_to_base91(binary()) :: integer()
def convert_to_base91(<<value::binary-size(4)>>) do
[v1, v2, v3, v4] = to_charlist(value)
(v1 - 33) * 91 * 91 * 91 + (v2 - 33) * 91 * 91 + (v3 - 33) * 91 + v4
end
# Helper to extract course and speed from comment field (e.g., "/123/045" or "123/045")
@spec extract_course_and_speed(String.t()) :: {integer() | nil, float() | nil}
defp extract_course_and_speed(comment) do
# Match "/123/045" or "123/045" at the start of the comment
case Regex.run(~r"/?(\d{3})/(\d{3})", comment) do
[_, course, speed] -> {String.to_integer(course), String.to_integer(speed) * 1.0}
_ -> {nil, nil}
end
end
# Patch parse_position_without_timestamp to include course/speed
@spec parse_position_without_timestamp(String.t()) :: map()
def parse_position_without_timestamp(position_data) do
case position_data do
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9), symbol_code::binary-size(1),
comment::binary>> ->
parse_position_uncompressed(latitude, sym_table_id, longitude, symbol_code, comment)
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> ->
parse_position_short_uncompressed(latitude, sym_table_id, longitude)
<<"/", latitude_compressed::binary-size(4), longitude_compressed::binary-size(4), symbol_code::binary-size(1),
cs::binary-size(2), compression_type::binary-size(1), comment::binary>> ->
parse_position_compressed(
latitude_compressed,
longitude_compressed,
symbol_code,
cs,
compression_type,
comment
)
_ ->
parse_position_malformed(position_data)
end
end
defp parse_position_uncompressed(latitude, sym_table_id, longitude, symbol_code, comment) do
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
ambiguity = Parser.UtilityHelpers.calculate_position_ambiguity(latitude, longitude)
dao_data = parse_dao_extension(comment)
{course, speed} = extract_course_and_speed(comment)
has_position =
(is_number(lat) or is_struct(lat, Decimal)) and (is_number(lon) or is_struct(lon, Decimal))
base_map = %{
latitude: lat,
longitude: lon,
timestamp: nil,
symbol_table_id: sym_table_id,
symbol_code: symbol_code,
comment: comment,
data_type: :position,
aprs_messaging?: false,
compressed?: false,
position_ambiguity: ambiguity,
dao: dao_data,
course: course,
speed: speed,
has_position: has_position
}
if sym_table_id == "/" and symbol_code == "_" do
weather_map = Parser.Weather.parse_weather_data(comment)
Map.merge(base_map, weather_map)
else
base_map
end
end
defp parse_position_short_uncompressed(latitude, sym_table_id, longitude) do
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
ambiguity = Parser.UtilityHelpers.calculate_position_ambiguity(latitude, longitude)
has_position =
(is_number(lat) or is_struct(lat, Decimal)) and (is_number(lon) or is_struct(lon, Decimal))
%{
latitude: lat,
longitude: lon,
timestamp: nil,
symbol_table_id: sym_table_id,
symbol_code: "_",
data_type: :position,
aprs_messaging?: false,
compressed?: false,
position_ambiguity: ambiguity,
dao: nil,
course: nil,
speed: nil,
has_position: has_position
}
end
defp parse_position_compressed(latitude_compressed, longitude_compressed, symbol_code, cs, compression_type, comment) do
converted_lat = Parser.CompressedPositionHelpers.convert_compressed_lat(latitude_compressed)
converted_lon = Parser.CompressedPositionHelpers.convert_compressed_lon(longitude_compressed)
compressed_cs = Parser.CompressedPositionHelpers.convert_compressed_cs(cs)
ambiguity = Parser.CompressedPositionHelpers.calculate_compressed_ambiguity(compression_type)
has_position =
(is_number(converted_lat) or is_struct(converted_lat, Decimal)) and
(is_number(converted_lon) or is_struct(converted_lon, Decimal))
base_data = %{
latitude: converted_lat,
longitude: converted_lon,
symbol_table_id: "/",
symbol_code: symbol_code,
comment: comment,
position_format: :compressed,
compression_type: compression_type,
data_type: :position,
compressed?: true,
position_ambiguity: ambiguity,
dao: nil,
has_position: has_position
}
Map.merge(base_data, compressed_cs)
rescue
_e ->
%{
latitude: nil,
longitude: nil,
symbol_table_id: "/",
symbol_code: symbol_code,
comment: comment,
position_format: :compressed,
compression_type: compression_type,
data_type: :position,
compressed?: true,
position_ambiguity: Parser.CompressedPositionHelpers.calculate_compressed_ambiguity(compression_type),
dao: nil,
course: nil,
speed: nil,
has_position: false
}
end
defp parse_position_malformed(position_data) do
%{
latitude: nil,
longitude: nil,
timestamp: nil,
symbol_table_id: nil,
symbol_code: nil,
data_type: :malformed_position,
aprs_messaging?: false,
compressed?: false,
comment: String.trim(position_data),
dao: nil,
course: nil,
speed: nil,
has_position: false
}
end
# Patch parse_position_with_message_without_timestamp to propagate course/speed
@spec parse_position_with_message_without_timestamp(String.t()) :: map()
def parse_position_with_message_without_timestamp(position_data) do
result = parse_position_without_timestamp(position_data)
Map.put(result, :aprs_messaging?, true)
end
# Patch parse_position_with_timestamp to extract course/speed from comment
@spec parse_position_with_timestamp(boolean(), binary(), atom()) :: map()
def parse_position_with_timestamp(
aprs_messaging?,
<<time::binary-size(7), latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9),
symbol_code::binary-size(1), comment::binary>>,
_data_type
) do
case Parser.UtilityHelpers.validate_position_data(latitude, longitude) do
{:ok, {lat, lon}} ->
position = parse_aprs_position(latitude, longitude)
{course, speed} = extract_course_and_speed(comment)
%{
latitude: lat,
longitude: lon,
position: position,
time: Parser.UtilityHelpers.validate_timestamp(time),
symbol_table_id: sym_table_id,
symbol_code: symbol_code,
comment: comment,
data_type: :position,
aprs_messaging?: aprs_messaging?,
compressed?: false,
course: course,
speed: speed
}
_ ->
# Fallback: try to extract lat/lon using regex if binary pattern match fails
regex =
~r/^(?<time>\w{7})(?<lat>\d{4,5}\.\d+[NS])(?<sym_table>.)(?<lon>\d{5,6}\.\d+[EW])(?<sym_code>.)(?<comment>.*)$/
case Regex.named_captures(
regex,
time <> latitude <> sym_table_id <> longitude <> symbol_code <> comment
) do
%{
"lat" => lat,
"lon" => lon,
"time" => time,
"sym_table" => sym_table,
"sym_code" => sym_code,
"comment" => comment
} ->
pos = parse_aprs_position(lat, lon)
%{
latitude: pos.latitude,
longitude: pos.longitude,
time: time,
symbol_table_id: sym_table,
symbol_code: sym_code,
comment: comment,
data_type: :position,
aprs_messaging?: aprs_messaging?,
compressed?: false
}
_ ->
%{
data_type: :timestamped_position_error,
error: "Invalid timestamped position format",
raw_data: time <> latitude <> sym_table_id <> longitude <> symbol_code <> comment
}
end
end
end
def parse_position_with_timestamp(aprs_messaging?, data, _data_type) do
# Fallback: try to extract lat/lon using regex if binary pattern match fails
regex =
~r/^(?<time>\w{7})(?<lat>\d{4,5}\.\d+[NS])(?<sym_table>.)(?<lon>\d{5,6}\.\d+[EW])(?<sym_code>.)(?<comment>.*)$/
case Regex.named_captures(regex, data) do
%{
"lat" => lat,
"lon" => lon,
"time" => time,
"sym_table" => sym_table,
"sym_code" => sym_code,
"comment" => comment
} ->
pos = parse_aprs_position(lat, lon)
%{
latitude: pos.latitude,
longitude: pos.longitude,
time: time,
symbol_table_id: sym_table,
symbol_code: sym_code,
comment: comment,
data_type: :position,
aprs_messaging?: aprs_messaging?,
compressed?: false
}
_ ->
%{
data_type: :timestamped_position_error,
error: "Invalid timestamped position format",
raw_data: data
}
end
end
@spec parse_manufacturer(binary()) :: String.t()
def parse_manufacturer(symbols) do
Aprs.DeviceIdentification.identify_device(symbols)
end
@spec convert_compressed_lat(binary()) :: float()
def convert_compressed_lat(lat) do
[l1, l2, l3, l4] = to_charlist(lat)
90 - ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 380_926
end
@spec convert_compressed_lon(binary()) :: float()
def convert_compressed_lon(lon) do
[l1, l2, l3, l4] = to_charlist(lon)
-180 + ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 190_463
end
@spec convert_compressed_cs(binary()) :: map()
def convert_compressed_cs(cs) do
[c, s] = to_charlist(cs)
c = c - 33
s = s - 33
case c do
x when x in ?!..?z ->
# compressed speed/course value
# speed is returned in knots
%{
course: s * 4,
speed: Convert.speed(1.08 ** s - 1, :knots, :mph)
}
?Z ->
# pre-calculated radio range
%{
range: 2 * 1.08 ** s
}
_ ->
%{}
end
end
# Status Report parsing
def parse_status(<<">", status_text::binary>>) do
%{
status_text: status_text,
data_type: :status
}
end
@spec parse_status(String.t()) :: map()
def parse_status(data) do
%{
status_text: data,
data_type: :status
}
end
# Station Capabilities parsing
def parse_station_capabilities(<<"<", capabilities::binary>>) do
%{
capabilities: capabilities,
data_type: :station_capabilities
}
end
@spec parse_station_capabilities(String.t()) :: map()
def parse_station_capabilities(data) do
%{
capabilities: data,
data_type: :station_capabilities
}
end
# Query parsing
def parse_query(<<"?", query_type::binary-size(1), query_data::binary>>) do
%{
query_type: query_type,
query_data: query_data,
data_type: :query
}
end
@spec parse_query(String.t()) :: map()
def parse_query(data) do
%{
query_data: data,
data_type: :query
}
end
# User Defined parsing
def parse_user_defined(<<"{", user_id::binary-size(1), user_data::binary>>) do
parsed_data = parse_user_defined_format(user_id, user_data)
Map.merge(
%{
user_id: user_id,
data_type: :user_defined,
raw_data: user_data
},
parsed_data
)
end
@spec parse_user_defined(String.t()) :: map()
def parse_user_defined(data) do
%{
user_data: data,
data_type: :user_defined
}
end
# Parse specific user-defined formats
defp parse_user_defined_format("A", user_data), do: %{format: :experimental_a, content: user_data}
defp parse_user_defined_format("B", user_data), do: %{format: :experimental_b, content: user_data}
defp parse_user_defined_format("C", user_data), do: %{format: :custom_c, content: user_data}
defp parse_user_defined_format(_, user_data), do: %{format: :unknown, content: user_data}
# Third Party Traffic parsing
def parse_third_party_traffic(packet) do
if Parser.UtilityHelpers.count_leading_braces(packet) + 1 > 3 do
%{
error: "Maximum tunnel depth exceeded"
}
else
case parse_tunneled_packet(packet) do
{:ok, parsed_packet} ->
build_third_party_traffic_result(packet, parsed_packet)
{:error, reason} ->
%{
error: reason
}
end
end
end
defp build_third_party_traffic_result(packet, parsed_packet) do
case parse_nested_tunnel(packet) do
{:ok, nested_packet} ->
%{
third_party_packet: nested_packet,
data_type: :third_party_traffic,
raw_data: packet
}
{:error, _} ->
%{
third_party_packet: parsed_packet,
data_type: :third_party_traffic,
raw_data: packet
}
end
end
@spec parse_tunneled_packet(String.t()) :: {:ok, map()} | {:error, String.t()}
defp parse_tunneled_packet(packet) do
case String.split(packet, ":", parts: 2) do
[header, information] ->
parse_tunneled_packet_with_header(header, information)
_ ->
{:error, "Invalid tunneled packet format"}
end
end
defp parse_tunneled_packet_with_header(header, information) do
case parse_tunneled_header(header) do
{:ok, header_data} ->
parse_tunneled_packet_with_information(header_data, information)
{:error, reason} ->
{:error, "Invalid header: #{reason}"}
end
end
defp parse_tunneled_packet_with_information(header_data, information) do
case parse_datatype_safe(information) do
{:ok, data_type} ->
data_without_type = String.slice(information, 1..-1//1)
data_extended = parse_data(data_type, header_data.destination, data_without_type)
{:ok,
Map.merge(header_data, %{
information_field: information,
data_type: data_type,
data_extended: data_extended
})}
{:error, reason} ->
{:error, "Invalid data type: #{reason}"}
end
end
@spec parse_tunneled_header(String.t()) :: {:ok, map()} | {:error, String.t()}
defp parse_tunneled_header(header) do
case String.split(header, ">", parts: 2) do
[sender, path] ->
parse_sender_and_path(sender, path)
_ ->
{:error, "Invalid header format"}
end
end
defp parse_sender_and_path(sender, path) do
case parse_callsign(sender) do
{:ok, callsign_parts} ->
base_callsign = List.first(callsign_parts)
ssid = extract_ssid(List.last(callsign_parts))
case split_path_for_tunnel(path) do
{:ok, [destination, digi_path]} ->
{:ok,
%{
sender: sender,
base_callsign: base_callsign,
ssid: ssid,
destination: destination,
digi_path: digi_path
}}
{:error, reason} ->
{:error, "Invalid path: #{reason}"}
end
{:error, reason} ->
{:error, "Invalid callsign: #{reason}"}
end
end
defp extract_ssid(nil), do: nil
defp extract_ssid(s) when is_binary(s), do: s
defp extract_ssid(i) when is_integer(i), do: to_string(i)
defp extract_ssid(_), do: nil
defp split_path_for_tunnel(path) do
split_path(path)
end
# Add network tunneling support
@spec parse_network_tunnel(String.t()) :: {:ok, map()} | {:error, String.t()}
defp parse_network_tunnel(packet) do
# Network tunneling packets start with "}" and contain a complete APRS packet
case String.slice(packet, 1..-1//1) do
tunneled_packet ->
case parse_tunneled_packet(tunneled_packet) do
{:ok, parsed_packet} ->
{:ok,
Map.merge(parsed_packet, %{
tunnel_type: :network,
raw_data: packet
})}
{:error, reason} ->
{:error, "Invalid tunneled packet: #{reason}"}
end
end
end
# Add support for multiple levels of tunneling
defp parse_nested_tunnel(packet, depth \\ 0) do
cond do
depth > 3 ->
{:error, "Maximum tunnel depth exceeded"}
String.starts_with?(packet, "}") ->
case parse_network_tunnel(packet) do
{:ok, parsed_packet} -> handle_parsed_network_tunnel(parsed_packet, depth)
{:error, reason} -> {:error, reason}
end
true ->
{:error, "Not a tunneled packet"}
end
end
defp handle_parsed_network_tunnel(parsed_packet, depth) do
case Map.get(parsed_packet, :data_extended) do
%{raw_data: nested_data} when is_binary(nested_data) ->
case parse_nested_tunnel(nested_data, depth + 1) do
{:ok, nested_packet} -> {:ok, Map.put(parsed_packet, :nested_packet, nested_packet)}
{:error, _} -> {:ok, parsed_packet}
end
_ ->
{:ok, parsed_packet}
end
end
# Add DAO (Datum) extension support
@spec parse_dao_extension(String.t()) :: map() | nil
defp parse_dao_extension(comment) do
case Regex.run(~r/!([A-Za-z])([A-Za-z])([A-Za-z])!/, comment) do
[_, lat_dao, lon_dao, _] ->
%{
lat_dao: lat_dao,
lon_dao: lon_dao,
datum: "WGS84"
}
_ ->
nil
end
end
end

View file

@ -1,37 +0,0 @@
defmodule Parser.AX25 do
@moduledoc """
AX.25 callsign and path parsing/validation for APRS packets.
"""
@doc """
Parse and validate an AX.25 callsign. Returns {:ok, {base, ssid}} or {:error, reason}.
"""
@spec parse_callsign(String.t()) :: {:ok, {String.t(), String.t()}} | {:error, String.t()}
def parse_callsign(callsign) do
cond do
not is_binary(callsign) ->
{:error, "Invalid callsign format"}
byte_size(callsign) == 0 ->
{:error, "Empty callsign"}
String.contains?(callsign, "-") ->
case String.split(callsign, "-") do
[base, ssid] -> {:ok, {base, ssid}}
_ -> {:ok, {callsign, "0"}}
end
true ->
{:ok, {callsign, "0"}}
end
end
@doc """
Parse and validate an AX.25 path. Returns {:ok, [String.t()]} or {:error, reason}.
"""
@spec parse_path(String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
def parse_path(_path) do
# Stub: actual logic to be implemented
{:error, "Not yet implemented"}
end
end

View file

@ -1,20 +0,0 @@
defmodule Parser.Compressed do
@moduledoc """
Compressed position parsing for APRS packets.
"""
alias Parser.Types.ParseError
@doc """
Parse a compressed position string. Returns a struct or ParseError.
"""
@spec parse(String.t()) :: map() | ParseError.t()
def parse(_compressed_str) do
# Stub: actual logic to be implemented
%ParseError{
error_code: :not_implemented,
error_message: "Compressed position parsing not yet implemented",
raw_data: nil
}
end
end

View file

@ -1,57 +0,0 @@
defmodule Parser.CompressedPositionHelpers do
@moduledoc """
Compressed position helpers for APRS.
"""
@spec convert_compressed_lat(binary()) :: {:ok, float()} | {:error, String.t()}
def convert_compressed_lat(lat) when is_binary(lat) and byte_size(lat) == 4 do
[l1, l2, l3, l4] = to_charlist(lat)
{:ok, 90 - ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 380_926}
end
def convert_compressed_lat(_), do: {:error, "Invalid compressed latitude"}
@spec convert_compressed_lon(binary()) :: {:ok, float()} | {:error, String.t()}
def convert_compressed_lon(lon) when is_binary(lon) and byte_size(lon) == 4 do
[l1, l2, l3, l4] = to_charlist(lon)
{:ok, -180 + ((l1 - 33) * 91 ** 3 + (l2 - 33) * 91 ** 2 + (l3 - 33) * 91 + l4 - 33) / 190_463}
end
def convert_compressed_lon(_), do: {:error, "Invalid compressed longitude"}
@spec calculate_compressed_ambiguity(String.t()) :: integer()
def calculate_compressed_ambiguity(compression_type) do
case compression_type do
" " -> 0
"!" -> 1
"\"" -> 2
"#" -> 3
"$" -> 4
_ -> 0
end
end
@doc false
def convert_to_base91(<<value::binary-size(4)>>) do
[v1, v2, v3, v4] = to_charlist(value)
(v1 - 33) * 91 * 91 * 91 + (v2 - 33) * 91 * 91 + (v3 - 33) * 91 + v4
end
@spec convert_compressed_cs(binary()) :: map()
def convert_compressed_cs(cs) do
[c, s] = to_charlist(cs)
c = c - 33
s = s - 33
case c do
x when x in ?!..?z ->
%{course: s * 4, speed: Aprs.Convert.speed(1.08 ** s - 1, :knots, :mph)}
?Z ->
%{range: 2 * 1.08 ** s}
_ ->
%{}
end
end
end

View file

@ -1,22 +0,0 @@
defmodule Parser.Core do
@moduledoc """
Main entry point for APRS packet parsing. Delegates to submodules for specific formats.
"""
alias Parser.Types.Packet
alias Parser.Types.ParseError
@doc """
Parse an APRS packet string into a Packet struct or return a ParseError struct.
"""
@spec parse(String.t()) :: {:ok, Packet.t()} | {:error, ParseError.t()}
def parse(_packet) do
# Stub: actual logic will delegate to submodules
{:error,
%ParseError{
error_code: :not_implemented,
error_message: "Not yet implemented",
raw_data: nil
}}
end
end

View file

@ -1,28 +0,0 @@
defmodule Parser.DeviceParser do
@moduledoc """
Extracts device identifier (TOCALL or Mic-E) from APRS packets.
"""
@doc """
Extract the device identifier from a packet map or raw packet string.
"""
def extract_device_identifier(%{destination: dest}) when is_binary(dest) do
# TOCALL is usually the first 6 chars of destination
String.slice(dest, 0, 6)
end
def extract_device_identifier(%{data_type: :mic_e, destination: dest}) when is_binary(dest) do
# Mic-E uses destination for device ID
String.slice(dest, 0, 6)
end
def extract_device_identifier(packet) when is_binary(packet) do
# Try to parse out the destination field from raw packet
case Regex.run(~r/^[^>]+>([^,]+),/, packet) do
[_, dest] -> String.slice(dest, 0, 6)
_ -> nil
end
end
def extract_device_identifier(_), do: nil
end

Some files were not shown because too many files have changed in this diff Show more