aprs.me/lib/aprsme/device_identification.ex
Graham McIntire ff3a478bbd
perf: add LIMIT to unbounded DISTINCT ON, guard device table scan, add DB indexes
Bound batch DISTINCT ON queries by input count. Guard Repo.all(Devices) with exists? check. Add trigram GIN index on base_callsign and (has_weather, received_at DESC) partial index.

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 16:49:48 -05:00

269 lines
8.1 KiB
Elixir

defmodule Aprsme.DeviceIdentification do
@moduledoc """
Handles APRS device identification based on the APRS device identification database.
"""
use Gettext, backend: AprsmeWeb.Gettext
import Ecto.Query
alias Aprsme.CircuitBreaker
alias Aprsme.Devices
alias Aprsme.Repo
@device_patterns [
{~r/^ \x00\x00$/, "Original MIC-E"},
{~r/^>\x00\^$/, "Kenwood TH-D74"},
{~r/^>\x00\x00$/, "Kenwood TH-D74A"},
{~r/^]\x00=$/, "Kenwood DM-710"},
{~r/^]\x00\x00$/, "Kenwood DM-700"},
{~r/^`_ $/, "Yaesu VX-8"},
{~r/^`_\"$/, "Yaesu FTM-350"},
{~r/^`_#$/, "Yaesu VX-8G"},
{~r/^`_\$$/, "Yaesu FT1D"},
{~r/^`_%$/, "Yaesu FTM-400DR"},
{~r/^`_\)$/, "Yaesu FTM-100D"},
{~r/^`_\($/, "Yaesu FT2D"},
{~r/^` X$/, "AP510"},
{~r/^`\x00\x00$/, "Mic-Emsg"},
{~r/^'\|3$/, "Byonics TinyTrack3"},
{~r/^'\|4$/, "Byonics TinyTrack4"},
{~r/^':4$/, "SCS GmbH & Co. P4dragon DR-7400 modems"},
{~r/^':8$/, "SCS GmbH & Co. P4dragon DR-7800 modems"},
{~r/^'\x00\x00$/, "McTrackr"},
{~r/^\x00\"\x00$/, "Hamhud"},
{~r/^\x00\/\x00$/, "Argent"},
{~r/^\x00\^\x00$/, "HinzTec anyfrog"},
{~r/^\x00\*\x00$/, "APOZxx www.KissOZ.dk Tracker. OZ1EKD and OZ7HVO"},
{~r/^\x00~\x00$/, "Other"}
]
@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> Aprsme.DeviceIdentification.identify_device(">" <> <<0>> <> "^")
"Kenwood TH-D74"
iex> Aprsme.DeviceIdentification.identify_device("`_#")
"Yaesu VX-8G"
iex> Aprsme.DeviceIdentification.identify_device("not-a-match")
"Unknown"
"""
@spec identify_device(String.t()) :: String.t()
def identify_device(symbols) do
Enum.find_value(@device_patterns, gettext("Unknown"), fn {regex, name} ->
if Regex.match?(regex, symbols) do
name
end
end)
end
@doc """
Returns a list of all known device manufacturers.
## Examples
iex> "Kenwood" in Aprsme.DeviceIdentification.known_manufacturers()
true
iex> Enum.member?(Aprsme.DeviceIdentification.known_manufacturers(), "AP510")
true
"""
@spec known_manufacturers() :: [String.t()]
def known_manufacturers do
[
"Original MIC-E",
"Kenwood",
"Yaesu",
"AP510",
"Byonics",
"SCS GmbH & Co.",
"McTrackr",
"Hamhud",
"Argent",
"HinzTec",
"APOZxx",
"Other"
]
end
@doc """
Returns a list of all known device models for a given manufacturer.
## Examples
iex> Aprsme.DeviceIdentification.known_models("Kenwood")
["TH-D74", "TH-D74A", "DM-710", "DM-700"]
iex> Aprsme.DeviceIdentification.known_models("Unknown")
[]
"""
@spec known_models(String.t()) :: [String.t()]
def known_models("Kenwood"), do: ["TH-D74", "TH-D74A", "DM-710", "DM-700"]
def known_models("Yaesu"), do: ["VX-8", "FTM-350", "VX-8G", "FT1D", "FTM-400DR", "FTM-100D", "FT2D"]
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: []
@url "https://aprs-deviceid.aprsfoundation.org/tocalls.dense.json"
@week_seconds 7 * 24 * 60 * 60
def maybe_refresh_devices do
last = fetch_most_recent_device()
refresh_if_stale(last, to_datetime(last && last.updated_at))
end
defp fetch_most_recent_device do
Repo.one(from d in Devices, order_by: [desc: d.updated_at], limit: 1)
rescue
_ -> nil
end
defp to_datetime(%NaiveDateTime{} = naive), do: DateTime.from_naive!(naive, "Etc/UTC")
defp to_datetime(%DateTime{} = dt), do: dt
defp to_datetime(_), do: nil
# No devices at all — always refresh.
defp refresh_if_stale(nil, _last_time), do: fetch_and_upsert_devices()
# Stored rows but unknown timestamp — treat as stale.
defp refresh_if_stale(_last, nil), do: fetch_and_upsert_devices()
defp refresh_if_stale(_last, last_time) do
age_seconds = DateTime.diff(DateTime.utc_now(), last_time)
if age_seconds > @week_seconds, do: fetch_and_upsert_devices(), else: :ok
end
# The Devices schema uses :naive_datetime for its timestamps, so insert_all
# needs a NaiveDateTime, not a DateTime.
defp now_for_insert, do: NaiveDateTime.utc_now(:second)
def fetch_and_upsert_devices do
url = Application.get_env(:aprsme, :device_id_url, @url)
case CircuitBreaker.call(:aprs_foundation_api, fn -> fetch_devices_from_url(url) end, 15_000) do
{:ok, result} -> result
{:error, reason} -> {:error, reason}
end
end
@doc false
# Exposed for testing via URL injection.
def fetch_devices_from_url(url \\ @url) do
req_opts = Application.get_env(:aprsme, :device_id_req_opts, [])
case Req.get(url, req_opts) do
{:ok, %Req.Response{status: 200, body: body}} ->
upsert_devices(body)
{:ok, %Req.Response{status: status}} ->
{:error, {:http_error, status}}
{:error, reason} ->
{:error, reason}
end
end
def upsert_devices(json) do
tocalls = Map.get(json, "tocalls", %{})
mice = Map.get(json, "mice", %{})
micelegacy = Map.get(json, "micelegacy", %{})
now = now_for_insert()
all_devices =
Enum.flat_map([tocalls, mice, micelegacy], fn group ->
Enum.map(group, fn {identifier, attrs} ->
process_device_attrs(attrs, identifier, now)
end)
end)
{:ok, _result} =
Repo.transaction(fn ->
Repo.delete_all(Devices)
Repo.insert_all(Devices, all_devices)
end)
:ok
end
# Whitelist of JSON keys that map directly to Devices schema fields.
# Using an explicit list avoids String.to_atom on untrusted input.
@device_fields ~w(identifier class model vendor os contact features)
defp process_device_attrs(attrs, identifier, now) do
attrs
|> Map.put("identifier", identifier)
|> Map.update("features", nil, &normalize_features/1)
|> Map.take(@device_fields)
|> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end)
|> Map.put(:inserted_at, now)
|> Map.put(:updated_at, now)
end
defp normalize_features(features) when is_list(features), do: features
defp normalize_features(nil), do: nil
defp normalize_features(other), do: [other]
@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(nil), do: nil
def lookup_device_by_identifier(identifier) when is_binary(identifier) do
# Use device cache for better performance
if Code.ensure_loaded?(Aprsme.DeviceCache) do
Aprsme.DeviceCache.lookup_device(identifier)
else
# Fallback to direct DB lookup if no cache available
lookup_device_from_db(identifier)
end
end
defp lookup_device_from_db(identifier) do
if Repo.exists?(from(d in Devices, limit: 1)) do
devices = Repo.all(Devices)
Enum.find(devices, fn device ->
pattern_matches?(device.identifier, identifier)
end)
end
rescue
_ -> nil
end
defp pattern_matches?(pattern, identifier) do
match_pattern(String.contains?(pattern, "?"), pattern, identifier)
end
defp match_pattern(true, pattern, identifier) do
regex = wildcard_pattern_to_regex(pattern)
Regex.match?(regex, identifier)
rescue
_e in Regex.CompileError -> false
end
defp match_pattern(false, pattern, identifier), do: pattern == identifier
# 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(fn s ->
regex = "^" <> s <> "$"
~r/#{regex}/
end)
end
end