aprs.me/lib/aprs/device_identification.ex

187 lines
5.6 KiB
Elixir

defmodule Aprs.DeviceIdentification do
@moduledoc """
Handles APRS device identification based on the APRS device identification database.
"""
@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.
"""
@spec identify_device(String.t()) :: String.t()
def identify_device(symbols) do
Enum.find_value(@device_patterns, "Unknown", fn {regex, name} ->
if Regex.match?(regex, symbols) do
name
end
end)
end
@doc """
Returns a list of all known device manufacturers.
"""
@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.
"""
@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: []
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