From 54b3bbf5224706e67fb26008b508061d92076d58 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 23 Jun 2025 14:25:19 -0500 Subject: [PATCH] better device parsing --- lib/aprs/device_identification.ex | 49 +++++++++++++++++-- lib/aprs/devices.ex | 2 + lib/aprs/packets.ex | 13 +++-- lib/aprs_web/live/info_live/show.html.heex | 4 +- .../live/packets_live/callsign_view.ex | 14 ++++-- .../live/packets_live/callsign_view.html.heex | 6 +-- test/aprs/device_identification_test.exs | 17 ++++++- test/support/devices_seeder.exs | 8 +-- 8 files changed, 91 insertions(+), 22 deletions(-) diff --git a/lib/aprs/device_identification.ex b/lib/aprs/device_identification.ex index 94e3d96..79cca14 100644 --- a/lib/aprs/device_identification.ex +++ b/lib/aprs/device_identification.ex @@ -3,6 +3,11 @@ defmodule Aprs.DeviceIdentification do Handles APRS device identification based on the APRS device identification database. """ + import Ecto.Query + + alias Aprs.Devices + alias Aprs.Repo + @device_patterns [ {~r/^ \x00\x00$/, "Original MIC-E"}, {~r/^>\x00\^$/, "Kenwood TH-D74"}, @@ -33,6 +38,17 @@ defmodule Aprs.DeviceIdentification do @doc """ Identifies the manufacturer and model of an APRS device based on its symbol pattern. Returns a tuple of {manufacturer, model} or "Unknown" if the device cannot be identified. + + ## Examples + + iex> Aprs.DeviceIdentification.identify_device(">" <> <<0>> <> "^") + "Kenwood TH-D74" + + iex> Aprs.DeviceIdentification.identify_device("`_#") + "Yaesu VX-8G" + + iex> Aprs.DeviceIdentification.identify_device("not-a-match") + "Unknown" """ @spec identify_device(String.t()) :: String.t() def identify_device(symbols) do @@ -45,6 +61,14 @@ defmodule Aprs.DeviceIdentification do @doc """ Returns a list of all known device manufacturers. + + ## Examples + + iex> "Kenwood" in Aprs.DeviceIdentification.known_manufacturers() + true + + iex> Enum.member?(Aprs.DeviceIdentification.known_manufacturers(), "AP510") + true """ @spec known_manufacturers() :: [String.t()] def known_manufacturers do @@ -66,6 +90,14 @@ defmodule Aprs.DeviceIdentification do @doc """ Returns a list of all known device models for a given manufacturer. + + ## Examples + + iex> Aprs.DeviceIdentification.known_models("Kenwood") + ["TH-D74", "TH-D74A", "DM-710", "DM-700"] + + iex> Aprs.DeviceIdentification.known_models("Unknown") + [] """ @spec known_models(String.t()) :: [String.t()] def known_models("Kenwood"), do: ["TH-D74", "TH-D74A", "DM-710", "DM-700"] @@ -76,20 +108,19 @@ defmodule Aprs.DeviceIdentification do 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 @@ -99,10 +130,12 @@ defmodule Aprs.DeviceIdentification do 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 @@ -116,6 +149,7 @@ defmodule Aprs.DeviceIdentification do Repo.transaction(fn -> Repo.delete_all(Devices) + Enum.each([tocalls, mice, micelegacy], fn group -> Enum.each(group, fn {identifier, attrs} -> attrs = @@ -125,10 +159,12 @@ defmodule Aprs.DeviceIdentification do 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 @@ -140,12 +176,16 @@ defmodule Aprs.DeviceIdentification do @doc """ Looks up a device by identifier, using ? as a single-character wildcard. Returns the device struct if found, or nil. + + Note: This function requires the devices table to be seeded and a running Repo context. """ 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 @@ -155,9 +195,11 @@ defmodule Aprs.DeviceIdentification do _e in Regex.CompileError -> false end + String.contains?(pattern, "*") -> # Compare literally if pattern contains * but not ? pattern == identifier + true -> pattern == identifier end @@ -177,6 +219,7 @@ defmodule Aprs.DeviceIdentification do end defmodule Aprs.DeviceIdentification.Worker do + @moduledoc false use Oban.Worker, queue: :default, max_attempts: 1 @impl true diff --git a/lib/aprs/devices.ex b/lib/aprs/devices.ex index c2d21f9..d903460 100644 --- a/lib/aprs/devices.ex +++ b/lib/aprs/devices.ex @@ -1,5 +1,7 @@ defmodule Aprs.Devices do + @moduledoc false use Ecto.Schema + import Ecto.Changeset schema "devices" do diff --git a/lib/aprs/packets.ex b/lib/aprs/packets.ex index 41d0369..15f90e8 100644 --- a/lib/aprs/packets.ex +++ b/lib/aprs/packets.ex @@ -28,21 +28,22 @@ defmodule Aprs.Packets do |> normalize_packet_attrs() |> set_received_at() |> patch_lat_lon_from_data_extended() - |> (fn attrs -> + |> then(fn attrs -> {lat, lon} = extract_position(attrs) set_lat_lon(attrs, lat, lon) - end).() + end) |> normalize_ssid() - |> (fn attrs -> + |> then(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).() + 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) + packet_attrs = Map.new(packet_attrs, fn {k, v} -> {k, sanitize_packet_strings(v)} end) insert_packet(packet_attrs, packet_data) rescue error -> @@ -530,10 +531,12 @@ defmodule Aprs.Packets do 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 diff --git a/lib/aprs_web/live/info_live/show.html.heex b/lib/aprs_web/live/info_live/show.html.heex index 687ab4d..972bed6 100644 --- a/lib/aprs_web/live/info_live/show.html.heex +++ b/lib/aprs_web/live/info_live/show.html.heex @@ -43,7 +43,9 @@ alt="APRS symbol" /> <% end %> - <.link navigate={~p"/packets/#{@callsign}"} class="ml-2 text-sm text-blue-600 hover:underline">View packets + <.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 5b2ecc2..6fc408a 100644 --- a/lib/aprs_web/live/packets_live/callsign_view.ex +++ b/lib/aprs_web/live/packets_live/callsign_view.ex @@ -58,10 +58,12 @@ 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) + 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) @@ -69,6 +71,7 @@ defmodule AprsWeb.PacketsLive.CallsignView do 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) @@ -205,9 +208,10 @@ defmodule AprsWeb.PacketsLive.CallsignView do defp enrich_packet_with_device_info(packet) do device_identifier = Map.get(packet, :device_identifier) || Map.get(packet, "device_identifier") - device = if is_binary(device_identifier), do: Aprs.DeviceIdentification.lookup_device_by_identifier(device_identifier), else: nil - model = if device, do: device.model, else: nil - vendor = if device, do: device.vendor, else: nil + device = if is_binary(device_identifier), do: Aprs.DeviceIdentification.lookup_device_by_identifier(device_identifier) + model = if device, do: device.model + vendor = if device, do: device.vendor + packet |> Map.put(:device_model, model) |> Map.put(:device_vendor, vendor) 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 f0cb505..031926a 100644 --- a/lib/aprs_web/live/packets_live/callsign_view.html.heex +++ b/lib/aprs_web/live/packets_live/callsign_view.html.heex @@ -66,9 +66,9 @@ <: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(" ") %> + {[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/test/aprs/device_identification_test.exs b/test/aprs/device_identification_test.exs index dd2841d..2f0cb29 100644 --- a/test/aprs/device_identification_test.exs +++ b/test/aprs/device_identification_test.exs @@ -1,9 +1,12 @@ defmodule Aprs.DeviceIdentificationTest do use Aprs.DataCase, async: false - alias Aprs.DeviceIdentification import DevicesSeeder + alias Aprs.DeviceIdentification + + doctest Aprs.DeviceIdentification + describe "identify_device/1" do test "identifies Original MIC-E devices" do assert DeviceIdentification.identify_device(" " <> <<0, 0>>) == "Original MIC-E" @@ -103,5 +106,17 @@ defmodule Aprs.DeviceIdentificationTest do assert found.model != nil assert found.vendor != nil end + + test "matches Mic-E device identifier from raw packet" do + # Seed the devices table from the JSON + seed_from_json() + + # The device identifier extracted from the raw packet is "]=" + found = Aprs.DeviceIdentification.lookup_device_by_identifier("]=") + assert found != nil + assert found.identifier == "]=" + assert found.model != nil + assert found.vendor != nil + end end end diff --git a/test/support/devices_seeder.exs b/test/support/devices_seeder.exs index ac2b1f3..dde43ba 100644 --- a/test/support/devices_seeder.exs +++ b/test/support/devices_seeder.exs @@ -1,9 +1,9 @@ # test/support/devices_seeder.exs - -alias Aprs.Repo alias Aprs.Devices +alias Aprs.Repo defmodule DevicesSeeder do + @moduledoc false def seed_from_json(path \\ "test/support/test_devices.json") do {:ok, body} = File.read(path) {:ok, json} = Jason.decode(body) @@ -14,8 +14,7 @@ defmodule DevicesSeeder do Repo.delete_all(Devices) - [tocalls, mice, micelegacy] - |> Enum.each(fn group -> + Enum.each([tocalls, mice, micelegacy], fn group -> Enum.each(group, fn {identifier, attrs} -> attrs = attrs @@ -24,6 +23,7 @@ defmodule DevicesSeeder do if is_list(f), do: f, else: [f] end) |> Map.put("updated_at", now) + %Devices{} |> Devices.changeset(attrs) |> Ecto.Changeset.apply_changes()