diff --git a/lib/microwaveprop/beacons/beacon.ex b/lib/microwaveprop/beacons/beacon.ex index 17046c28..1c271a47 100644 --- a/lib/microwaveprop/beacons/beacon.ex +++ b/lib/microwaveprop/beacons/beacon.ex @@ -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 -> diff --git a/lib/microwaveprop_web/live/beacon_live/form.ex b/lib/microwaveprop_web/live/beacon_live/form.ex index 033ed018..9dbadd6f 100644 --- a/lib/microwaveprop_web/live/beacon_live/form.ex +++ b/lib/microwaveprop_web/live/beacon_live/form.ex @@ -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" /> diff --git a/lib/microwaveprop_web/live/beacon_live/index.ex b/lib/microwaveprop_web/live/beacon_live/index.ex index 83315ae3..720cd131 100644 --- a/lib/microwaveprop_web/live/beacon_live/index.ex +++ b/lib/microwaveprop_web/live/beacon_live/index.ex @@ -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 :let={{_id, beacon}} label="EIRP (mW)">{format_mw(beacon.power_mw)} <:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft} - <:col :let={{_id, beacon}} label="Keying">{keying_label(beacon.keying)} + <:col :let={{_id, beacon}} label="Keying">{Beacon.keying_label(beacon.keying)} <:col :let={{_id, beacon}} label="On air"> {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: "" diff --git a/lib/microwaveprop_web/live/beacon_live/show.ex b/lib/microwaveprop_web/live/beacon_live/show.ex index a5078511..479e9c90 100644 --- a/lib/microwaveprop_web/live/beacon_live/show.ex +++ b/lib/microwaveprop_web/live/beacon_live/show.ex @@ -94,7 +94,11 @@ defmodule MicrowavepropWeb.BeaconLive.Show do <:item title="Longitude">{@beacon.lon} <:item title="TX power (EIRP) (mW)">{@beacon.power_mw} <:item title="Height above ground (ft)">{@beacon.height_ft} - <:item title="Keying">{keying_label(@beacon.keying)} + <:item title="Bearing">{bearing_label(@beacon.bearing)} + <:item :if={@beacon.beamwidth_deg} title="Beamwidth"> + {@beacon.beamwidth_deg}° + + <:item title="Keying">{Beacon.keying_label(@beacon.keying)} <:item title="On the air">{if @beacon.on_the_air, do: "Yes", else: "No"} <:item :if={@beacon.notes && @beacon.notes != ""} title="Notes">
{@beacon.notes}
@@ -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 diff --git a/priv/repo/migrations/20260409135526_add_bearing_and_beamwidth_to_beacons.exs b/priv/repo/migrations/20260409135526_add_bearing_and_beamwidth_to_beacons.exs new file mode 100644 index 00000000..ae0a0039 --- /dev/null +++ b/priv/repo/migrations/20260409135526_add_bearing_and_beamwidth_to_beacons.exs @@ -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 diff --git a/test/microwaveprop/beacons_test.exs b/test/microwaveprop/beacons_test.exs index 8de6c4f6..1f6cb278 100644 --- a/test/microwaveprop/beacons_test.exs +++ b/test/microwaveprop/beacons_test.exs @@ -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"