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 ``/`` 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