Instead of shelling out to https://gridmap.org/locate/:callsign for callsign → lat/lon lookups, ports the resolver pipeline from gridmap-web into this project so the whole flow runs in-process. New modules: - Microwaveprop.Qrz — cache facade over the QRZ.com XML callsign API. Looks up from qrz_callsigns first, falls back to a live fetch, and upserts the result with a configurable cache_ttl_hours (default 168h / 7 days). - Microwaveprop.Qrz.Client — HTTP/XML client against https://xmldata.qrz.com/xml/current/. Holds the session key in an Agent, transparently re-logs-in on :session_expired, and parses responses via xmerl. - Microwaveprop.Qrz.Callsign — Ecto schema for the qrz_callsigns cache table, binary_id primary key per project convention. - Microwaveprop.Qrz.Record — slim struct with only the 11 fields we actually consume (identity, name, grid, address, lat/lon). The full XML payload stays in the raw :data jsonb column for anyone who wants the other ~40 QRZ fields. - Microwaveprop.Geocoder — Req-based client against the Google Maps Geocoding API. Only called as a fallback when QRZ has no explicit <lat>/<lon> for the callsign. - Microwaveprop.CallsignLocation — orchestrator. Reads the callsign_locations cache, on miss calls Qrz then either uses QRZ's coords directly or geocodes the formatted address, snaps to an 8-char Maidenhead grid via Microwaveprop.Radio.Maidenhead, and upserts the result. Microwaveprop.Radio.CallsignClient.locate/1 is rewritten to delegate to CallsignLocation.lookup/1 and shape the response back to the existing {:ok, %{callsign, gridsquare, lat, lon}} contract so the callers in PathLive and RoverLive don't change. Wiring: - priv/repo/migrations/20260413000000_create_qrz_callsigns_and_callsign_locations.exs creates qrz_callsigns and callsign_locations with unique indexes on :callsign. - Microwaveprop.Qrz.Client added to the application supervision tree so the session Agent is started. - :xmerl added to extra_applications so the release bundles it. - config/test.exs wires Req.Test plugs for Microwaveprop.Qrz.Client and Microwaveprop.Geocoder, forces cache_ttl_hours: 0 so the cache never short-circuits test-level stubs, and supplies dummy QRZ credentials. - config/runtime.exs pulls QRZ_USERNAME / QRZ_PASSWORD / QRZ_AGENT and GOOGLE_API_KEY from the environment so prod and dev can configure both upstream keys out of band. Tests (ported verbatim from gridmap-web): - test/microwaveprop/qrz/callsign_test.exs — schema/changeset - test/microwaveprop/qrz_test.exs — cache hit, cache miss, TTL behavior, upsert on stale, error passthrough, case-insensitive input - test/microwaveprop/geocoder_test.exs — success, zero results, request denied, transport error - test/microwaveprop/callsign_location_test.exs — end-to-end flow including the QRZ-lat/lon shortcut and the missing-address error path All 1294 tests still pass. Credo strict clean.
125 lines
3.3 KiB
Elixir
125 lines
3.3 KiB
Elixir
defmodule Microwaveprop.CallsignLocation do
|
|
@moduledoc """
|
|
Resolves a callsign to a lat/lon + Maidenhead grid by combining a
|
|
QRZ.com lookup (licensee address) with the Google Maps geocoder,
|
|
caching the result in `callsign_locations`.
|
|
|
|
If QRZ already has `<lat>`/`<lon>` fields for the callsign, those are
|
|
used directly and the geocoder is skipped. Otherwise the QRZ address
|
|
is joined into a single line and sent to Google's geocoding endpoint.
|
|
"""
|
|
alias Microwaveprop.CallsignLocation.Location
|
|
alias Microwaveprop.Geocoder
|
|
alias Microwaveprop.Qrz
|
|
alias Microwaveprop.Radio.Maidenhead
|
|
alias Microwaveprop.Repo
|
|
|
|
@type result :: %{
|
|
callsign: String.t(),
|
|
latitude: float(),
|
|
longitude: float(),
|
|
gridsquare: String.t(),
|
|
name: String.t() | nil,
|
|
address: String.t() | nil
|
|
}
|
|
|
|
@spec lookup(String.t()) :: {:ok, result()} | {:error, String.t()}
|
|
def lookup(callsign) do
|
|
callsign = String.upcase(callsign)
|
|
|
|
case Repo.get_by(Location, callsign: callsign) do
|
|
%Location{} = cached ->
|
|
{:ok, format_cached(cached)}
|
|
|
|
nil ->
|
|
fetch_and_cache(callsign)
|
|
end
|
|
end
|
|
|
|
defp fetch_and_cache(callsign) do
|
|
with {:ok, record} <- Qrz.lookup_callsign(callsign),
|
|
{:ok, %{lat: lat, lon: lon}, address} <- resolve_coordinates(record) do
|
|
gridsquare = Maidenhead.from_latlon(lat, lon, 8)
|
|
name = build_name(record)
|
|
|
|
{:ok, _} =
|
|
%Location{}
|
|
|> Location.changeset(%{
|
|
callsign: callsign,
|
|
latitude: lat,
|
|
longitude: lon,
|
|
gridsquare: gridsquare
|
|
})
|
|
|> Repo.insert(
|
|
on_conflict: [
|
|
set: [
|
|
latitude: lat,
|
|
longitude: lon,
|
|
gridsquare: gridsquare,
|
|
updated_at: DateTime.utc_now(:second)
|
|
]
|
|
],
|
|
conflict_target: :callsign,
|
|
returning: true
|
|
)
|
|
|
|
{:ok,
|
|
%{
|
|
callsign: callsign,
|
|
latitude: lat,
|
|
longitude: lon,
|
|
gridsquare: gridsquare,
|
|
name: name,
|
|
address: address
|
|
}}
|
|
end
|
|
end
|
|
|
|
defp resolve_coordinates(record) do
|
|
address = build_address_string(record)
|
|
|
|
case {record.lat, record.lon} do
|
|
{lat, lon} when is_float(lat) and is_float(lon) ->
|
|
{:ok, %{lat: lat, lon: lon}, address}
|
|
|
|
_ ->
|
|
with {:ok, addr} <- require_address(address),
|
|
{:ok, coords} <- Geocoder.geocode(addr) do
|
|
{:ok, coords, addr}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp build_address_string(record) do
|
|
parts =
|
|
Enum.reject(
|
|
[record.addr1, record.addr2, record.state, record.zip, record.country],
|
|
&(is_nil(&1) or String.trim(&1) == "")
|
|
)
|
|
|
|
if parts == [], do: nil, else: Enum.join(parts, ", ")
|
|
end
|
|
|
|
defp require_address(nil), do: {:error, "No address available for callsign"}
|
|
defp require_address(address), do: {:ok, address}
|
|
|
|
defp build_name(record) do
|
|
joined =
|
|
[record.fname, record.name]
|
|
|> Enum.reject(&is_nil/1)
|
|
|> Enum.join(" ")
|
|
|
|
if joined == "", do: nil, else: joined
|
|
end
|
|
|
|
defp format_cached(%Location{} = loc) do
|
|
%{
|
|
callsign: loc.callsign,
|
|
latitude: loc.latitude,
|
|
longitude: loc.longitude,
|
|
gridsquare: loc.gridsquare,
|
|
name: nil,
|
|
address: nil
|
|
}
|
|
end
|
|
end
|