towerops/lib/towerops_web/live/coverage_live/form.ex
Graham McIntire 84d4fccb29 feat: optionally attach a coverage to a device at the chosen site
After picking a site in the coverage form, a new optional dropdown
lets the user attach the coverage to one of the devices at that site.
The dropdown reloads on every site change. If the site has no devices
yet, a hint replaces it.

Schema: nullable device_id FK on coverages (on_delete: nilify_all so
deleting a device leaves the coverage standing). Filtered by both
site_id and organization_id when loading the dropdown options.

Show page lists the attached device by name (or IP) when set.
2026-05-06 14:49:32 -05:00

237 lines
6.3 KiB
Elixir

defmodule ToweropsWeb.CoverageLive.Form do
@moduledoc false
use ToweropsWeb, :live_view
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(:antennas, antennas)
|> 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
defp save(socket, :new, attrs) do
case Coverages.create_coverage(socket.assigns.organization.id, attrs) do
{:ok, coverage} ->
{:noreply,
socket
|> put_flash(:info, t("Coverage created"))
|> push_navigate(to: ~p"/coverage/#{coverage.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
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 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