Add beacon bearing, beamwidth, and weak-signal keyings

Beacons can now record a directional bearing ("omni" or 0-360 degrees)
and a beamwidth in degrees. The keying list grows from {on_off, fsk}
to include FM Voice, WSPR, and the full Q65A/B/C/D/E × 15/30/60/120
family, grouped via optgroups in the submission form.
This commit is contained in:
Graham McIntire 2026-04-09 08:59:03 -05:00
parent 8b4b5aea26
commit fbae931d7e
6 changed files with 256 additions and 12 deletions

View file

@ -11,10 +11,70 @@ defmodule Microwaveprop.Beacons.Beacon do
alias Microwaveprop.Radio.Maidenhead
@keyings ~w(on_off fsk)
@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 """
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
@ -28,6 +88,8 @@ defmodule Microwaveprop.Beacons.Beacon do
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
@ -48,13 +110,18 @@ defmodule Microwaveprop.Beacons.Beacon do
: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)
@ -98,6 +165,52 @@ defmodule Microwaveprop.Beacons.Beacon do
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 ->

View file

@ -43,11 +43,24 @@ defmodule MicrowavepropWeb.BeaconLive.Form do
step="any"
required
/>
<.input
field={@form[:bearing]}
type="text"
label="Bearing"
placeholder="omni or 0-360 degrees"
/>
<.input
field={@form[:beamwidth_deg]}
type="number"
label="Beamwidth (degrees)"
step="any"
placeholder="Leave blank for omni"
/>
<.input
field={@form[:keying]}
type="select"
label="Keying"
options={[{"On/Off", "on_off"}, {"FSK", "fsk"}]}
options={Beacon.keying_options()}
required
/>
<.input field={@form[:on_the_air]} type="checkbox" label="On the air" />

View file

@ -3,6 +3,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
use MicrowavepropWeb, :live_view
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
@impl true
def render(assigns) do
@ -30,7 +31,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
<:col :let={{_id, beacon}} label="Lon">{beacon.lon}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{format_mw(beacon.power_mw)}</:col>
<:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col>
<:col :let={{_id, beacon}} label="Keying">{keying_label(beacon.keying)}</:col>
<:col :let={{_id, beacon}} label="Keying">{Beacon.keying_label(beacon.keying)}</:col>
<:col :let={{_id, beacon}} label="On air">
<span class={["badge badge-sm", (beacon.on_the_air && "badge-success") || "badge-ghost"]}>
{if beacon.on_the_air, do: "Yes", else: "No"}
@ -144,7 +145,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
end
@impl true
def handle_info({type, %Microwaveprop.Beacons.Beacon{}}, socket) when type in [:created, :updated, :deleted] do
def handle_info({type, %Beacon{}}, socket) when type in [:created, :updated, :deleted] do
pending =
if admin?(socket.assigns.current_scope) do
Beacons.list_pending_beacons()
@ -162,10 +163,6 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
defp admin?(%{user: %{is_admin: true}}), do: true
defp admin?(_), do: false
defp keying_label("on_off"), do: "On/Off"
defp keying_label("fsk"), do: "FSK"
defp keying_label(other), do: other
# Format a milliwatt power value without scientific notation.
# Keeps up to 3 decimal places, drops trailing zeros.
defp format_mw(nil), do: ""

View file

@ -94,7 +94,11 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
<:item title="Longitude">{@beacon.lon}</:item>
<:item title="TX power (EIRP) (mW)">{@beacon.power_mw}</:item>
<:item title="Height above ground (ft)">{@beacon.height_ft}</:item>
<:item title="Keying">{keying_label(@beacon.keying)}</:item>
<:item title="Bearing">{bearing_label(@beacon.bearing)}</:item>
<:item :if={@beacon.beamwidth_deg} title="Beamwidth">
{@beacon.beamwidth_deg}°
</:item>
<:item title="Keying">{Beacon.keying_label(@beacon.keying)}</:item>
<:item title="On the air">{if @beacon.on_the_air, do: "Yes", else: "No"}</:item>
<:item :if={@beacon.notes && @beacon.notes != ""} title="Notes">
<div class="whitespace-pre-wrap">{@beacon.notes}</div>
@ -153,7 +157,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
defp admin?(%{user: %{is_admin: true}}), do: true
defp admin?(_), do: false
defp keying_label("on_off"), do: "On/Off"
defp keying_label("fsk"), do: "FSK"
defp keying_label(other), do: other
defp bearing_label(nil), do: "Omni"
defp bearing_label("omni"), do: "Omni"
defp bearing_label(value), do: "#{value}°"
end

View file

@ -0,0 +1,10 @@
defmodule Microwaveprop.Repo.Migrations.AddBearingAndBeamwidthToBeacons do
use Ecto.Migration
def change do
alter table(:beacons) do
add :bearing, :string, null: false, default: "omni"
add :beamwidth_deg, :float
end
end
end

View file

@ -163,6 +163,113 @@ defmodule Microwaveprop.BeaconsTest do
assert "is invalid" in errors_on(changeset).keying
end
test "accepts fm_voice keying" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(keying: "fm_voice"))
assert beacon.keying == "fm_voice"
end
test "accepts wspr keying" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(keying: "wspr"))
assert beacon.keying == "wspr"
end
test "accepts all Q65 keying variants" do
user = user_fixture()
variants =
for letter <- ~w(a b c d e), period <- ~w(15 30 60 120), do: "q65#{letter}_#{period}"
for keying <- variants do
assert {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(keying: keying))
assert beacon.keying == keying
end
end
test "defaults bearing to omni" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
assert beacon.bearing == "omni"
end
test "normalizes empty bearing to omni" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: ""))
assert beacon.bearing == "omni"
end
test "normalizes 'Omni' to 'omni'" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: "Omni"))
assert beacon.bearing == "omni"
end
test "accepts a numeric bearing" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: "90"))
assert beacon.bearing == "90"
end
test "accepts bearing of 0 and 360" do
user = user_fixture()
{:ok, b0} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: "0"))
assert b0.bearing == "0"
{:ok, b360} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: "360"))
assert b360.bearing == "360"
end
test "rejects a non-numeric non-omni bearing" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(bearing: "north"))
assert errors_on(changeset).bearing != []
end
test "rejects a bearing greater than 360" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(bearing: "400"))
assert errors_on(changeset).bearing != []
end
test "rejects a negative bearing" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(bearing: "-10"))
assert errors_on(changeset).bearing != []
end
test "stores beamwidth_deg" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(beamwidth_deg: 30.0))
assert beacon.beamwidth_deg == 30.0
end
test "rejects a non-positive beamwidth" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(beamwidth_deg: 0))
assert errors_on(changeset).beamwidth_deg != []
end
test "rejects beamwidth > 360" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(beamwidth_deg: 400))
assert errors_on(changeset).beamwidth_deg != []
end
test "stores notes" do
user = user_fixture()
notes = "Antenna 20 ft AGL, horizontal polarization"