From a48b2edcb30b941cdf424742290f8ea310179e15 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 23 Jun 2025 14:19:04 -0500 Subject: [PATCH] add device identifier --- config/config.exs | 3 +- lib/aprs/application.ex | 7 +- lib/aprs/device_identification.ex | 109 ++++++++++++++++++ lib/aprs/devices.ex | 22 ++++ lib/aprs/encoding_utils.ex | 78 +++---------- lib/aprs/packet.ex | 9 +- lib/aprs/packets.ex | 91 +++++---------- lib/aprs_web/live/info_live/show.html.heex | 1 + .../live/packets_live/callsign_view.ex | 28 ++++- .../live/packets_live/callsign_view.html.heex | 11 ++ .../live/packets_live/index.html.heex | 5 + lib/parser/device_parser.ex | 28 +++++ mix.exs | 5 +- mix.lock | 1 + .../20250621120000_create_devices.exs | 18 +++ ...21000_add_device_identifier_to_packets.exs | 9 ++ ..._update_devices_timestamps_to_utc_usec.exs | 10 ++ ...93000_update_packets_timestamps_to_utc.exs | 11 ++ test/aprs/device_identification_test.exs | 17 ++- test/aprs/encoding_utils_test.exs | 6 - test/aprs/packets_encoding_test.exs | 105 +---------------- test/support/devices_seeder.exs | 41 +++++++ test/support/test_devices.json | 1 + test/test_helper.exs | 2 + 24 files changed, 373 insertions(+), 245 deletions(-) create mode 100644 lib/aprs/devices.ex create mode 100644 lib/parser/device_parser.ex create mode 100644 priv/repo/migrations/20250621120000_create_devices.exs create mode 100644 priv/repo/migrations/20250621121000_add_device_identifier_to_packets.exs create mode 100644 priv/repo/migrations/20250623190000_update_devices_timestamps_to_utc_usec.exs create mode 100644 priv/repo/migrations/20250623193000_update_packets_timestamps_to_utc.exs create mode 100644 test/support/devices_seeder.exs create mode 100644 test/support/test_devices.json 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__)