prop/lib/microwaveprop/beacons/beacon.ex
Graham McIntire 8425edbdc4 Add comma separators to MHz frequencies on beacon pages
Formats frequencies like 10368.1 as "10,368.1" on both the
beacon list table and detail page (subtitle, map label, stat field).
2026-04-11 09:37:26 -05:00

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