Formats frequencies like 10368.1 as "10,368.1" on both the beacon list table and detail page (subtitle, map label, stat field).
292 lines
7.7 KiB
Elixir
292 lines
7.7 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, :float
|
|
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)
|
|
|> 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
|
|
|
|
# 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
|