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.
237 lines
6.3 KiB
Elixir
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
|