prop/lib/microwaveprop/radio/maidenhead.ex
Graham McIntire ee9275e0b9 Add /beacons CRUD and /users admin page
Beacons:
- Scaffolded with phx.gen.live then reworked so reads are public
  and mutations go through a live_session gated by the new
  :require_admin on_mount hook in UserAuth
- Beacon schema stores frequency (MHz), callsign, grid, lat/lon,
  power (W), and height above ground (m); grid auto-derives from
  lat/lon when left blank via Maidenhead.from_latlon
- Adds Maidenhead.from_latlon/3 so we can compute grids locally
  instead of hitting an external API

Users admin page:
- /users and /users/:id/edit (admin-only) for listing, editing
  (callsign/name/email/is_admin), and deleting other users
- Adds Accounts.list_users, admin_update_user, delete_user, and
  a dedicated admin_changeset on the User schema
- Nav gains a "Users" link for admins and a "Beacons" link for
  everyone
2026-04-08 12:01:45 -05:00

116 lines
3.5 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.Radio.Maidenhead do
@moduledoc false
# Maidenhead grid: Field (2 letters A-R) + Square (2 digits) + optional
# repeating pairs of Subsquare (2 letters) and Extended Square (2 digits).
# Minimum 4 chars, always even length. E.g. EM12, EM12kp, EM12kp37, EM12kp37ab
@spec valid?(any()) :: boolean()
def valid?(nil), do: false
def valid?(grid) when is_binary(grid) do
len = byte_size(grid)
len >= 4 and rem(len, 2) == 0 and String.match?(grid, grid_regex())
end
def valid?(_), do: false
# Field: [A-Ra-r]{2}, Square: [0-9]{2}, then alternating letter/digit pairs
defp grid_regex do
~r/^[A-Ra-r]{2}[0-9]{2}(?:[A-Xa-x]{2}(?:[0-9]{2}(?:[A-Xa-x]{2}(?:[0-9]{2})*)*)*)*$/
end
@spec to_latlon(any()) :: {:ok, {float(), float()}} | :error
def to_latlon(nil), do: :error
def to_latlon(grid) when is_binary(grid) do
grid = String.upcase(grid)
if valid?(grid) do
<<f1, f2, s1, s2>> <> rest = grid
# Field: 18 divisions (A-R), 20° lon / 10° lat each
lon = (f1 - ?A) * 20.0 - 180.0
lat = (f2 - ?A) * 10.0 - 90.0
# Square: 10 divisions (0-9), 2° lon / 1° lat each
lon = lon + (s1 - ?0) * 2.0
lat = lat + (s2 - ?0) * 1.0
chars = String.to_charlist(rest)
{lat, lon, lat_size, lon_size} = decode_pairs(chars, lat, lon, 1.0, 2.0, :letter)
{:ok, {lat + lat_size / 2, lon + lon_size / 2}}
else
:error
end
end
def to_latlon(_), do: :error
@doc """
Converts a lat/lon pair into a Maidenhead grid at the requested
precision (must be an even number between 4 and 8). Default precision
is 6 (subsquare level, ~5'×2.5', ~7 km × 4.6 km at the equator).
"""
@spec from_latlon(number(), number(), pos_integer()) :: String.t()
def from_latlon(lat, lon, precision \\ 6) when is_number(lat) and is_number(lon) and precision in [4, 6, 8] do
lat = lat + 90.0
lon = lon + 180.0
# Field (A-R), 20° lon / 10° lat
f1 = trunc(lon / 20.0)
f2 = trunc(lat / 10.0)
lon = lon - f1 * 20.0
lat = lat - f2 * 10.0
# Square (0-9), 2° lon / 1° lat
s1 = trunc(lon / 2.0)
s2 = trunc(lat / 1.0)
lon = lon - s1 * 2.0
lat = lat - s2 * 1.0
grid = <<?A + f1, ?A + f2, ?0 + s1, ?0 + s2>>
cond do
precision == 4 ->
grid
precision >= 6 ->
# Subsquare (a-x), 5'/60 lon, 2.5'/60 lat
ss1 = trunc(lon * 60.0 / 5.0)
ss2 = trunc(lat * 60.0 / 2.5)
lon = lon - ss1 * (5.0 / 60.0)
lat = lat - ss2 * (2.5 / 60.0)
grid = grid <> <<?a + ss1, ?a + ss2>>
if precision == 8 do
# Extended square (0-9), (5/60)/10 lon, (2.5/60)/10 lat
e1 = trunc(lon * 60.0 / 0.5)
e2 = trunc(lat * 60.0 / 0.25)
grid <> <<?0 + e1, ?0 + e2>>
else
grid
end
end
end
# Decode remaining alternating letter(24)/digit(10) pairs after field+square
defp decode_pairs([c1, c2 | rest], lat, lon, lat_size, lon_size, :letter) do
cell_lon = lon_size / 24
cell_lat = lat_size / 24
lon = lon + (c1 - ?A) * cell_lon
lat = lat + (c2 - ?A) * cell_lat
decode_pairs(rest, lat, lon, cell_lat, cell_lon, :digit)
end
defp decode_pairs([c1, c2 | rest], lat, lon, lat_size, lon_size, :digit) do
cell_lon = lon_size / 10
cell_lat = lat_size / 10
lon = lon + (c1 - ?0) * cell_lon
lat = lat + (c2 - ?0) * cell_lat
decode_pairs(rest, lat, lon, cell_lat, cell_lon, :letter)
end
defp decode_pairs([], lat, lon, lat_size, lon_size, _type) do
{lat, lon, lat_size, lon_size}
end
end