diff --git a/lib/microwaveprop/accounts/user.ex b/lib/microwaveprop/accounts/user.ex index bbe06ce7..6404c57e 100644 --- a/lib/microwaveprop/accounts/user.ex +++ b/lib/microwaveprop/accounts/user.ex @@ -4,6 +4,8 @@ defmodule Microwaveprop.Accounts.User do import Ecto.Changeset + alias Microwaveprop.Radio.Maidenhead + @admin_email "graham@mcintire.me" @primary_key {:id, :binary_id, autogenerate: true} @@ -18,6 +20,11 @@ defmodule Microwaveprop.Accounts.User do field :is_admin, :boolean, default: false field :authenticated_at, :utc_datetime, virtual: true + field :home_grid, :string + field :home_lat, :float + field :home_lon, :float + field :home_elevation_m, :integer + timestamps(type: :utc_datetime) end @@ -208,6 +215,114 @@ defmodule Microwaveprop.Accounts.User do change(user, confirmed_at: now) end + @doc """ + Changeset for the rover-page "home QTH" fields. + + Accepts either a Maidenhead grid or an explicit lat/lon. When only the + grid is given, lat/lon are derived from its centre; when only lat/lon + are given, the grid is left untouched. An empty `home_grid` paired + with explicit nil lat/lon clears all home fields (used by the "clear + home" affordance on /rover). + """ + @spec change_home_qth(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() + def change_home_qth(user, attrs) do + user + |> cast(attrs, [:home_grid, :home_lat, :home_lon, :home_elevation_m]) + |> normalize_home_grid() + |> validate_home_grid_format() + |> derive_home_latlon_from_grid() + |> validate_home_latlon_range() + |> require_home_qth_pair() + end + + defp normalize_home_grid(changeset) do + case get_change(changeset, :home_grid) do + nil -> + changeset + + "" -> + put_change(changeset, :home_grid, nil) + + grid when is_binary(grid) -> + put_change(changeset, :home_grid, normalize_grid(grid)) + end + end + + defp normalize_grid(grid) do + trimmed = String.trim(grid) + + case String.length(trimmed) do + 4 -> String.upcase(trimmed) + 6 -> String.upcase(String.slice(trimmed, 0, 4)) <> String.downcase(String.slice(trimmed, 4, 2)) + _ -> trimmed + end + end + + defp validate_home_grid_format(changeset) do + case get_field(changeset, :home_grid) do + nil -> + changeset + + grid -> + if Maidenhead.valid?(grid), + do: changeset, + else: add_error(changeset, :home_grid, "must be a 4 or 6 character Maidenhead locator") + end + end + + defp derive_home_latlon_from_grid(changeset) do + cond do + not changeset.valid? -> + changeset + + get_change(changeset, :home_lat) || get_change(changeset, :home_lon) -> + changeset + + grid = get_change(changeset, :home_grid) -> + case Maidenhead.to_latlon(grid) do + {:ok, {lat, lon}} -> + changeset + |> put_change(:home_lat, lat) + |> put_change(:home_lon, lon) + + :error -> + changeset + end + + true -> + changeset + end + end + + defp validate_home_latlon_range(changeset) do + changeset + |> validate_number(:home_lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0) + |> validate_number(:home_lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0) + end + + defp require_home_qth_pair(changeset) do + if home_qth_satisfied?(changeset), + do: changeset, + else: add_error(changeset, :home_grid, "must provide a grid or lat/lon") + end + + defp home_qth_satisfied?(changeset) do + grid = get_field(changeset, :home_grid) + lat = get_field(changeset, :home_lat) + lon = get_field(changeset, :home_lon) + + cond do + is_binary(grid) and grid != "" -> true + is_number(lat) and is_number(lon) -> true + is_nil(grid) and is_nil(lat) and is_nil(lon) -> any_home_change?(changeset) + true -> false + end + end + + defp any_home_change?(changeset) do + Enum.any?([:home_grid, :home_lat, :home_lon], &Map.has_key?(changeset.changes, &1)) + end + @doc """ Verifies the password. diff --git a/priv/repo/migrations/20260425210051_add_home_qth_to_users.exs b/priv/repo/migrations/20260425210051_add_home_qth_to_users.exs new file mode 100644 index 00000000..33ac0691 --- /dev/null +++ b/priv/repo/migrations/20260425210051_add_home_qth_to_users.exs @@ -0,0 +1,12 @@ +defmodule Microwaveprop.Repo.Migrations.AddHomeQthToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :home_grid, :string + add :home_lat, :float + add :home_lon, :float + add :home_elevation_m, :integer + end + end +end diff --git a/scripts/__pycache__/recalibrate_algo.cpython-313.pyc b/scripts/__pycache__/recalibrate_algo.cpython-313.pyc new file mode 100644 index 00000000..b50e4b6d Binary files /dev/null and b/scripts/__pycache__/recalibrate_algo.cpython-313.pyc differ diff --git a/test/microwaveprop/accounts/user_test.exs b/test/microwaveprop/accounts/user_test.exs index 7e60427a..90a0d83a 100644 --- a/test/microwaveprop/accounts/user_test.exs +++ b/test/microwaveprop/accounts/user_test.exs @@ -442,4 +442,59 @@ defmodule Microwaveprop.Accounts.UserTest do refute User.valid_password?(nil, "anything") end end + + describe "change_home_qth/2" do + test "derives lat/lon from a 6-char grid" do + changeset = User.change_home_qth(%User{}, %{"home_grid" => "EM13qc"}) + assert changeset.valid? + lat = Ecto.Changeset.get_change(changeset, :home_lat) + lon = Ecto.Changeset.get_change(changeset, :home_lon) + assert_in_delta lat, 33.10, 0.05 + assert_in_delta lon, -96.625, 0.05 + assert Ecto.Changeset.get_change(changeset, :home_grid) == "EM13qc" + end + + test "accepts explicit lat/lon without grid" do + changeset = User.change_home_qth(%User{}, %{"home_lat" => 33.0, "home_lon" => -96.7}) + assert changeset.valid? + assert Ecto.Changeset.get_change(changeset, :home_lat) == 33.0 + assert Ecto.Changeset.get_change(changeset, :home_lon) == -96.7 + end + + test "rejects invalid grids" do + changeset = User.change_home_qth(%User{}, %{"home_grid" => "ZZ99zz"}) + refute changeset.valid? + assert errors_on(changeset)[:home_grid] + end + + test "requires either grid or lat/lon" do + changeset = User.change_home_qth(%User{}, %{}) + refute changeset.valid? + assert errors_on(changeset)[:home_grid] + end + + test "rejects out-of-range lat/lon" do + changeset = User.change_home_qth(%User{}, %{"home_lat" => 100.0, "home_lon" => 0.0}) + refute changeset.valid? + assert errors_on(changeset)[:home_lat] + end + + test "uppercase Field + lowercase subsquare normalisation" do + changeset = User.change_home_qth(%User{}, %{"home_grid" => "em13QC"}) + assert changeset.valid? + assert Ecto.Changeset.get_change(changeset, :home_grid) == "EM13qc" + end + + test "clears all home fields when home_grid is an empty string" do + user = %User{home_grid: "EM13qc", home_lat: 33.1, home_lon: -96.8, home_elevation_m: 200} + + changeset = + User.change_home_qth(user, %{"home_grid" => "", "home_lat" => nil, "home_lon" => nil}) + + assert changeset.valid? + assert Ecto.Changeset.get_change(changeset, :home_grid) == nil + assert Ecto.Changeset.get_change(changeset, :home_lat) == nil + assert Ecto.Changeset.get_change(changeset, :home_lon) == nil + end + end end