towerops/lib/towerops_web/live/coverage_live/form.ex
2026-06-14 08:27:57 -05:00

352 lines
9.3 KiB
Elixir

defmodule ToweropsWeb.CoverageLive.Form do
@moduledoc false
use ToweropsWeb, :live_view
alias Phoenix.HTML.Form, as: HTMLForm
alias Towerops.Coverages
alias Towerops.Coverages.Antenna
alias Towerops.Coverages.Coverage
alias Towerops.Devices
alias Towerops.Sites
# Defaults expressed in the user's preferred units (imperial / GHz).
@default_tx_power_dbm 18.0
@default_height_ft 100.0
@default_radius_mi 4.0
@default_frequency_ghz 5.8
@default_azimuth_deg 0.0
@default_downtilt_deg 0.0
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
sites = Sites.list_organization_sites(organization.id)
antennas = Antenna.list()
{:ok,
socket
|> assign(:organization, organization)
|> assign(:sites, sites)
|> assign(:antenna_options, antenna_options(antennas))
|> assign(:devices, [])}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :new, _params) do
coverage = %Coverage{
organization_id: socket.assigns.organization.id,
tx_power_dbm: @default_tx_power_dbm,
azimuth_deg: @default_azimuth_deg,
downtilt_deg: @default_downtilt_deg,
foliage_tuning: 0,
receiver_height_m: 3.0,
rx_threshold_dbm: -90.0,
cable_loss_db: 0.0,
sm_gain_dbi: 0.0,
height_above_rooftop_m: 0.0,
height_agl_ft: @default_height_ft,
radius_mi: @default_radius_mi,
frequency_ghz: @default_frequency_ghz
}
socket
|> assign(:page_title, t("New Coverage"))
|> assign(:coverage, coverage)
|> assign_form(Coverages.change_coverage(coverage))
end
defp apply_action(socket, :edit, %{"id" => id}) do
coverage =
socket.assigns.organization.id
|> Coverages.get_coverage!(id)
|> populate_imperial_virtuals()
socket
|> assign(:page_title, t("Edit Coverage"))
|> assign(:coverage, coverage)
|> assign(:devices, devices_for_site(socket.assigns.organization.id, coverage.site_id))
|> assign_form(Coverages.change_coverage(coverage))
end
@impl true
def handle_event("validate", %{"coverage" => attrs}, socket) do
changeset =
socket.assigns.coverage
|> Coverages.change_coverage(attrs)
|> Map.put(:action, :validate)
new_site_id = Map.get(attrs, "site_id")
socket = maybe_refresh_devices(socket, new_site_id)
{:noreply, assign_form(socket, changeset)}
end
@impl true
def handle_event("save", %{"coverage" => attrs}, socket) do
save(socket, socket.assigns.live_action, attrs)
end
@impl true
def handle_event("location_picked", %{"lat" => lat, "lon" => lon}, socket) do
attrs =
socket
|> current_form_attrs()
|> Map.put("latitude_override", to_string(lat))
|> Map.put("longitude_override", to_string(lon))
changeset =
socket.assigns.coverage
|> Coverages.change_coverage(attrs)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
@impl true
def handle_event("use_site_location", _params, socket) do
attrs =
socket
|> current_form_attrs()
|> Map.put("latitude_override", "")
|> Map.put("longitude_override", "")
changeset =
socket.assigns.coverage
|> Coverages.change_coverage(attrs)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
defp current_form_attrs(socket) do
case socket.assigns[:form] do
nil -> %{}
form -> form.params || %{}
end
end
defp save(socket, :new, attrs) do
with {:ok, coverage} <- Coverages.create_coverage(socket.assigns.organization.id, attrs),
{:ok, queued} <- Coverages.queue_compute(coverage) do
{:noreply,
socket
|> put_flash(:info, t("Coverage queued for compute"))
|> push_navigate(to: ~p"/coverage/#{queued.id}")}
else
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
{:error, _other} ->
{:noreply, put_flash(socket, :error, t("Failed to queue coverage compute"))}
end
end
defp save(socket, :edit, attrs) do
case Coverages.update_coverage(socket.assigns.coverage, attrs) do
{:ok, coverage} ->
{:noreply,
socket
|> put_flash(:info, t("Coverage updated"))
|> push_navigate(to: ~p"/coverage/#{coverage.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp assign_form(socket, changeset) do
form = to_form(changeset)
eirp = compute_eirp_from_form(form)
socket
|> assign(:form, form)
|> assign(:computed_eirp, eirp)
end
# EIRP = TX power + antenna gain. Cable loss is intentionally not part
# of the user-facing formula (kept zero by default in the schema).
defp compute_eirp_from_form(form) do
tx_power = form_number(form, :tx_power_dbm, 0.0)
gain =
case form[:antenna_slug].value do
slug when is_binary(slug) and slug != "" ->
case Antenna.get(slug) do
%Antenna{gain_dbi: g} -> g
_ -> 0.0
end
_ ->
0.0
end
tx_power + gain
end
defp form_number(form, field, default) do
case form[field].value do
nil ->
default
"" ->
default
n when is_number(n) ->
n * 1.0
s when is_binary(s) ->
case Float.parse(s) do
{n, _} -> n
:error -> default
end
end
end
defp antenna_options(antennas) do
antennas
|> Enum.group_by(& &1.manufacturer)
|> Enum.sort_by(fn {mfr, _} -> mfr || "" end)
|> Enum.map(fn {mfr, ants} ->
{mfr || t("Other"),
Enum.map(ants, fn ant ->
{"#{ant.model} (#{ant.gain_dbi} dBi)", ant.slug}
end)}
end)
end
# Reload the devices dropdown when the user picks a different site.
# Clearing site_id clears the dropdown.
defp maybe_refresh_devices(socket, site_id) do
current_site_id =
case socket.assigns[:form] do
nil -> nil
form -> form[:site_id].value
end
if to_string(site_id) == to_string(current_site_id) do
socket
else
assign(socket, :devices, devices_for_site(socket.assigns.organization.id, site_id))
end
end
defp devices_for_site(_org_id, site_id) when site_id in [nil, ""], do: []
defp devices_for_site(org_id, site_id) do
site_id
|> Devices.list_site_devices()
|> Enum.filter(&(&1.organization_id == org_id))
end
@doc """
Latitude to display on the location-picker marker:
override (if set) → selected site's lat → nil.
"""
def radio_marker_lat(form, sites) do
case form_field_value(form, :latitude_override) do
lat when is_number(lat) ->
lat
lat when is_binary(lat) and lat != "" ->
case Float.parse(lat) do
{n, _} -> n
:error -> selected_site_lat(form, sites)
end
_ ->
selected_site_lat(form, sites)
end
end
@doc "Longitude to display on the location-picker marker."
def radio_marker_lon(form, sites) do
case form_field_value(form, :longitude_override) do
lon when is_number(lon) ->
lon
lon when is_binary(lon) and lon != "" ->
case Float.parse(lon) do
{n, _} -> n
:error -> selected_site_lon(form, sites)
end
_ ->
selected_site_lon(form, sites)
end
end
@doc "True if the form's lat/lon override is set (non-empty)."
def radio_overridden?(form) do
has_override?(form_field_value(form, :latitude_override)) and
has_override?(form_field_value(form, :longitude_override))
end
defp has_override?(nil), do: false
defp has_override?(""), do: false
defp has_override?(_), do: true
defp selected_site_lat(form, sites) do
case selected_site(form, sites) do
%{latitude: lat} when is_number(lat) -> lat
_ -> nil
end
end
defp selected_site_lon(form, sites) do
case selected_site(form, sites) do
%{longitude: lon} when is_number(lon) -> lon
_ -> nil
end
end
defp selected_site(form, sites) do
case form_field_value(form, :site_id) do
id when is_binary(id) and id != "" -> Enum.find(sites, &(&1.id == id))
_ -> nil
end
end
defp form_field_value(form, field) do
v = HTMLForm.input_value(form, field)
v
end
@doc false
def device_label(device) do
case device do
%{name: name, ip_address: ip} when is_binary(name) and name != "" ->
"#{name} (#{ip})"
%{ip_address: ip} ->
"#{ip}"
_ ->
""
end
end
defp populate_imperial_virtuals(%Coverage{} = c) do
%{
c
| height_agl_ft: m_to_ft(c.height_agl_m),
height_above_rooftop_ft: m_to_ft(c.height_above_rooftop_m),
receiver_height_ft: m_to_ft(c.receiver_height_m),
tx_clearance_ft: m_to_ft(c.tx_clearance_m),
radius_mi: m_to_mi(c.radius_m),
frequency_ghz: mhz_to_ghz(c.frequency_mhz)
}
end
defp m_to_ft(nil), do: nil
defp m_to_ft(m) when is_number(m), do: Float.round(m / 0.3048, 1)
defp m_to_mi(nil), do: nil
defp m_to_mi(m) when is_number(m), do: Float.round(m / 1609.344, 2)
defp mhz_to_ghz(nil), do: nil
defp mhz_to_ghz(mhz) when is_number(mhz), do: Float.round(mhz / 1000.0, 3)
end