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:
parent
8b4b5aea26
commit
fbae931d7e
6 changed files with 256 additions and 12 deletions
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue