Height in feet doesn't need decimal precision. Migrates the DB column, updates schema type, and strips trailing .0 from form input so the integer cast succeeds.
304 lines
8.1 KiB
Elixir
304 lines
8.1 KiB
Elixir
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)
|
|
|
|
def keyings, do: @keyings
|
|
|
|
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.
|
|
"""
|
|
def format_mw(nil), do: ""
|
|
|
|
def format_mw(mw) when is_float(mw) do
|
|
if mw == trunc(mw) do
|
|
Integer.to_string(trunc(mw))
|
|
else
|
|
mw |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros()
|
|
end
|
|
end
|
|
|
|
def format_mw(mw), do: to_string(mw)
|
|
|
|
@doc "Formats a frequency in MHz with comma separators (e.g. 10368 → \"10,368\")."
|
|
def format_freq(nil), do: ""
|
|
|
|
def format_freq(mhz) when is_number(mhz) do
|
|
int_part = trunc(mhz)
|
|
frac = mhz - int_part
|
|
|
|
formatted_int =
|
|
int_part
|
|
|> Integer.to_string()
|
|
|> String.reverse()
|
|
|> String.replace(~r/.{3}/, "\\0,")
|
|
|> String.trim_trailing(",")
|
|
|> String.reverse()
|
|
|
|
if frac == 0.0 do
|
|
formatted_int
|
|
else
|
|
decimal = mhz |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros()
|
|
[_int, frac_part] = String.split(decimal, ".")
|
|
"#{formatted_int}.#{frac_part}"
|
|
end
|
|
end
|
|
|
|
def format_freq(mhz), do: to_string(mhz)
|
|
|
|
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.
|
|
"""
|
|
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
|
|
field :user_id, :binary_id
|
|
|
|
timestamps(type: :utc_datetime)
|
|
end
|
|
|
|
@required_fields [:frequency_mhz, :callsign, :lat, :lon, :power_mw, :height_ft, :keying]
|
|
|
|
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, new_lat)
|
|
|> put_change(:lon, new_lon)
|
|
|
|
: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
|