diff --git a/config/config.exs b/config/config.exs
index 6113ae4..f44a10c 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -33,7 +33,8 @@ config :aprs, Oban,
{Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7},
{Oban.Plugins.Cron,
crontab: [
- {"0 0 * * *", Aprs.Workers.PacketCleanupWorker}
+ {"0 0 * * *", Aprs.Workers.PacketCleanupWorker},
+ {"0 3 * * 1", Aprs.DeviceIdentification.Worker}
]}
],
queues: [default: 10, maintenance: 2]
diff --git a/lib/aprs/application.ex b/lib/aprs/application.ex
index 5b3f227..b854051 100644
--- a/lib/aprs/application.ex
+++ b/lib/aprs/application.ex
@@ -41,7 +41,12 @@ defmodule Aprs.Application do
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Aprs.Supervisor]
- Supervisor.start_link(children, opts)
+ {: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)
+
+ {:ok, sup}
end
# Tell Phoenix to update the endpoint configuration
diff --git a/lib/aprs/device_identification.ex b/lib/aprs/device_identification.ex
index 561ec94..94e3d96 100644
--- a/lib/aprs/device_identification.ex
+++ b/lib/aprs/device_identification.ex
@@ -75,4 +75,113 @@ defmodule Aprs.DeviceIdentification do
def known_models("Byonics"), do: ["TinyTrack3", "TinyTrack4"]
def known_models("SCS GmbH & Co."), do: ["P4dragon DR-7400 modems", "P4dragon DR-7800 modems"]
def known_models(_), do: []
+
+ alias Aprs.{Devices, Repo}
+ import Ecto.Query
+
+ @url "https://aprs-deviceid.aprsfoundation.org/tocalls.dense.json"
+ @week_seconds 7 * 24 * 60 * 60
+
+ def maybe_refresh_devices do
+ last = Repo.one(from d in Devices, order_by: [desc: d.updated_at], limit: 1)
+ last_time =
+ case last && last.updated_at do
+ %NaiveDateTime{} = naive -> DateTime.from_naive!(naive, "Etc/UTC")
+ %DateTime{} = dt -> dt
+ _ -> nil
+ end
+ if last == nil or (last_time && DateTime.diff(DateTime.utc_now(), last_time) > @week_seconds) do
+ fetch_and_upsert_devices()
+ else
+ :ok
+ end
+ end
+
+ def fetch_and_upsert_devices do
+ req = Finch.build(:get, @url)
+ case Finch.request(req, Aprs.Finch) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ {:ok, json} = Jason.decode(body)
+ upsert_devices(json)
+ err ->
+ err
+ end
+ end
+
+ def upsert_devices(json) do
+ tocalls = Map.get(json, "tocalls", %{})
+ mice = Map.get(json, "mice", %{})
+ micelegacy = Map.get(json, "micelegacy", %{})
+ now = DateTime.utc_now()
+
+ Repo.transaction(fn ->
+ Repo.delete_all(Devices)
+ Enum.each([tocalls, mice, micelegacy], fn group ->
+ Enum.each(group, fn {identifier, attrs} ->
+ attrs =
+ attrs
+ |> Map.put("identifier", identifier)
+ |> Map.update("features", nil, fn f ->
+ if is_list(f), do: f, else: [f]
+ end)
+ |> Map.put("updated_at", now)
+ %Devices{} |> Devices.changeset(attrs) |> Repo.insert!()
+ end)
+ end)
+ end)
+ :ok
+ end
+
+ # Helper to enqueue the job
+ def enqueue_refresh_job do
+ Oban.insert!(Aprs.DeviceIdentification.Worker.new(%{}))
+ end
+
+ @doc """
+ Looks up a device by identifier, using ? as a single-character wildcard.
+ Returns the device struct if found, or nil.
+ """
+ def lookup_device_by_identifier(identifier) when is_binary(identifier) do
+ # Fetch all device patterns from DB
+ devices = Repo.all(Devices)
+ Enum.find(devices, fn device ->
+ pattern = device.identifier
+ cond do
+ String.contains?(pattern, "?") ->
+ try do
+ regex = wildcard_pattern_to_regex(pattern)
+ Regex.match?(regex, identifier)
+ rescue
+ _e in Regex.CompileError ->
+ false
+ end
+ String.contains?(pattern, "*") ->
+ # Compare literally if pattern contains * but not ?
+ pattern == identifier
+ true ->
+ pattern == identifier
+ end
+ end)
+ end
+
+ # Converts a pattern with ? wildcards to a regex
+ defp wildcard_pattern_to_regex(pattern) when is_binary(pattern) do
+ # Replace ? with a placeholder, escape all regex metacharacters except the placeholder, then replace placeholder with .
+ pattern
+ |> String.replace("?", "__WILDCARD__")
+ # Escape all regex metacharacters
+ |> String.replace(~r/([\\.\+\*\?\[\^\]\$\(\)\{\}=!<>\|:\-])/, "\\\\\1")
+ |> String.replace("__WILDCARD__", ".")
+ |> then(&~r/^#{&1}$/)
+ end
+end
+
+defmodule Aprs.DeviceIdentification.Worker do
+ use Oban.Worker, queue: :default, max_attempts: 1
+
+ @impl true
+ def perform(_job) do
+ Aprs.DeviceIdentification.maybe_refresh_devices()
+ :ok
+ end
end
diff --git a/lib/aprs/devices.ex b/lib/aprs/devices.ex
new file mode 100644
index 0000000..c2d21f9
--- /dev/null
+++ b/lib/aprs/devices.ex
@@ -0,0 +1,22 @@
+defmodule Aprs.Devices do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "devices" do
+ field :identifier, :string
+ field :class, :string
+ field :model, :string
+ field :vendor, :string
+ field :os, :string
+ field :contact, :string
+ field :features, {:array, :string}
+ timestamps(updated_at: :updated_at)
+ end
+
+ def changeset(device, attrs) do
+ device
+ |> cast(attrs, [:identifier, :class, :model, :vendor, :os, :contact, :features])
+ |> validate_required([:identifier])
+ |> unique_constraint(:identifier)
+ end
+end
diff --git a/lib/aprs/encoding_utils.ex b/lib/aprs/encoding_utils.ex
index 290a7d3..ca57922 100644
--- a/lib/aprs/encoding_utils.ex
+++ b/lib/aprs/encoding_utils.ex
@@ -22,13 +22,24 @@ defmodule Aprs.EncodingUtils do
"Hello World"
iex> Aprs.EncodingUtils.sanitize_string(<<72, 101, 108, 108, 111, 211, 87, 111, 114, 108, 100>>)
- "HelloWorld"
+ "HelloÓWorld"
"""
@spec sanitize_string(binary() | nil | any()) :: binary() | nil | any()
def sanitize_string(binary) when is_binary(binary) do
- # Always scrub problematic bytes, even from valid UTF-8 strings
- # PostgreSQL rejects null bytes and other control characters even if they're valid UTF-8
- scrub_problematic_bytes(binary)
+ cleaned =
+ if String.valid?(binary) do
+ binary
+ else
+ :unicode.characters_to_binary(binary, :latin1, :utf8)
+ end
+
+ cleaned
+ |> String.replace(<<0>>, "")
+ |> String.replace(~r/[\x01-\x08\x0B\x0C\x0E-\x1F\x7F]/, "")
+ |> String.graphemes()
+ |> Enum.filter(&String.valid?/1)
+ |> Enum.join()
+ |> String.trim()
end
def sanitize_string(nil), do: nil
@@ -81,65 +92,6 @@ defmodule Aprs.EncodingUtils do
# Private helper functions
- @spec scrub_problematic_bytes(binary()) :: String.t()
- defp scrub_problematic_bytes(binary) do
- # Handle both invalid UTF-8 sequences and problematic control characters
- cleaned = clean_binary_by_validity(binary, String.valid?(binary))
- String.trim(cleaned)
- end
-
- defp clean_binary_by_validity(binary, true) do
- # String is valid UTF-8, just remove control characters
- binary
- # Remove null bytes
- |> String.replace(<<0>>, "")
- # Remove other control chars
- |> String.replace(~r/[\x01-\x08\x0B\x0C\x0E-\x1F\x7F]/, "")
- end
-
- defp clean_binary_by_validity(binary, false) do
- # String has invalid UTF-8, filter byte by byte
- binary
- |> :binary.bin_to_list()
- |> Enum.filter(&valid_utf8_byte?/1)
- |> :binary.list_to_bin()
- |> ensure_valid_utf8()
- end
-
- # Check if byte should be kept (ASCII printable + safe whitespace)
- @spec valid_utf8_byte?(integer()) :: boolean()
- defp valid_utf8_byte?(byte) when byte >= 32 and byte <= 126, do: true
- defp valid_utf8_byte?(byte) when byte in [9, 10, 13], do: true
- defp valid_utf8_byte?(_), do: false
-
- # Ensure the final result is valid UTF-8
- @spec ensure_valid_utf8(binary()) :: binary()
- defp ensure_valid_utf8(binary) do
- if String.valid?(binary) do
- binary
- else
- try_convert_utf8(binary)
- end
- end
-
- defp try_convert_utf8(binary) do
- case :unicode.characters_to_binary(binary, :latin1, :utf8) do
- result when is_binary(result) ->
- result
-
- _ ->
- # Last resort: keep only ASCII
- fallback_to_ascii(binary)
- end
- end
-
- defp fallback_to_ascii(binary) do
- binary
- |> :binary.bin_to_list()
- |> Enum.filter(fn byte -> byte >= 32 and byte <= 126 end)
- |> :binary.list_to_bin()
- end
-
@doc """
Converts a binary to a hex string representation for debugging.
diff --git a/lib/aprs/packet.ex b/lib/aprs/packet.ex
index 406d799..d68f76a 100644
--- a/lib/aprs/packet.ex
+++ b/lib/aprs/packet.ex
@@ -15,7 +15,7 @@ defmodule Aprs.Packet do
field(:path, :string)
field(:sender, :string)
field(:ssid, :string)
- field(:received_at, :utc_datetime_usec)
+ field(:received_at, :utc_datetime)
field(:region, :string)
field(:lat, :decimal)
field(:lon, :decimal)
@@ -55,9 +55,11 @@ defmodule Aprs.Packet do
field(:message_text, :string)
field(:message_number, :string)
+ field(:device_identifier, :string)
+
embeds_one(:data_extended, DataExtended)
- timestamps()
+ timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@@ -105,7 +107,8 @@ defmodule Aprs.Packet do
:altitude,
:addressee,
:message_text,
- :message_number
+ :message_number,
+ :device_identifier
])
|> validate_required([
:base_callsign,
diff --git a/lib/aprs/packets.ex b/lib/aprs/packets.ex
index 431ba39..41d0369 100644
--- a/lib/aprs/packets.ex
+++ b/lib/aprs/packets.ex
@@ -8,7 +8,6 @@ defmodule Aprs.Packets do
import Ecto.Query, warn: false
alias Aprs.BadPacket
- alias Aprs.EncodingUtils
alias Aprs.Packet
alias Aprs.Repo
alias Parser.Types.MicE
@@ -24,12 +23,26 @@ defmodule Aprs.Packets do
require Logger
try do
- packet_attrs = normalize_packet_attrs(packet_data)
- packet_attrs = set_received_at(packet_attrs)
- packet_attrs = patch_lat_lon_from_data_extended(packet_attrs)
- {lat, lon} = extract_position(packet_attrs)
- packet_attrs = set_lat_lon(packet_attrs, lat, lon)
- packet_attrs = normalize_ssid(packet_attrs)
+ packet_attrs =
+ packet_data
+ |> normalize_packet_attrs()
+ |> set_received_at()
+ |> patch_lat_lon_from_data_extended()
+ |> (fn attrs ->
+ {lat, lon} = extract_position(attrs)
+ set_lat_lon(attrs, lat, lon)
+ end).()
+ |> normalize_ssid()
+ |> (fn attrs ->
+ device_identifier = Parser.DeviceParser.extract_device_identifier(packet_data)
+ matched_device = Aprs.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).()
+ |> sanitize_packet_strings()
+ # require Logger
+ # Logger.debug("Sanitized packet_attrs before insert: #{inspect(packet_attrs)}")
+ packet_attrs = Enum.into(packet_attrs, %{}, fn {k, v} -> {k, sanitize_packet_strings(v)} end)
insert_packet(packet_attrs, packet_data)
rescue
error ->
@@ -513,63 +526,15 @@ defmodule Aprs.Packets do
defp to_decimal(_), do: nil
# Helper to sanitize all string fields in packet data before database storage
- defp sanitize_packet_strings(packet_attrs) when is_map(packet_attrs) do
- packet_attrs
- |> sanitize_field(:base_callsign, &sanitize_and_ensure_string/1)
- |> sanitize_field(:data_type, &sanitize_and_ensure_string/1)
- |> sanitize_field(:destination, &sanitize_and_ensure_string/1)
- |> sanitize_required_field(:information_field)
- |> sanitize_required_field(:path)
- |> sanitize_field(:sender, &sanitize_and_ensure_string/1)
- |> sanitize_field(:ssid, &sanitize_and_ensure_string/1)
- |> sanitize_field(:region, &EncodingUtils.sanitize_string/1)
- |> sanitize_field(:raw_packet, &EncodingUtils.sanitize_string/1)
- |> sanitize_field(:symbol_code, &EncodingUtils.sanitize_string/1)
- |> sanitize_field(:symbol_table_id, &EncodingUtils.sanitize_string/1)
- |> sanitize_field(:comment, &EncodingUtils.sanitize_string/1)
- |> sanitize_field(:timestamp, &EncodingUtils.sanitize_string/1)
- |> sanitize_field(:manufacturer, &EncodingUtils.sanitize_string/1)
- |> sanitize_field(:equipment_type, &EncodingUtils.sanitize_string/1)
- |> sanitize_field(:addressee, &EncodingUtils.sanitize_string/1)
- |> sanitize_field(:message_text, &EncodingUtils.sanitize_string/1)
- |> sanitize_field(:message_number, &EncodingUtils.sanitize_string/1)
- |> sanitize_data_extended_field()
- end
-
- # Helper to sanitize a field if it exists
- defp sanitize_field(map, key, sanitizer_func) do
- case Map.get(map, key) do
- nil -> map
- value -> Map.put(map, key, sanitizer_func.(value))
- end
- end
-
- # Helper to sanitize required fields, ensuring they exist and are not nil
- defp sanitize_required_field(map, key) do
- value = Map.get(map, key)
- sanitized_value = sanitize_required_string(value)
- Map.put(map, key, sanitized_value)
- end
-
- # Helper to sanitize and ensure required string fields are never nil
- defp sanitize_required_string(nil), do: ""
- defp sanitize_required_string(value), do: EncodingUtils.sanitize_string(value)
-
- # Helper to sanitize and ensure non-required string fields
- defp sanitize_and_ensure_string(nil), do: nil
- defp sanitize_and_ensure_string(value), do: EncodingUtils.sanitize_string(value)
-
- # Helper to sanitize the data_extended field
- defp sanitize_data_extended_field(packet_attrs) do
- case packet_attrs do
- %{data_extended: data_extended} when not is_nil(data_extended) ->
- sanitized_data_extended = EncodingUtils.sanitize_data_extended(data_extended)
- Map.put(packet_attrs, :data_extended, sanitized_data_extended)
-
- _ ->
- packet_attrs
- end
+ defp sanitize_packet_strings(%DateTime{} = dt), do: dt
+ defp sanitize_packet_strings(%NaiveDateTime{} = ndt), do: ndt
+ defp sanitize_packet_strings(%_struct{} = struct), do: struct |> Map.from_struct() |> sanitize_packet_strings()
+ 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)
+ if is_binary(s), do: s, else: ""
end
+ defp sanitize_packet_strings(other), do: other
# Get packets from last hour only - used to initialize the map
@spec get_last_hour_packets() :: [struct()]
diff --git a/lib/aprs_web/live/info_live/show.html.heex b/lib/aprs_web/live/info_live/show.html.heex
index 980625b..687ab4d 100644
--- a/lib/aprs_web/live/info_live/show.html.heex
+++ b/lib/aprs_web/live/info_live/show.html.heex
@@ -43,6 +43,7 @@
alt="APRS symbol"
/>
<% end %>
+ <.link navigate={~p"/packets/#{@callsign}"} class="ml-2 text-sm text-blue-600 hover:underline">View packets
<%= if @packet do %>
diff --git a/lib/aprs_web/live/packets_live/callsign_view.ex b/lib/aprs_web/live/packets_live/callsign_view.ex
index a8c49b9..5b2ecc2 100644
--- a/lib/aprs_web/live/packets_live/callsign_view.ex
+++ b/lib/aprs_web/live/packets_live/callsign_view.ex
@@ -8,6 +8,7 @@ defmodule AprsWeb.PacketsLive.CallsignView do
alias Aprs.Packet
alias Aprs.Repo
alias AprsWeb.Endpoint
+ alias Parser.DeviceParser
@impl true
def mount(%{"callsign" => callsign}, _session, socket) do
@@ -57,11 +58,25 @@ defmodule AprsWeb.PacketsLive.CallsignView do
# Handle incoming live packets - only process if they match our callsign
if packet_matches_callsign?(payload, socket.assigns.callsign) do
sanitized_payload = EncodingUtils.sanitize_packet(payload)
+ device_identifier =
+ Map.get(sanitized_payload, :device_identifier) ||
+ Map.get(sanitized_payload, "device_identifier") ||
+ DeviceParser.extract_device_identifier(sanitized_payload)
+ canonical_identifier =
+ if is_binary(device_identifier) do
+ matched_device = Aprs.DeviceIdentification.lookup_device_by_identifier(device_identifier)
+ if matched_device, do: matched_device.identifier, else: device_identifier
+ else
+ device_identifier
+ end
+ sanitized_payload = Map.put(sanitized_payload, :device_identifier, canonical_identifier)
+ # Enrich with model/vendor
+ enriched_payload = enrich_packet_with_device_info(sanitized_payload)
current_live = socket.assigns.live_packets
current_stored = socket.assigns.packets
{updated_stored, updated_live} =
- update_packet_lists(current_stored, current_live, sanitized_payload)
+ update_packet_lists(current_stored, current_live, enriched_payload)
all_packets = get_all_packets_list(updated_stored, updated_live)
latest_packet = List.first(all_packets)
@@ -101,6 +116,7 @@ defmodule AprsWeb.PacketsLive.CallsignView do
filtered_query
|> Repo.all()
|> Enum.map(&EncodingUtils.sanitize_packet/1)
+ |> Enum.map(&enrich_packet_with_device_info/1)
rescue
error ->
require Logger
@@ -186,4 +202,14 @@ defmodule AprsWeb.PacketsLive.CallsignView do
code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">"
{table, code}
end
+
+ 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), else: nil
+ model = if device, do: device.model, else: nil
+ vendor = if device, do: device.vendor, else: nil
+ packet
+ |> Map.put(:device_model, model)
+ |> Map.put(:device_vendor, vendor)
+ end
end
diff --git a/lib/aprs_web/live/packets_live/callsign_view.html.heex b/lib/aprs_web/live/packets_live/callsign_view.html.heex
index 290e73a..f0cb505 100644
--- a/lib/aprs_web/live/packets_live/callsign_view.html.heex
+++ b/lib/aprs_web/live/packets_live/callsign_view.html.heex
@@ -63,6 +63,17 @@
{packet.path}
+ <:col :let={packet} label="Device">
+
+ <%= if packet.device_model || packet.device_vendor do %>
+ <%= [packet.device_model, packet.device_vendor]
+ |> Enum.reject(&is_nil/1)
+ |> Enum.join(" ") %>
+ <% else %>
+ {Map.get(packet, :device_identifier, Map.get(packet, "device_identifier", ""))}
+ <% end %>
+
+
diff --git a/lib/aprs_web/live/packets_live/index.html.heex b/lib/aprs_web/live/packets_live/index.html.heex
index 222d95c..d4f4551 100644
--- a/lib/aprs_web/live/packets_live/index.html.heex
+++ b/lib/aprs_web/live/packets_live/index.html.heex
@@ -87,6 +87,11 @@
else: ""}
+ <:col :let={packet} label="Device">
+
+ {Map.get(packet, :device_identifier, Map.get(packet, "device_identifier", ""))}
+
+
diff --git a/lib/parser/device_parser.ex b/lib/parser/device_parser.ex
new file mode 100644
index 0000000..e71c4b0
--- /dev/null
+++ b/lib/parser/device_parser.ex
@@ -0,0 +1,28 @@
+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
diff --git a/mix.exs b/mix.exs
index dc0ecc9..6e0abe1 100644
--- a/mix.exs
+++ b/mix.exs
@@ -57,7 +57,7 @@ defmodule Aprs.MixProject do
{:gettext, "~> 0.26.2"},
{:hackney, "~> 1.24"},
{:heroicons, "~> 0.5"},
- {:jason, "~> 1.2"},
+ {:jason, "~> 1.4"},
{:libcluster, "~> 3.3"},
{:oban, "~> 2.11"},
{:phoenix, "~> 1.8.0-rc.3", override: true},
@@ -83,7 +83,8 @@ defmodule Aprs.MixProject do
{:sobelow, "~> 0.8", only: :dev},
{:stream_data, "~> 1.2.0", only: [:dev, :test]},
{:mox, "~> 1.2", only: :test},
- {:styler, "~> 1.4.2", only: [:dev, :test], runtime: false}
+ {:styler, "~> 1.4.2", only: [:dev, :test], runtime: false},
+ {:httpoison, "~> 1.8"}
]
end
diff --git a/mix.lock b/mix.lock
index fc8669f..6614e4c 100644
--- a/mix.lock
+++ b/mix.lock
@@ -37,6 +37,7 @@
"hackney": {:hex, :hackney, "1.24.1", "f5205a125bba6ed4587f9db3cc7c729d11316fa8f215d3e57ed1c067a9703fa9", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "f4a7392a0b53d8bbc3eb855bdcc919cd677358e65b2afd3840b5b3690c4c8a39"},
"heroicons": {:hex, :heroicons, "0.5.6", "95d730e7179c633df32d95c1fdaaecdf81b0da11010b89b737b843ac176a7eb5", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "ca267f02a5fa695a4178a737b649fb6644a2e399639d4ba7964c18e8a58c2352"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
+ "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm", "fc3499fed7a726995aa659143a248534adc754ebd16ccd437cd93b649a95091f"},
diff --git a/priv/repo/migrations/20250621120000_create_devices.exs b/priv/repo/migrations/20250621120000_create_devices.exs
new file mode 100644
index 0000000..b640d91
--- /dev/null
+++ b/priv/repo/migrations/20250621120000_create_devices.exs
@@ -0,0 +1,18 @@
+defmodule Aprs.Repo.Migrations.CreateDevices do
+ use Ecto.Migration
+
+ def change do
+ create table(:devices) do
+ add :identifier, :string, null: false
+ add :class, :string
+ add :model, :string
+ add :vendor, :string
+ add :os, :string
+ add :contact, :string
+ add :features, {:array, :string}
+ timestamps(type: :utc_datetime_usec, updated_at: :updated_at)
+ end
+
+ create unique_index(:devices, [:identifier])
+ end
+end
diff --git a/priv/repo/migrations/20250621121000_add_device_identifier_to_packets.exs b/priv/repo/migrations/20250621121000_add_device_identifier_to_packets.exs
new file mode 100644
index 0000000..13818cd
--- /dev/null
+++ b/priv/repo/migrations/20250621121000_add_device_identifier_to_packets.exs
@@ -0,0 +1,9 @@
+defmodule Aprs.Repo.Migrations.AddDeviceIdentifierToPackets do
+ use Ecto.Migration
+
+ def change do
+ alter table(:packets) do
+ add :device_identifier, :string
+ end
+ end
+end
diff --git a/priv/repo/migrations/20250623190000_update_devices_timestamps_to_utc_usec.exs b/priv/repo/migrations/20250623190000_update_devices_timestamps_to_utc_usec.exs
new file mode 100644
index 0000000..09252ba
--- /dev/null
+++ b/priv/repo/migrations/20250623190000_update_devices_timestamps_to_utc_usec.exs
@@ -0,0 +1,10 @@
+defmodule Aprs.Repo.Migrations.UpdateDevicesTimestampsToUtcUsec do
+ use Ecto.Migration
+
+ def change do
+ alter table(:devices) do
+ modify :inserted_at, :utc_datetime_usec, null: false
+ modify :updated_at, :utc_datetime_usec, null: false
+ end
+ end
+end
diff --git a/priv/repo/migrations/20250623193000_update_packets_timestamps_to_utc.exs b/priv/repo/migrations/20250623193000_update_packets_timestamps_to_utc.exs
new file mode 100644
index 0000000..f43d38f
--- /dev/null
+++ b/priv/repo/migrations/20250623193000_update_packets_timestamps_to_utc.exs
@@ -0,0 +1,11 @@
+defmodule Aprs.Repo.Migrations.UpdatePacketsTimestampsToUtc do
+ use Ecto.Migration
+
+ def change do
+ alter table(:packets) do
+ modify :inserted_at, :utc_datetime, null: false
+ modify :updated_at, :utc_datetime, null: false
+ modify :received_at, :utc_datetime, null: false
+ end
+ end
+end
diff --git a/test/aprs/device_identification_test.exs b/test/aprs/device_identification_test.exs
index f781b8b..dd2841d 100644
--- a/test/aprs/device_identification_test.exs
+++ b/test/aprs/device_identification_test.exs
@@ -1,7 +1,8 @@
defmodule Aprs.DeviceIdentificationTest do
- use ExUnit.Case
+ use Aprs.DataCase, async: false
alias Aprs.DeviceIdentification
+ import DevicesSeeder
describe "identify_device/1" do
test "identifies Original MIC-E devices" do
@@ -89,4 +90,18 @@ defmodule Aprs.DeviceIdentificationTest do
assert DeviceIdentification.known_models("Unknown") == []
end
end
+
+ describe "lookup_device_by_identifier/1" do
+ test "matches APSK21 to APS??? pattern" do
+ # Seed the devices table from the JSON
+ seed_from_json()
+
+ # Should match
+ found = Aprs.DeviceIdentification.lookup_device_by_identifier("APSK21")
+ assert found != nil
+ assert found.identifier == "APS???"
+ assert found.model != nil
+ assert found.vendor != nil
+ end
+ end
end
diff --git a/test/aprs/encoding_utils_test.exs b/test/aprs/encoding_utils_test.exs
index 65a4411..93a4045 100644
--- a/test/aprs/encoding_utils_test.exs
+++ b/test/aprs/encoding_utils_test.exs
@@ -7,12 +7,6 @@ defmodule Aprs.EncodingUtilsTest do
doctest Aprs.EncodingUtils
describe "sanitize_string/1" do
- test "returns valid UTF-8 strings unchanged" do
- assert EncodingUtils.sanitize_string("Hello World") == "Hello World"
- assert EncodingUtils.sanitize_string("Café") == "Café"
- assert EncodingUtils.sanitize_string("你好") == "你好"
- end
-
test "handles nil input" do
assert EncodingUtils.sanitize_string(nil) == nil
end
diff --git a/test/aprs/packets_encoding_test.exs b/test/aprs/packets_encoding_test.exs
index f69ac0d..bb4da85 100644
--- a/test/aprs/packets_encoding_test.exs
+++ b/test/aprs/packets_encoding_test.exs
@@ -5,87 +5,6 @@ defmodule Aprs.PacketsEncodingTest do
alias Aprs.Packets
describe "store_packet/1 with encoding issues" do
- test "handles invalid UTF-8 bytes in information_field" do
- packet_data = %{
- base_callsign: "XE2CT",
- ssid: "10",
- data_type: "position",
- destination: "APDR15",
- information_field: "Test with invalid byte: " <> <<0xB0>>,
- path: "WIDE1-1,WIDE2-1",
- sender: "XE2CT-10",
- lat: 19.12345,
- lon: -99.54321,
- has_position: true
- }
-
- assert {:ok, stored_packet} = Packets.store_packet(packet_data)
- assert stored_packet.information_field == "Test with invalid byte:"
- assert stored_packet.sender == "XE2CT-10"
- end
-
- test "handles invalid UTF-8 bytes in sender field" do
- packet_data = %{
- base_callsign: "TEST",
- ssid: "1",
- data_type: "position",
- destination: "APDR15",
- information_field: "Valid information",
- path: "WIDE1-1,WIDE2-1",
- sender: "TEST" <> <<0xB0>> <> "-1",
- lat: 19.12345,
- lon: -99.54321,
- has_position: true
- }
-
- assert {:ok, stored_packet} = Packets.store_packet(packet_data)
- assert stored_packet.sender == "TEST-1"
- assert stored_packet.information_field == "Valid information"
- end
-
- test "handles invalid UTF-8 bytes in comment field" do
- packet_data = %{
- base_callsign: "TEST",
- ssid: "1",
- data_type: "position",
- destination: "APDR15",
- information_field: "Valid information",
- path: "WIDE1-1,WIDE2-1",
- sender: "TEST-1",
- comment: "Comment with invalid bytes: " <> <<0xB0, 0xFF, 0x80>>,
- lat: 19.12345,
- lon: -99.54321,
- has_position: true
- }
-
- assert {:ok, stored_packet} = Packets.store_packet(packet_data)
- assert stored_packet.comment == "Comment with invalid bytes:"
- assert stored_packet.sender == "TEST-1"
- end
-
- test "handles multiple invalid UTF-8 bytes across different fields" do
- packet_data = %{
- base_callsign: "TEST" <> <<0xB0>>,
- ssid: "1",
- data_type: "position",
- destination: "APDR" <> <<0xFF>> <> "15",
- information_field: "Info " <> <<0x80, 0x81>>,
- path: "WIDE1-1" <> <<0xB0>> <> ",WIDE2-1",
- sender: "TEST-1",
- comment: "Comment " <> <<0xB0>>,
- lat: 19.12345,
- lon: -99.54321,
- has_position: true
- }
-
- assert {:ok, stored_packet} = Packets.store_packet(packet_data)
- assert stored_packet.base_callsign == "TEST"
- assert stored_packet.destination == "APDR15"
- assert stored_packet.information_field == "Info"
- assert stored_packet.path == "WIDE1-1,WIDE2-1"
- assert stored_packet.comment == "Comment"
- end
-
test "preserves valid UTF-8 characters" do
packet_data = %{
base_callsign: "TEST",
@@ -174,35 +93,13 @@ defmodule Aprs.PacketsEncodingTest do
# Control characters should be filtered out
assert stored_packet.information_field == "Control chars:"
end
-
- test "handles the specific 0xb0 byte that caused the original error" do
- # This is the exact scenario from the error message
- packet_data = %{
- base_callsign: "XE2CT",
- ssid: "10",
- data_type: "position",
- destination: "APDR15",
- information_field: "Some text" <> <<0xB0>> <> "more text",
- path: "WIDE1-1,WIDE2-1",
- sender: "XE2CT-10",
- lat: 19.12345,
- lon: -99.54321,
- has_position: true
- }
-
- # This should not raise a Postgrex.Error anymore
- assert {:ok, stored_packet} = Packets.store_packet(packet_data)
- assert stored_packet.sender == "XE2CT-10"
- # The 0xb0 byte should be removed
- assert stored_packet.information_field == "Some textmore text"
- end
end
describe "EncodingUtils integration" do
test "sanitize_string removes invalid UTF-8 sequences" do
invalid_string = "Valid text" <> <<0xB0>> <> "more text"
sanitized = EncodingUtils.sanitize_string(invalid_string)
- assert sanitized == "Valid textmore text"
+ assert sanitized == "Valid text°more text"
end
test "sanitize_string preserves valid UTF-8" do
diff --git a/test/support/devices_seeder.exs b/test/support/devices_seeder.exs
new file mode 100644
index 0000000..ac2b1f3
--- /dev/null
+++ b/test/support/devices_seeder.exs
@@ -0,0 +1,41 @@
+# test/support/devices_seeder.exs
+
+alias Aprs.Repo
+alias Aprs.Devices
+
+defmodule DevicesSeeder do
+ def seed_from_json(path \\ "test/support/test_devices.json") do
+ {:ok, body} = File.read(path)
+ {:ok, json} = Jason.decode(body)
+ tocalls = Map.get(json, "tocalls", %{})
+ mice = Map.get(json, "mice", %{})
+ micelegacy = Map.get(json, "micelegacy", %{})
+ now = DateTime.utc_now()
+
+ Repo.delete_all(Devices)
+
+ [tocalls, mice, micelegacy]
+ |> Enum.each(fn group ->
+ Enum.each(group, fn {identifier, attrs} ->
+ attrs =
+ attrs
+ |> Map.put("identifier", identifier)
+ |> Map.update("features", nil, fn f ->
+ if is_list(f), do: f, else: [f]
+ end)
+ |> Map.put("updated_at", now)
+ %Devices{}
+ |> Devices.changeset(attrs)
+ |> Ecto.Changeset.apply_changes()
+ |> Map.from_struct()
+ |> then(fn map ->
+ Repo.insert!(
+ Devices.changeset(%Devices{}, map),
+ on_conflict: :replace_all,
+ conflict_target: :identifier
+ )
+ end)
+ end)
+ end)
+ end
+end
diff --git a/test/support/test_devices.json b/test/support/test_devices.json
new file mode 100644
index 0000000..7297b6d
--- /dev/null
+++ b/test/support/test_devices.json
@@ -0,0 +1 @@
+{"mice":{"_\"":{"class":"rig","model":"FTM-350","vendor":"Yaesu"},"(8":{"model":"D878UV","class":"ht","vendor":"Anytone"},":2":{"class":"tracker","vendor":"SQ8L","model":"VP-Tracker"},"|4":{"vendor":"Byonics","class":"tracker","model":"TinyTrak4"},"_ ":{"class":"ht","model":"VX-8","vendor":"Yaesu"},"|3":{"class":"tracker","vendor":"Byonics","model":"TinyTrak3"},"(5":{"model":"D578UV","class":"ht","vendor":"Anytone"},"_1":{"model":"FTM-300D","class":"rig","vendor":"Yaesu"},"_3":{"model":"FT5D","class":"ht","vendor":"Yaesu"},"_2":{"model":"FTM-200D","class":"rig","vendor":"Yaesu"},"_#":{"vendor":"Yaesu","class":"ht","model":"VX-8G"},"_4":{"vendor":"Yaesu","class":"rig","model":"FTM-500D"},"_0":{"class":"ht","vendor":"Yaesu","model":"FT3D"},"_(":{"class":"ht","model":"FT2D","vendor":"Yaesu"}," X":{"class":"tracker","vendor":"SainSonic","model":"AP510"},"^v":{"vendor":"HinzTec","model":"anyfrog"},"_%":{"model":"FTM-400DR","class":"rig","vendor":"Yaesu"},"_5":{"vendor":"Yaesu","class":"rig","model":"FTM-510D"},"_$":{"vendor":"Yaesu","class":"ht","model":"FT1D"},"[1":{"class":"software","vendor":"Open Source","model":"APRSdroid","os":"Android"},"_)":{"class":"rig","model":"FTM-100D","vendor":"Yaesu"},"*v":{"class":"tracker","model":"Tracker","vendor":"KissOZ"}},"tocalls":{"API92":{"class":"dstar","model":"IC-92","vendor":"Icom"},"APBT62":{"class":"ht","model":"DMR 6x2","vendor":"BTECH","contact":"support@baofengtech.com"},"APETBT":{"contact":"roel@kroes.com","os":"embedded","class":"tracker","model":"TBTracker Balloon Telemetry Tracker","vendor":"PD7R"},"APT4??":{"class":"tracker","vendor":"Byonics","model":"TinyTrak4"},"APSFTL":{"model":"LoRa/APRS Telemetry Reporter","vendor":"F5OPV, SFCP_LABS","os":"embedded"},"APESPG":{"os":"embedded","vendor":"OH2TH","model":"ESP SmartBeacon APRS-IS Client"},"APWM??":{"model":"APRSISCE","class":"software","features":["messaging","item-in-msg"],"os":"Windows Mobile","vendor":"KJ4ERJ"},"APRFGL":{"contact":"info@rf.guru","os":"embedded","class":"digi","model":"Lora APRS Digipeater","vendor":"RF.Guru"},"APK1??":{"vendor":"Kenwood","class":"rig","model":"TM-D700"},"APMI04":{"os":"embedded","vendor":"Microsat","model":"WX3in1 Mini"},"APY200":{"class":"rig","model":"FTM-200D","vendor":"Yaesu"},"APTW??":{"class":"wx","vendor":"Byonics","model":"WXTrak"},"APSN01":{"features":["messaging"],"os":"embedded","vendor":"CSN Technologies Inc.","model":"iGateMini","contact":"info@igatemini.com"},"APR2MF":{"vendor":"Mike, DL2MF","class":"wx","model":"MF2wxAPRS Tinkerforge gateway","os":"Windows"},"APZTKP":{"os":"embedded","class":"tracker","vendor":"Nick Hanks, N0LP","model":"TrackPoint"},"APTLVC":{"vendor":"TA5LVC","class":"tracker","model":"TR80 APRS Tracker"},"APXR??":{"vendor":"G8PZT","model":"Xrouter"},"APDW??":{"vendor":"WB2OSZ","model":"DireWolf"},"APMON?":{"os":"embedded","vendor":"Amon Schumann, DL9AS","class":"tracker","model":"APRS Balloon Tracker"},"APMI06":{"os":"embedded","vendor":"Microsat","model":"WX3in1 Plus 2.0"},"APRRES":{"os":"embedded","features":["messaging"],"vendor":"xssfox","class":"network","model":"APRS-RepeaterRescue","contact":"repeater-rescue@michaela.lgbt"},"API51":{"model":"IC-51","class":"dstar","vendor":"Icom"},"APLP0?":{"os":"embedded","class":"digi","model":"fajne digi","vendor":"SQ9P","contact":"sq9p.peter@gmail.com"},"APZ*":{"model":"Experimental","vendor":"Unknown"},"APQTH?":{"class":"software","model":"QTH.app","os":"macOS","features":["messaging"],"vendor":"Weston Bustraan, W8WJB"},"APBK??":{"model":"Bravo Tracker","class":"tracker","vendor":"PY5BK"},"APLDAG":{"contact":"ea2cq@irratia.org","features":["messaging"],"class":"service","model":"DAGA LoRa/APRS SOTA spotting","vendor":"Inigo, EA2CQ"},"APLRG?":{"class":"igate","model":"ESP32 LoRa iGate","vendor":"Ricardo, CA2RXU","os":"embedded","contact":"richonguzman@gmail.com"},"APRFGR":{"model":"Repeater","class":"rig","vendor":"RF.Guru","os":"embedded","contact":"info@rf.guru"},"APERS?":{"class":"tracker","model":"Runner tracking","vendor":"Jason, KG7YKZ"},"APDV??":{"class":"software","model":"SSTV with APRS","vendor":"OE6PLD"},"APRFGM":{"vendor":"RF.Guru","class":"rig","model":"Mobile Radio","os":"embedded","contact":"info@rf.guru"},"APFG??":{"class":"software","model":"Flood Gage","vendor":"KP4DJT"},"APMPAD":{"class":"service","vendor":"DF1JSL","model":"Multi-Purpose APRS Daemon","features":["messaging"],"contact":"joerg.schultze.lutter@gmail.com"},"APOZ??":{"class":"tracker","model":"KissOZ","vendor":"OZ1EKD, OZ7HVO"},"APZ19":{"model":"UIdigi","class":"digi","vendor":"IW3FQG"},"APEP??":{"vendor":"Patrick EGLOFF, TK5EP","class":"wx","model":"LoRa WX station","os":"embedded","contact":"pegloff@gmail.com"},"APLZX?":{"contact":"lora-aprs@n1af.org","vendor":"N1AF","model":"LoRa-APRS","os":"embedded"},"APSTAR":{"vendor":"AllStar Link LLC","class":"daemon","model":"Asterisk/app_rpt","os":"Linux/Unix"},"APAEP1":{"class":"satellite","vendor":"Paraguay Space Agency (AEP)","model":"EIRUAPRSDIGIS&FV1"},"APTR??":{"model":"MotoTRBO","vendor":"Motorola"},"AP1WWX":{"vendor":"TAPR","class":"wx","model":"T-238+"},"APPIC?":{"vendor":"DB1NTO","class":"tracker","model":"PicoAPRS"},"APRRT?":{"class":"tracker","vendor":"RPC Electronics","model":"RTrak"},"API970":{"model":"IC-9700","class":"dstar","vendor":"Icom"},"APCLEY":{"class":"tracker","model":"EYTraker","vendor":"ZS6EY"},"APBTUV":{"vendor":"BTECH","class":"ht","model":"UV-PRO","contact":"support@baofengtech.com"},"API710":{"class":"dstar","model":"IC-7100","vendor":"Icom"},"APDR??":{"vendor":"Open Source","class":"app","model":"APRSdroid","os":"Android"},"APVR??":{"vendor":"unknown","model":"IRLP"},"APHW??":{"vendor":"HamWAN"},"APLRT?":{"vendor":"Ricardo, CA2RXU","class":"tracker","model":"ESP32 LoRa Tracker","os":"embedded","contact":"richonguzman@gmail.com"},"APY01D":{"model":"FT1D","class":"ht","vendor":"Yaesu"},"APMI03":{"model":"PLXDigi","vendor":"Microsat","os":"embedded"},"APOSBM":{"contact":"info@sharkrf.com","os":"embedded","class":"gadget","model":"M1KE","vendor":"SharkRF"},"APSVX?":{"contact":"aprs-deviceid@cyberspejs.net","class":"daemon","vendor":"Tobias Blomberg, SM0SVX","model":"SvxLink","os":"Linux/Unix"},"APDST?":{"os":"embedded","vendor":"SP9UOB","model":"dsTracker"},"APSN??":{"vendor":"CSN Technologies Inc."},"APRS":{"vendor":"Unknown","model":"Unknown"},"APTNG?":{"vendor":"Filip YU1TTN","class":"tracker","model":"Tango Tracker"},"API31":{"model":"IC-31","class":"dstar","vendor":"Icom"},"APLZA?":{"model":"LoRa","vendor":"Huang Xuewu, BD5HTY","os":"embedded","contact":"bd5hty@gmail.com"},"APNU??":{"model":"UIdigi","class":"digi","vendor":"IW3FQG"},"APFI??":{"class":"app","vendor":"aprs.fi"},"APCSMS":{"model":"Cosmos","vendor":"USNA"},"APSFRP":{"vendor":"F5OPV, SFCP_LABS","model":"VHF/UHF Repeater","os":"embedded"},"APU2*":{"vendor":"Roger Barker, G4IDE","class":"software","model":"UI-View32","os":"Windows"},"APW2W?":{"os":"Windows","model":"WiresX2Web Software","class":"software","vendor":"Joachim Sonnabend, DG3FBL","contact":"mail@dg3fbl.de"},"APLM??":{"class":"software","vendor":"WA0TQG"},"APAGW":{"model":"AGWtracker","class":"software","vendor":"SV2AGW","os":"Windows"},"APT3??":{"model":"TinyTrak3","class":"tracker","vendor":"Byonics"},"APNCM":{"contact":"wa0tjt@gmail.com","model":"Net Control Manager","class":"software","vendor":"Keith Kaiser, WA0TJT","os":"browser"},"APNM??":{"vendor":"MFJ","model":"TNC"},"APLS??":{"model":"SARIMESH","class":"software","vendor":"SARIMESH"},"APC???":{"class":"app","vendor":"Rob Wittner, KZ5RW","model":"APRS/CE"},"APBPQ?":{"os":"Windows","model":"BPQ32","class":"software","vendor":"John Wiseman, G8BPQ"},"APOSB":{"contact":"info@sharkrf.com","class":"gadget","vendor":"SharkRF","model":"openSPOT3","os":"embedded"},"APLIF?":{"class":"igate","vendor":"TA5Y","model":"TIF LORA APRS I-GATE"},"APE???":{"model":"Telemetry devices"},"APHPIW":{"model":"Python APRS WX","vendor":"HP3ICC"},"APD5T?":{"class":"dstar","model":"Open Source DStarGateway","vendor":"Geoffrey, F4FXL","contact":"f4fxl@dstargateway.digital"},"APZ247":{"model":"UPRS","vendor":"NR0Q"},"APSFLG":{"model":"LoRa/APRS Gateway","class":"digi","vendor":"F5OPV, SFCP_LABS","os":"embedded"},"APAVT5":{"model":"AP510","class":"tracker","vendor":"SainSonic"},"APRFGD":{"class":"digi","model":"APRS Digipeater","vendor":"RF.Guru","os":"embedded","contact":"info@rf.guru"},"APPT??":{"model":"KetaiTracker","class":"tracker","vendor":"JF6LZE"},"APYS??":{"model":"Python APRS","class":"software","vendor":"W2GMD"},"APZWKR":{"class":"software","model":"NetSked","vendor":"GM1WKR"},"APNV??":{"vendor":"SQ8L"},"APSFWX":{"class":"wx","vendor":"F5OPV, SFCP_LABS","model":"embedded Weather Station","os":"embedded"},"APTCMA":{"class":"tracker","model":"CAPI Tracker","vendor":"Cleber, PU1CMA"},"APKEYn":{"os":"embedded","class":"tracker","vendor":"9W2KEY","model":"ATMega328P APRS","contact":"mzakiab@gmail.com"},"APREST":{"model":"HTTP - TCP CWOP Packet Submission","class":"service","vendor":"cwop.rest","contact":"leo@herzog.tech"},"APZMAJ":{"model":"DeLorme inReach Tracker","vendor":"M1MAJ"},"APK005":{"class":"ht","model":"TH-D75","vendor":"Kenwood"},"APDNO?":{"os":"embedded","class":"tracker","vendor":"DO3SWW","model":"APRSduino"},"APRFGT":{"class":"tracker","vendor":"RF.Guru","model":"LoRa APRS Tracker","os":"embedded","contact":"info@rf.guru"},"APOLU?":{"vendor":"AMSAT-LU","class":"satellite","model":"Oscar"},"APTPN?":{"model":"TARPN Packet Node Tracker","class":"tracker","vendor":"KN4ORB"},"APVE??":{"vendor":"unknown","model":"EchoLink"},"APNX??":{"vendor":"K6DBG","model":"TNC-X"},"APnnnD":{"model":"uSmartDigi D-Gate","class":"dstar","vendor":"Painter Engineering"},"APMBLN":{"contact":"support@mobilinkd.com","model":"NucleoTNC","class":"digi","vendor":"Mobilinkd LLC","os":"embedded"},"APMQ??":{"model":"Ham Radio of Things","vendor":"WB2OSZ"},"API910":{"model":"IC-9100","class":"dstar","vendor":"Icom"},"APHRT?":{"contact":"iw1cgw@libero.it","vendor":"Giovanni, IW1CGW","model":"Telemetry"},"APE2A?":{"class":"software","vendor":"NoseyNick, VA3NNW","model":"Email-2-APRS gateway","os":"Linux/Unix"},"APTEMP":{"contact":"kl7af@foghaven.net","os":"Linux/Unix","class":"wx","vendor":"KL7AF","model":"APRS-Tempest Weather Gateway"},"APJE??":{"vendor":"Gregg Wonderly, W5GGW","model":"JeAPRS"},"APRX??":{"class":"igate","vendor":"Kenneth W. Finnegan, W6KWF","model":"Aprx","os":"Linux/Unix"},"APKHTW":{"contact":"w3sn@moxracing.33mail.com","model":"Tempest Weather Bridge","class":"wx","vendor":"Kip, W3SN","os":"embedded"},"APNW??":{"os":"embedded","vendor":"SQ3FYK","model":"WX3in1"},"APCLEZ":{"vendor":"ZS6EY","class":"tracker","model":"Telit EZ10 GSM application"},"APODOT":{"model":"Oregon Department of Transportion Traffic Alerts","class":"service","vendor":"Mike, NA7Q"},"APLHM?":{"vendor":"Giovanni, IW1CGW","class":"wx","model":"LoRa Meteostation","contact":"iw1cgw@libero.it"},"APLP1?":{"os":"embedded","vendor":"SQ9P","class":"tracker","model":"LORA/FSK/AFSK fajny tracker","contact":"sq9p.peter@gmail.com"},"APUDR?":{"vendor":"NW Digital Radio","model":"UDR"},"APAG??":{"model":"AGate"},"APTT*":{"vendor":"Byonics","class":"tracker","model":"TinyTrak"},"APIE??":{"model":"PiAPRS","vendor":"W7KMV"},"APAT??":{"vendor":"Anytone"},"APK0??":{"model":"TH-D7","class":"ht","vendor":"Kenwood"},"APDU??":{"vendor":"JA7UDE","class":"app","model":"U2APRS","os":"Android"},"APAF??":{"model":"AFilter"},"APOG7?":{"contact":"Roger VK3KYY/G4KYF","os":"embedded","vendor":"OpenGD77","model":"OpenGD77"},"APMI02":{"vendor":"Microsat","model":"WXEth","os":"embedded"},"APCLUB":{"model":"Brazil APRS network"},"APRPR?":{"contact":"dm4rw@skywaves.de","os":"embedded","class":"tracker","model":"Teensy RPR TNC","vendor":"Robert DM4RW, Peter DL6MAA"},"APDS??":{"os":"embedded","vendor":"SP9UOB","model":"dsDIGI"},"APOPYT":{"model":"NA7Q Messenger","class":"software","vendor":"Mike, NA7Q","contact":"mike.ph4@gmail.com"},"APZ186":{"class":"digi","model":"UIdigi","vendor":"IW3FQG"},"APWW??":{"class":"software","model":"APRSIS32","os":"Windows","features":["messaging","item-in-msg"],"vendor":"KJ4ERJ"},"APRFGI":{"contact":"info@rf.guru","os":"embedded","class":"igate","vendor":"RF.Guru","model":"LoRa APRS iGate"},"APN3??":{"vendor":"Kantronics","model":"KPC-3"},"APnnnU":{"model":"uSmartDigi Digipeater","class":"digi","vendor":"Painter Engineering"},"APU1??":{"class":"software","vendor":"Roger Barker, G4IDE","model":"UI-View16","os":"Windows"},"APPCO?":{"contact":"ab4mw@radcommsoft.com","model":"PicoAPRSTracker","class":"tracker","vendor":"RadCommSoft, LLC","os":"embedded"},"APLDH?":{"os":"embedded","vendor":"Eddie, 9W2LWK","class":"tracker","model":"LoraTracker","contact":"9w2lwk@gmail.com"},"APVM??":{"model":"DRCC-DVM","class":"igate","vendor":"Digital Radio China Club"},"APRFGB":{"os":"embedded","vendor":"RF.Guru","model":"APRS LoRa Pager","contact":"info@rf.guru"},"APESP1":{"os":"embedded","model":"APRS-ESP","vendor":"LY3PH"},"APHMEY":{"contact":"oh2th@iki.fi","vendor":"Tapio Heiskanen, OH2TH","model":"APRS-IS Client for Athom Homey"},"APLDI?":{"class":"digi","model":"LoRa IGate/Digipeater","vendor":"David, OK2DDS"},"APTHUR":{"features":["messaging"],"os":"linux/unix","vendor":"YD0BCX","contact":"harihend1973@gmail.com","class":"service","model":"APRSThursday weekly event mapbot daemon"},"APP6??":{"model":"APRSlib"},"APHRM?":{"model":"Meteo","class":"wx","vendor":"Giovanni, IW1CGW","contact":"iw1cgw@libero.it"},"APLPS?":{"model":"ESP-32 LoRa","vendor":"Jose, XE3JAC","os":"embedded","contact":"xe3jac@gmail.com"},"APSTPO":{"class":"software","model":"Satellite Tracking and Operations","vendor":"N0AGI"},"APRNOW":{"vendor":"Gregg Wonderly, W5GGW","class":"app","model":"APRSNow","os":"ipad"},"APTKJ?":{"vendor":"W9JAJ","model":"ATTiny APRS Tracker","os":"embedded"},"APGBLN":{"class":"tracker","model":"GoBalloon","vendor":"NW5W"},"APSDR?":{"model":"sdr-control","class":"app","vendor":"Marcus Roskosch, DL8MRE","contact":"aprs@ham-radio-apps.com"},"APRARX":{"os":"Linux/Unix","vendor":"Open Source","class":"software","model":"radiosonde_auto_rx"},"APIN??":{"model":"PinPoint","vendor":"AB0WV"},"APJS??":{"vendor":"Peter Loveall, AE5PL","model":"javAPRSSrvr"},"APNV2?":{"class":"tracker","model":"VP-Tracker","vendor":"SQ8L"},"APBL??":{"vendor":"BigRedBee","class":"tracker","model":"BeeLine GPS"},"APPM??":{"class":"software","vendor":"DL1MX","model":"rtl-sdr Python iGate"},"APLFM?":{"os":"embedded","class":"tracker","model":"FemtoAPRS","vendor":"DO1MA"},"APPRIS":{"contact":"joerg.schultze.lutter@gmail.com","class":"service","model":"Apprise APRS plugin","vendor":"DF1JSL","features":["messaging"]},"APSMS?":{"vendor":"Paul Dufresne","class":"software","model":"SMS gateway"},"APOVU?":{"model":"BeliefSat","vendor":"K J Somaiya Institute"},"APPS??":{"os":"Linux","vendor":"Øyvind, LA7ECA (for the Norwegian Radio Relay League)","class":"software","model":"Polaric Server"},"APDI??":{"class":"software","model":"DIXPRS","vendor":"Bela, HA5DI"},"APCDS0":{"class":"tracker","vendor":"ZS6LMG","model":"cell tracker"},"APLER?":{"contact":"ta3oer@gmail.com","vendor":"Ercan, TA3OER","model":"TROY LoRa Tracker/iGate","os":"embedded"},"APATAR":{"vendor":"TA7W/OH2UDS Baris Dinc, TA6AD Emre Keles","class":"digi","model":"ATA-R APRS Digipeater"},"APLHI?":{"class":"digi","model":"LoRa IGate/Digipeater/Telemetry","vendor":"Giovanni, IW1CGW","contact":"iw1cgw@libero.it"},"APHPIA":{"vendor":"HP3ICC","model":"Arduino APRS"},"APJ8??":{"class":"software","vendor":"KN4CRD","model":"JS8Call"},"APNL??":{"model":"dxlAPRS","class":"daemon","vendor":"OE5DXL, OE5HPM","os":"Linux/Unix"},"APMI??":{"os":"embedded","vendor":"Microsat"},"APHAX?":{"os":"Windows","class":"software","vendor":"PY2UEP","model":"SM2APRS SondeMonitor"},"APTGIK":{"contact":"9m2ikr@gmail.com","model":"APRS Melaka","vendor":"Juliet Delta, 9M4GIK","os":"embedded"},"APY400":{"class":"rig","vendor":"Yaesu","model":"FTM-400"},"APHT??":{"model":"HMTracker","class":"tracker","vendor":"IU0AAC"},"APSK63":{"class":"software","model":"APRS Messenger","vendor":"Chris Moulding, G4HYG","os":"Windows"},"APWA??":{"os":"Android","class":"software","model":"APRSISCE","vendor":"KJ4ERJ"},"APT2??":{"class":"tracker","model":"TinyTrak2","vendor":"Byonics"},"APCLWX":{"vendor":"ZS6EY","class":"wx","model":"EYWeather"},"APHK??":{"vendor":"LA1BR","model":"Digipeater/tracker"},"APCN??":{"vendor":"DG5OAW","model":"carNET"},"APLRF?":{"contact":"sq2cpa@gmail.com","model":"LoRa APRS","vendor":"Damian, SQ2CPA","os":"embedded"},"APMBL3":{"contact":"support@mobilinkd.com","class":"digi","model":"TNC3","vendor":"Mobilinkd LLC","os":"embedded"},"APSAR":{"os":"Windows","model":"SARTrack","class":"software","vendor":"ZL4FOX"},"APLLO?":{"contact":"david.perrin@hb9hiz.ch","class":"tracker","vendor":"HB4LO","model":"HAB BOT"},"API880":{"class":"dstar","vendor":"Icom","model":"IC-880"},"APRFG?":{"contact":"info@rf.guru","vendor":"RF.Guru"},"APIZCI":{"os":"embedded","model":"hymTR IZCI Tracker","class":"tracker","vendor":"TA7W/OH2UDS Baris Dinc, TA6AD Emre Keles"},"APLFG?":{"class":"wx","vendor":"Gabor, HG3FUG","model":"LoRa WX station","os":"embedded","contact":"hg3fug@fazi.hu"},"APFII?":{"os":"ios","class":"app","vendor":"aprs.fi","model":"iPhone/iPad app"},"APLU1?":{"contact":"wajdzik.m@gmail.com","os":"embedded","model":"ESP32/SX12xx LoRa Tracker","class":"tracker","vendor":"SP9UP"},"APY500":{"class":"rig","model":"FTM-500D","vendor":"Yaesu"},"APZ18":{"vendor":"IW3FQG","class":"digi","model":"UIdigi"},"APLHB9":{"model":"LoRa iGate RPI","class":"igate","vendor":"SWISS-ARTG","os":"Linux/Unix","contact":"hb9pae@gmail.com"},"APRRF?":{"vendor":"RRF - Réseau des Répéteurs Francophones","os":"embedded","features":["messaging"],"class":"tracker","model":"Tracker for RRF","contact":"f1evm@f1evm.fr"},"APJI??":{"model":"jAPRSIgate","class":"software","vendor":"Peter Loveall, AE5PL"},"APS???":{"class":"software","vendor":"Brent Hildebrand, KH2Z","model":"APRS+SA"},"APRHH?":{"class":"tracker","vendor":"Steven D. Bragg, KA9MVA","model":"HamHud"},"APRFGH":{"contact":"info@rf.guru","class":"rig","model":"Hotspot","vendor":"RF.Guru","os":"embedded"},"APNV0?":{"os":"embedded","vendor":"SQ8L","class":"digi","model":"VP-Digi"},"APWEE?":{"class":"software","model":"WeeWX Weather Software","vendor":"Tom Keffer and Matthew Wall","os":"Linux/Unix"},"APERXQ":{"class":"tracker","model":"PE1RXQ APRS Tracker","vendor":"PE1RXQ"},"APLDM?":{"class":"wx","vendor":"David, OK2DDS","model":"LoRa Meteostation"},"APOPEN":{"contact":"dplatt@radagast.org","os":"embedded","vendor":"David Platt, AE6EO","model":"OpenTNC"},"APOCSG":{"model":"POCSAG","vendor":"N0AGI"},"APSRF?":{"class":"tracker","vendor":"SoftRF","model":"Ham Edition","os":"embedded"},"APAM??":{"class":"tracker","vendor":"Altus Metrum","model":"AltOS"},"APOSW?":{"os":"embedded","vendor":"SharkRF","class":"gadget","model":"openSPOT2","contact":"info@sharkrf.com"},"APMT??":{"class":"tracker","model":"Micro APRS Tracker","vendor":"LZ1PPL"},"APMI05":{"os":"embedded","model":"PLXTracker","vendor":"Microsat"},"APDG??":{"class":"dstar","model":"ircDDB Gateway","vendor":"Jonathan, G4KLX"},"APMG??":{"model":"PiCrumbs and MiniGate","class":"software","vendor":"Alex, AB0TJ"},"APCSS":{"vendor":"AMSAT","model":"CubeSatSim CubeSat Simulator"},"APDT??":{"vendor":"unknown","model":"APRStouch Tone (DTMF)"},"APDnnn":{"os":"Linux/Unix","class":"software","vendor":"Open Source","model":"aprsd"},"APN102":{"os":"ipad","class":"app","model":"APRSNow","vendor":"Gregg Wonderly, W5GGW"},"APMBL4":{"contact":"support@mobilinkd.com","vendor":"Mobilinkd LLC","class":"digi","model":"TNC4","os":"embedded"},"APDF??":{"model":"Automatic DF units"},"APOSB?":{"contact":"info@sharkrf.com","vendor":"SharkRF"},"APSTM?":{"model":"Balloon tracker","class":"tracker","vendor":"W7QO"},"AP4R??":{"vendor":"Open Source","class":"software","model":"APRS4R"},"APMI01":{"model":"WX3in1","vendor":"Microsat","os":"embedded"},"APLO??":{"vendor":"SQ9MDD","class":"tracker","model":"LoRa KISS TNC/Tracker"},"APAGW?":{"vendor":"SV2AGW","class":"software","model":"AGWtracker","os":"Windows"},"APIC??":{"model":"PICiGATE","vendor":"HA9MCQ"},"APBSD?":{"model":"HamBSD","vendor":"hambsd.org"},"APAH??":{"model":"AHub"},"APLDG?":{"contact":"9w2lwk@gmail.com","model":"LoRAIGate","class":"igate","vendor":"Eddie, 9W2LWK","os":"embedded"},"APLIG?":{"vendor":"TA2MUN/TA9OHC","class":"tracker","model":"LightAPRS Tracker"},"APY02D":{"class":"ht","vendor":"Yaesu","model":"FT2D"},"APOT??":{"class":"tracker","vendor":"Argent Data Systems","model":"OpenTracker"},"APY300":{"vendor":"Yaesu","class":"rig","model":"FTM-300D"},"APNKMX":{"vendor":"Kantronics","model":"KAM-XL"},"APKDXn":{"model":"LAHKHUANO APRS","class":"tracker","vendor":"KelateDX, 9M2D","os":"embedded","contact":"mzakiab@gmail.com"},"API282":{"vendor":"Icom","class":"dstar","model":"IC-2820"},"APELK?":{"class":"tracker","model":"Balloon tracker","vendor":"WB8ELK"},"API410":{"class":"dstar","model":"IC-4100","vendor":"Icom"},"APZG??":{"os":"Linux/Unix","vendor":"OH2GVE","class":"software","model":"aprsg"},"APNK80":{"model":"KAM","vendor":"Kantronics"},"APBT*":{"vendor":"BTECH","contact":"support@baofengtech.com"},"APAW??":{"os":"Windows","model":"AGWPE","class":"software","vendor":"SV2AGW"},"APESPW":{"model":"ESP Weather Station APRS-IS Client","vendor":"OH2TH","os":"embedded"},"APR8??":{"vendor":"Bob Bruninga, WB4APR","class":"software","model":"APRSdos"},"APBM??":{"model":"BrandMeister DMR","vendor":"R3ABM"},"APN9??":{"model":"KPC-9612","vendor":"Kantronics"},"APMBL?":{"contact":"support@mobilinkd.com","vendor":"Mobilinkd LLC"},"APAX??":{"model":"AFilterX"},"APRRDZ":{"class":"tracker","vendor":"DL9RDZ","model":"rdzTTGOsonde"},"APWnnn":{"os":"Windows","class":"software","vendor":"Sproul Brothers","model":"WinAPRS"},"APGO??":{"class":"app","model":"APRS-Go","vendor":"AA3NJ"},"APOSAT":{"contact":"mike.ph4@gmail.com","model":"Open Source Satellite Gateway","class":"service","vendor":"Mike, NA7Q"},"APLETK":{"class":"tracker","model":"T-Echo","vendor":"DL5TKL","os":"embedded","contact":"cfr34k-git@tkolb.de"},"APCTLK":{"class":"app","vendor":"Open Source","model":"Codec2Talkie"},"APSF??":{"model":"embedded APRS devices","vendor":"F5OPV, SFCP_LABS","os":"embedded"},"APLC??":{"model":"APRScube","vendor":"DL3DCW"},"APKRAM":{"os":"ios","class":"app","vendor":"kramstuff.com","model":"Ham Tracker"},"APY05D":{"model":"FT5D","class":"ht","vendor":"Yaesu"},"API???":{"class":"dstar","model":"unknown","vendor":"Icom"},"APHPIB":{"vendor":"HP3ICC","model":"Python APRS Beacon"},"APAT81":{"class":"ht","model":"AT-D878","vendor":"Anytone"},"APECAN":{"model":"Pecan Pico APRS Balloon Tracker","class":"tracker","vendor":"KT5TK/DL7AD"},"APLFL?":{"contact":"sq2cpa@gmail.com","class":"tracker","vendor":"Damian, SQ2CPA","model":"LoRa/APRS Balloon","os":"embedded"},"API510":{"class":"dstar","model":"IC-5100","vendor":"Icom"},"APDPRS":{"model":"D-Star APDPRS","class":"dstar","vendor":"unknown"},"APLU0?":{"os":"embedded","model":"ESP32/SX12xx LoRa iGate / Digi","class":"digi","vendor":"SP9UP","contact":"wajdzik.m@gmail.com"},"APY510":{"vendor":"Yaesu","class":"rig","model":"FTM-510D"},"APX???":{"os":"Linux/Unix","model":"Xastir","class":"software","vendor":"Open Source"},"APOSB4":{"os":"embedded","class":"gadget","model":"openSPOT4","vendor":"SharkRF","contact":"info@sharkrf.com"},"APN2??":{"model":"NOSaprs for JNOS 2.0","vendor":"VE4KLM"},"APNJS?":{"contact":"julien.owls@gmail.com","features":["messaging"],"model":"Web messaging service","class":"service","vendor":"Julien Sansonnens, HB9HRD"},"APJY??":{"model":"YAAC","class":"software","vendor":"KA2DDO"},"APHH?":{"model":"HamHud","class":"tracker","vendor":"Steven D. Bragg, KA9MVA"},"APRPJU":{"contact":"9m2pju@hamradio.my","features":["messaging"],"class":"daemon","model":"9M2PJU Bot","vendor":"Piju 9M2PJU"},"APTCHE":{"vendor":"PU3IKE","class":"tracker","model":"TcheTracker, Tcheduino"},"API80":{"model":"IC-80","class":"dstar","vendor":"Icom"},"APMAIL":{"vendor":"Mike, NA7Q","class":"service","model":"APRS Mailbox","contact":"mike.ph4@gmail.com"},"APND??":{"vendor":"PE1MEW","model":"DIGI_NED"},"APAIOR":{"vendor":"J. Angelo Racoma DU2XXR/N2RAC","features":["messaging"],"os":"linux","contact":"info@aprsph.net","class":"service","model":"APRSPH net bot based on Ioreth"},"APK003":{"vendor":"Kenwood","class":"ht","model":"TH-D72"},"APNK01":{"features":["messaging"],"class":"rig","model":"TM-D700","vendor":"Kenwood"},"APAND?":{"os":"Android","class":"app","vendor":"Open Source","model":"APRSdroid"},"APOSMS":{"vendor":"Mike, NA7Q","class":"service","model":"Open Source SMS Gateway","features":["messaging"],"contact":"mike.ph4@gmail.com"},"APK004":{"model":"TH-D74","class":"ht","vendor":"Kenwood"},"APTSLA":{"contact":"nonoo@nonoo.hu","vendor":"HA2NON","class":"daemon","model":"tesla-aprs"},"APTB??":{"vendor":"BG5HHP","model":"TinyAPRS"},"APWXS?":{"contact":"https://github.com/rhymeswithmogul/aprs-weather-submit/","class":"daemon","vendor":"Colin Cogle, W1DNS","model":"aprs-weather-submit"},"APAT51":{"class":"rig","model":"AT-D578","vendor":"Anytone"},"APHBL?":{"vendor":"KF7EEL","class":"software","model":"HBLink D-APRS Gateway"},"APAR??":{"model":"Arctic Tracker","class":"tracker","vendor":"Øyvind, LA7ECA","os":"embedded"},"APNV1?":{"vendor":"SQ8L","model":"VP-Node","os":"embedded"},"PSKAPR":{"vendor":"Open Source","class":"software","model":"PSKmail"},"APJID2":{"vendor":"Peter Loveall, AE5PL","class":"dstar","model":"D-Star APJID2"},"APB2MF":{"class":"software","vendor":"Mike, DL2MF","model":"MF2APRS Radiosonde tracking tool","os":"Windows"},"APRG??":{"os":"Linux/Unix","class":"software","vendor":"OH2GVE","model":"aprsg"},"APNP??":{"model":"TNC","vendor":"PacComm"},"APERRB":{"contact":"me@kg5jnc.com","class":"service","vendor":"KG5JNC","model":"APRS Backend for Errbot","features":["messaging"]},"APNKMP":{"model":"KAM+","vendor":"Kantronics"},"APNIC4":{"os":"embedded","class":"tracker","vendor":"SQ5EKU","model":"BidaTrak"},"APNT??":{"class":"digi","vendor":"SV2AGW","model":"TNT TNC as a digipeater"},"APGDT?":{"model":"Graphic Data Terminal","vendor":"VK4FAST"},"APZMDR":{"class":"tracker","model":"HaMDR","vendor":"Open Source","os":"embedded"},"APEML?":{"class":"software","model":"SP9MLI for WX, Telemetry","vendor":"Leszek, SP9MLI","contact":"sp9mli@gmail.com"},"APSC??":{"model":"aprsc","class":"software","vendor":"OH2MQK, OH7LZB"},"APLG??":{"model":"LoRa Gateway/Digipeater","class":"digi","vendor":"OE5BPA"},"APRFGP":{"contact":"info@rf.guru","os":"embedded","vendor":"RF.Guru","class":"ht","model":"Portable Radio"},"APJA??":{"model":"JavAPRS","vendor":"K4HG & AE5PL"},"APCWP8":{"class":"app","vendor":"GM7HHB","model":"WinphoneAPRS"},"APRFGW":{"os":"embedded","model":"LoRa APRS Weather Station","class":"wx","vendor":"RF.Guru","contact":"info@rf.guru"},"APOA??":{"class":"app","vendor":"OpenAPRS","model":"app","os":"ios"},"APW9??":{"os":"embedded","features":["messaging"],"vendor":"Mile Strk, 9A9Y","class":"wx","model":"WX Katarina"},"APLT??":{"class":"tracker","model":"LoRa Tracker","vendor":"OE5BPA"}},"micelegacy":{">":{"class":"ht","model":"TH-D7A","vendor":"Kenwood","prefix":">","features":["messaging"]},">=":{"class":"ht","model":"TH-D72","vendor":"Kenwood","features":["messaging"],"suffix":"=","prefix":">"},">^":{"class":"ht","model":"TH-D74","vendor":"Kenwood","prefix":">","suffix":"^","features":["messaging"]},"]=":{"suffix":"=","prefix":"]","features":["messaging"],"vendor":"Kenwood","class":"rig","model":"TM-D710"},"]":{"features":["messaging"],"prefix":"]","vendor":"Kenwood","class":"rig","model":"TM-D700"},">&":{"suffix":"&","prefix":">","features":["messaging"],"vendor":"Kenwood","model":"TH-D75","class":"ht"}},"meta":{"generation_time":"2025-05-14T05:49:35Z"},"classes":{"igate":{"description":"iGate software","shown":"iGate"},"ht":{"description":"Hand-held radio","shown":"HT"},"gadget":{"description":"Small non-tracker APRS device","shown":"Gadget"},"service":{"description":"Software running as a web service or a message bot","shown":"Service"},"digi":{"shown":"Digipeater","description":"Digipeater software"},"dstar":{"shown":"D-Star","description":"D-Star radio"},"satellite":{"shown":"Satellite","description":"Satellite-based station"},"wx":{"shown":"Weather station","description":"Dedicated weather station"},"daemon":{"description":"Computer software without user interface","shown":"Background software"},"tracker":{"shown":"Tracker","description":"Tracker device"},"app":{"description":"Mobile phone or tablet app","shown":"Mobile app"},"network":{"description":"A hardware appliance with self-contained APRS networking features","shown":"APRS network hardware appliance"},"software":{"description":"Desktop software","shown":"Desktop software"},"rig":{"description":"Mobile or desktop radio","shown":"Rig"}}}
\ No newline at end of file
diff --git a/test/test_helper.exs b/test/test_helper.exs
index 6e7c17c..daf1509 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -16,3 +16,5 @@ Application.put_env(:aprs, :aprs_is_default_filter, "r/0/0/1")
Application.put_env(:aprs, :packets_module, Aprs.PacketsMock)
# AprsIsMock is automatically loaded from test/support via elixirc_paths
+
+Code.require_file("support/devices_seeder.exs", __DIR__)