defmodule Microwaveprop.Beacons.Beacon do @moduledoc """ A beacon transmitter. Frequency in MHz, coordinates as lat/lon, grid as Maidenhead (auto-computed from lat/lon when not supplied), power in milliwatts, height above ground in feet. """ use Ecto.Schema import Ecto.Changeset alias Microwaveprop.Radio.Maidenhead @keying_entries [ {"on_off", "On/Off"}, {"fsk", "FSK"}, {"fm_voice", "FM Voice"}, {"wspr", "WSPR"}, {"q65a_15", "Q65A-15"}, {"q65a_30", "Q65A-30"}, {"q65a_60", "Q65A-60"}, {"q65a_120", "Q65A-120"}, {"q65b_15", "Q65B-15"}, {"q65b_30", "Q65B-30"}, {"q65b_60", "Q65B-60"}, {"q65b_120", "Q65B-120"}, {"q65c_15", "Q65C-15"}, {"q65c_30", "Q65C-30"}, {"q65c_60", "Q65C-60"}, {"q65c_120", "Q65C-120"}, {"q65d_15", "Q65D-15"}, {"q65d_30", "Q65D-30"}, {"q65d_60", "Q65D-60"}, {"q65d_120", "Q65D-120"}, {"q65e_15", "Q65E-15"}, {"q65e_30", "Q65E-30"}, {"q65e_60", "Q65E-60"}, {"q65e_120", "Q65E-120"} ] @keyings Enum.map(@keying_entries, fn {k, _} -> k end) @keying_labels Map.new(@keying_entries) @spec keyings() :: [String.t()] def keyings, do: @keyings @spec keying_label(String.t()) :: String.t() def keying_label(key), do: Map.get(@keying_labels, key, key) @doc """ Formats a milliwatt power value as a plain decimal string, never scientific notation. Integers are printed without a decimal; floats keep up to three decimals with trailing zeros trimmed. """ @spec format_mw(number() | nil | term()) :: String.t() def format_mw(nil), do: "" def format_mw(mw) when is_number(mw) do format_number(mw) end def format_mw(mw), do: to_string(mw) @doc "Formats a frequency in MHz with comma separators (e.g. 10368 → \"10,368\")." @spec format_freq(number() | nil | term()) :: String.t() def format_freq(nil), do: "" def format_freq(mhz) when is_number(mhz) do format_number(mhz) end def format_freq(mhz), do: to_string(mhz) # Shared formatter for format_mw/1 and format_freq/1. Unconditionally # renders to 3 decimal places, trims trailing zeros, and handles the # edge case where `trim_trailing_zeros/1` strips the decimal point # entirely (e.g. 24192.0 → "24192.000" → "24192", which previously # crashed the `[_int, frac_part] = ...` pattern match when # `frac != 0.0` due to float rounding error). defp format_number(n) when is_integer(n), do: add_commas(n) defp format_number(n) when is_float(n) do formatted_int = n |> trunc() |> add_commas() decimal = n |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros() case String.split(decimal, ".") do [_int_only] -> formatted_int [_int, frac_part] -> "#{formatted_int}.#{frac_part}" end end defp add_commas(int) do int |> Integer.to_string() |> String.reverse() |> String.replace(~r/.{3}/, "\\0,") |> String.trim_trailing(",") |> String.reverse() end defp trim_trailing_zeros(str) do if String.contains?(str, ".") do str |> String.trim_trailing("0") |> String.trim_trailing(".") else str end end @doc """ Keying options for `Phoenix.HTML.Form.options_for_select/2`, grouped for a cleaner UI when dozens of weak-signal modes are present. """ @spec keying_options() :: [{String.t(), [{String.t(), String.t()}]}] def keying_options do [ {"Simple", [ {"On/Off", "on_off"}, {"FSK", "fsk"} ]}, {"Voice / Digital", [ {"FM Voice", "fm_voice"}, {"WSPR", "wspr"} ]}, {"Q65A", q65_group("a")}, {"Q65B", q65_group("b")}, {"Q65C", q65_group("c")}, {"Q65D", q65_group("d")}, {"Q65E", q65_group("e")} ] end defp q65_group(letter) do for period <- ~w(15 30 60 120) do {"Q65#{String.upcase(letter)}-#{period}", "q65#{letter}_#{period}"} end end @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "beacons" do field :frequency_mhz, :float field :callsign, :string field :grid, :string field :lat, :float field :lon, :float field :power_mw, :float field :height_ft, :integer field :on_the_air, :boolean, default: true field :approved, :boolean, default: false field :keying, :string, default: "on_off" field :bearing, :string, default: "omni" field :beamwidth_deg, :float field :notes, :string belongs_to :user, Microwaveprop.Accounts.User timestamps(type: :utc_datetime) end @type t :: %__MODULE__{} @required_fields [:frequency_mhz, :callsign, :lat, :lon, :power_mw, :height_ft, :keying] @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(beacon, attrs) do beacon |> cast(attrs, [ :frequency_mhz, :callsign, :grid, :lat, :lon, :power_mw, :height_ft, :on_the_air, :keying, :bearing, :beamwidth_deg, :notes ]) |> update_change(:callsign, fn cs -> cs && String.upcase(String.trim(cs)) end) |> update_change(:lat, &round_coord/1) |> update_change(:lon, &round_coord/1) |> update_change(:height_ft, &round_int/1) |> maybe_fill_latlon() |> maybe_fill_grid() |> normalize_bearing_change() |> validate_required(@required_fields) |> validate_inclusion(:keying, @keyings) |> validate_bearing() |> validate_number(:beamwidth_deg, greater_than: 0, less_than_or_equal_to: 360) |> validate_number(:frequency_mhz, greater_than: 0) |> validate_number(:power_mw, greater_than_or_equal_to: 0) |> validate_number(:height_ft, greater_than_or_equal_to: 0) |> validate_number(:lat, greater_than_or_equal_to: -90, less_than_or_equal_to: 90) |> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180) |> validate_length(:callsign, min: 3, max: 10) |> validate_grid_format() end defp round_coord(nil), do: nil defp round_coord(v) when is_float(v), do: Float.round(v, 6) defp round_coord(v), do: v defp round_int(nil), do: nil defp round_int(v) when is_float(v), do: round(v) defp round_int(v) when is_integer(v), do: v defp round_int(v), do: v # If grid is blank but lat/lon are valid, derive it. defp maybe_fill_grid(changeset) do grid = get_field(changeset, :grid) lat = get_field(changeset, :lat) lon = get_field(changeset, :lon) if grid in [nil, ""] and is_number(lat) and is_number(lon) do put_change(changeset, :grid, Maidenhead.from_latlon(lat, lon, 6)) else changeset end end # If lat/lon are blank but grid is a valid Maidenhead, derive them. defp maybe_fill_latlon(changeset) do grid = get_field(changeset, :grid) lat = get_field(changeset, :lat) lon = get_field(changeset, :lon) if is_binary(grid) and grid != "" and (lat in [nil, ""] or lon in [nil, ""]) do case Maidenhead.to_latlon(grid) do {:ok, {new_lat, new_lon}} -> changeset |> put_change(:lat, Float.round(new_lat * 1.0, 6)) |> put_change(:lon, Float.round(new_lon * 1.0, 6)) :error -> changeset end else changeset end end # Normalize bearing: trim, treat nil/blank/"omni" (any case) as "omni". # Anything else is left for validate_bearing to check as a number. defp normalize_bearing_change(changeset) do current = get_field(changeset, :bearing) normalized = normalize_bearing(current) if normalized == current do changeset else put_change(changeset, :bearing, normalized) end end defp normalize_bearing(nil), do: "omni" defp normalize_bearing(value) when is_binary(value) do trimmed = String.trim(value) cond do trimmed == "" -> "omni" String.downcase(trimmed) == "omni" -> "omni" true -> trimmed end end defp normalize_bearing(value), do: value defp validate_bearing(changeset) do case get_field(changeset, :bearing) do "omni" -> changeset value when is_binary(value) -> case Float.parse(value) do {n, ""} when n >= 0 and n <= 360 -> changeset _ -> add_error(changeset, :bearing, ~s(must be "omni" or a number between 0 and 360)) end _ -> changeset end end defp validate_grid_format(changeset) do case get_field(changeset, :grid) do nil -> add_error(changeset, :grid, "can't be blank") grid -> if Maidenhead.valid?(grid) do update_change(changeset, :grid, &normalize_grid/1) else add_error(changeset, :grid, "is not a valid Maidenhead grid") end end end # Field uppercase, square digits, subsquare lowercase (standard form) defp normalize_grid(nil), do: nil defp normalize_grid(grid) do grid = String.trim(grid) len = String.length(grid) if len >= 6 do grid |> String.slice(0, 4) |> String.upcase() |> Kernel.<>(String.downcase(String.slice(grid, 4, len - 4))) else String.upcase(grid) end end end