prop/lib/microwaveprop/beacons/beacon.ex
Graham McIntire b4b8d4ec47
Some checks failed
Build base image / Build and push base image (push) Successful in 12s
Build and Push / Build CI test image (push) Successful in 14s
Build and Push / Build and Push Docker Image (push) Failing after 14m7s
simplify: DRY up shared changesets, context helpers, LiveView helpers, and structural extraction
- Create MaidenheadChangesetHelpers: consolidate grid validation, callsign
  normalization, lat/lon validation, grid/latlon derivation across 6 schemas
- Create ContextHelpers: shared fetch_owned with admin bypass, safe_enqueue
  for Oban workers, UUID casting to replace CastError rescues
- Extend LiveHelpers: add current_user/1 (removes 7 duplicate definitions),
  subscribe/2 (replaces 13 inline PubSub sites), assign_url_params/2
- Extract Propagation.ScoreStore (528 lines): separate file I/O and cache
  management from scoring logic, 13 defdelegate passthroughs
- Split SubmitLive (942->475 lines): extract CSV/ADIF upload rendering into
  3 function component modules (csv_upload, adif_upload, preview)
- Update 16 LiveViews to use shared helpers
2026-08-06 18:06:50 -05:00

257 lines
7.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.MaidenheadChangesetHelpers, as: MCH
@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
])
|> MCH.normalize_callsign(:callsign)
|> update_change(:lat, &round_coord/1)
|> update_change(:lon, &round_coord/1)
|> update_change(:height_ft, &round_int/1)
|> MCH.derive_latlon(:grid, :lat, :lon)
|> MCH.derive_grid(:grid, :lat, :lon)
|> 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)
|> MCH.validate_latlon(:lat, :lon)
|> validate_length(:callsign, min: 3, max: 10)
|> MCH.validate_grid(:grid)
|> foreign_key_constraint(:user_id)
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
# 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
end