402 lines
14 KiB
Elixir
402 lines
14 KiB
Elixir
defmodule MicrowavepropWeb.BeaconLive.Show do
|
|
@moduledoc "Single-beacon detail page with reception-log history at `/beacons/:id`."
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.BeaconMeasurements
|
|
alias Microwaveprop.Beacons
|
|
alias Microwaveprop.Beacons.Beacon
|
|
alias Microwaveprop.Beacons.RangeEstimate
|
|
alias Microwaveprop.Radio.BandResolver
|
|
alias Microwaveprop.Radio.Maidenhead
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
|
<.header>
|
|
{@beacon.callsign}
|
|
<:subtitle>
|
|
{Beacon.format_freq(@beacon.frequency_mhz)} MHz · {@beacon.grid}
|
|
<span :if={not @beacon.approved} class="badge badge-warning badge-sm ml-2">
|
|
Pending approval
|
|
</span>
|
|
</:subtitle>
|
|
<:actions>
|
|
<.button
|
|
:if={admin?(@current_scope) and not @beacon.approved}
|
|
variant="primary"
|
|
phx-click="approve"
|
|
>
|
|
<.icon name="hero-check" /> Approve
|
|
</.button>
|
|
<.button navigate={~p"/beacons"}>
|
|
<.icon name="hero-arrow-left" />
|
|
</.button>
|
|
<.button navigate={path_calc_link(@beacon)}>
|
|
<.icon name="hero-map" /> Plot path to beacon
|
|
</.button>
|
|
<.button
|
|
:if={admin?(@current_scope)}
|
|
variant="primary"
|
|
navigate={~p"/beacons/#{@beacon}/edit?return_to=show"}
|
|
>
|
|
<.icon name="hero-pencil-square" /> Edit
|
|
</.button>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<div
|
|
id={"beacon-map-" <> @beacon.id}
|
|
phx-hook="BeaconMap"
|
|
phx-update="ignore"
|
|
class="h-96 w-full rounded-lg border border-base-300 mb-2"
|
|
data-lat={@beacon.lat}
|
|
data-lon={@beacon.lon}
|
|
data-label={"#{@beacon.callsign} · #{Beacon.format_freq(@beacon.frequency_mhz)} MHz"}
|
|
data-on-the-air={to_string(@beacon.on_the_air)}
|
|
>
|
|
</div>
|
|
|
|
<div class="mb-2 flex items-center justify-between flex-wrap gap-2">
|
|
<label class={[
|
|
"label gap-2 py-0",
|
|
if(@coverage_supported, do: "cursor-pointer", else: "cursor-not-allowed opacity-60")
|
|
]}>
|
|
<input
|
|
type="checkbox"
|
|
class="toggle toggle-sm toggle-primary"
|
|
checked={@show_coverage}
|
|
disabled={not @coverage_supported}
|
|
phx-click={if @coverage_supported, do: "toggle_coverage"}
|
|
/>
|
|
<span class="label-text text-sm">Show estimated current coverage</span>
|
|
<span :if={not @coverage_supported} class="label-text-alt text-xs opacity-70">
|
|
(5.76 GHz and up only — atmospheric loss dominates on microwave bands)
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div
|
|
:if={@show_coverage && @estimate}
|
|
class="text-xs opacity-70 mb-2 flex flex-wrap items-center gap-3"
|
|
>
|
|
<span>
|
|
Band <strong>{@estimate.band_label}</strong> ·
|
|
EIRP <strong>{@estimate.eirp_dbm} dBm</strong> ·
|
|
Atm loss <strong>{@estimate.atm_per_km} dB/km</strong>
|
|
</span>
|
|
<span>
|
|
Beacon grid score <strong>{@estimate.center_score}</strong>
|
|
<%= if @estimate.valid_time do %>
|
|
at {Calendar.strftime(@estimate.valid_time, "%Y-%m-%d %H:%M UTC")}
|
|
<% else %>
|
|
(no HRRR data — defaulting cells to 50)
|
|
<% end %>
|
|
· <strong>{length(@estimate.cells)}</strong>
|
|
cells rendered
|
|
</span>
|
|
</div>
|
|
|
|
<div :if={@show_coverage && @estimate} class="flex flex-wrap gap-3 text-xs mb-4">
|
|
<%= for tier <- @estimate.tiers do %>
|
|
<div class="flex items-center gap-1.5">
|
|
<span
|
|
class="inline-block w-3 h-3 rounded-sm border"
|
|
style={"background-color: #{tier.color}; border-color: #{tier.color}"}
|
|
></span>
|
|
<span>
|
|
<strong>{tier.label}</strong> (≥ {tier.min_dbm} dBm)
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<div class="card card-border bg-base-200/40">
|
|
<div class="card-body p-3 text-sm">
|
|
<dl class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-x-4 gap-y-2">
|
|
<.stat_field label="Frequency">
|
|
{Beacon.format_freq(@beacon.frequency_mhz)} MHz
|
|
</.stat_field>
|
|
<.stat_field label="Callsign">{@beacon.callsign}</.stat_field>
|
|
<.stat_field label="Keying">{Beacon.keying_label(@beacon.keying)}</.stat_field>
|
|
<.stat_field label="On the air">
|
|
<span class={[
|
|
"badge badge-sm",
|
|
(@beacon.on_the_air && "badge-success") || "badge-ghost"
|
|
]}>
|
|
{if @beacon.on_the_air, do: "Yes", else: "No"}
|
|
</span>
|
|
</.stat_field>
|
|
|
|
<.stat_field label="Grid">{@beacon.grid}</.stat_field>
|
|
<.stat_field label="Latitude">{format_coord(@beacon.lat)}</.stat_field>
|
|
<.stat_field label="Longitude">{format_coord(@beacon.lon)}</.stat_field>
|
|
<.stat_field label="EIRP">{Beacon.format_mw(@beacon.power_mw)} mW</.stat_field>
|
|
|
|
<.stat_field label="Height AGL">{@beacon.height_ft} ft</.stat_field>
|
|
<.stat_field label="Bearing">{bearing_label(@beacon.bearing)}</.stat_field>
|
|
<.stat_field :if={@beacon.beamwidth_deg} label="Beamwidth">
|
|
{Beacon.format_mw(@beacon.beamwidth_deg)}°
|
|
</.stat_field>
|
|
</dl>
|
|
|
|
<div :if={@beacon.notes && @beacon.notes != ""} class="mt-3 pt-3 border-t border-base-300">
|
|
<div class="font-semibold text-[11px] uppercase tracking-wider opacity-60 mb-1">
|
|
Notes
|
|
</div>
|
|
<div class="whitespace-pre-wrap text-sm">{@beacon.notes}</div>
|
|
</div>
|
|
|
|
<div :if={@beacon.user && @beacon.user.callsign} class="mt-3 pt-3 border-t border-base-300">
|
|
<span class="text-xs opacity-60">Submitted by</span>
|
|
<span class="text-sm font-semibold">{@beacon.user.callsign}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card bg-base-200/40 mt-6">
|
|
<div class="card-body">
|
|
<h2 class="card-title text-sm mb-2">Recent reception reports</h2>
|
|
<%= if @measurements == [] do %>
|
|
<p class="text-sm opacity-70">No reception reports yet.</p>
|
|
<% else %>
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Monitor</th>
|
|
<th>Frequency</th>
|
|
<th>SNR avg</th>
|
|
<th>SNR peak</th>
|
|
<th>Noise floor</th>
|
|
<th>Gain</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={m <- @measurements}>
|
|
<td class="whitespace-nowrap text-xs">
|
|
{Calendar.strftime(m.measured_at, "%Y-%m-%d %H:%M UTC")}
|
|
</td>
|
|
<td class="text-xs">{m.monitor.name}</td>
|
|
<td class="text-xs">{format_freq(m.frequency_hz)}</td>
|
|
<td class="text-xs">{Float.round(m.snr_avg_db, 1)} dB</td>
|
|
<td class="text-xs">{Float.round(m.snr_peak_db, 1)} dB</td>
|
|
<td class="text-xs">{Float.round(m.noise_floor_dbfs, 1)} dBFS</td>
|
|
<td class="text-xs">{Float.round(m.gain_db, 1)} dB</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
@impl true
|
|
def mount(%{"id" => id}, _session, socket) do
|
|
viewer = socket.assigns[:current_scope] && socket.assigns.current_scope.user
|
|
|
|
case Beacons.get_visible_beacon(id, viewer) do
|
|
nil ->
|
|
# Pending beacons are invisible to everyone except the
|
|
# submitter and admins. Render the same 404 we'd give for an
|
|
# unknown id so existence isn't observable.
|
|
raise Ecto.NoResultsError, queryable: Beacon
|
|
|
|
beacon ->
|
|
_ = if connected?(socket), do: Beacons.subscribe_beacons()
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, "Beacon")
|
|
|> assign(:beacon, beacon)
|
|
|> assign(:show_coverage, false)
|
|
|> assign(:coverage_supported, coverage_supported?(beacon))
|
|
# Estimate is lazy — computed on the first toggle-on to avoid
|
|
# paying the bbox grid cost on every page load.
|
|
|> assign(:estimate, nil)
|
|
|> assign(:measurements, BeaconMeasurements.list_recent_for_beacon(beacon.id, 10))}
|
|
end
|
|
end
|
|
|
|
# Estimated current coverage only makes sense from 5.76 GHz upward,
|
|
# where atmospheric attenuation meaningfully shapes the reach. Below
|
|
# that the free-space-loss-dominated map is misleading.
|
|
defp coverage_supported?(%{frequency_mhz: mhz}) when is_number(mhz), do: mhz >= 5760
|
|
defp coverage_supported?(_), do: false
|
|
|
|
@impl true
|
|
def handle_event("toggle_coverage", _params, socket) do
|
|
cond do
|
|
not socket.assigns.coverage_supported ->
|
|
{:noreply, socket}
|
|
|
|
socket.assigns.show_coverage ->
|
|
# Already on, user is turning it off.
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_coverage, false)
|
|
|> push_event("toggle_coverage", %{show: false})}
|
|
|
|
true ->
|
|
# First time turning on (or re-enabling after off) — compute
|
|
# the estimate if we don't already have it, then push the
|
|
# cell payload to the hook.
|
|
estimate = socket.assigns.estimate || RangeEstimate.estimate(socket.assigns.beacon)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_coverage, true)
|
|
|> assign(:estimate, estimate)
|
|
|> push_event("load_coverage", %{
|
|
grid_step: estimate.grid_step,
|
|
cells: estimate.cells,
|
|
show: true
|
|
})}
|
|
end
|
|
end
|
|
|
|
def handle_event("approve", _params, socket) do
|
|
if admin?(socket.assigns.current_scope) do
|
|
{:ok, approved} = Beacons.approve_beacon(socket.assigns.beacon)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:beacon, approved)
|
|
|> put_flash(:info, "Beacon approved.")}
|
|
else
|
|
{:noreply, put_flash(socket, :error, "Admins only.")}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:updated, %Beacon{id: id} = beacon}, %{assigns: %{beacon: %{id: id}}} = socket) do
|
|
supported = coverage_supported?(beacon)
|
|
show? = socket.assigns.show_coverage and supported
|
|
|
|
# Only recompute the estimate if coverage was already displayed —
|
|
# otherwise leave it nil and let the next toggle lazy-load it.
|
|
estimate =
|
|
if show? and socket.assigns.estimate do
|
|
RangeEstimate.estimate(beacon)
|
|
else
|
|
socket.assigns.estimate
|
|
end
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:beacon, beacon)
|
|
|> assign(:coverage_supported, supported)
|
|
|> assign(:show_coverage, show?)
|
|
|> assign(:estimate, estimate)
|
|
|
|
# Re-push fresh cells to the hook if the map is currently visible.
|
|
if show? and estimate do
|
|
{:noreply,
|
|
push_event(socket, "load_coverage", %{
|
|
grid_step: estimate.grid_step,
|
|
cells: estimate.cells,
|
|
show: true
|
|
})}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_info({:deleted, %Beacon{id: id}}, %{assigns: %{beacon: %{id: id}}} = socket) do
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, "This beacon was deleted.")
|
|
|> push_navigate(to: ~p"/beacons")}
|
|
end
|
|
|
|
def handle_info({type, %Beacon{}}, socket) when type in [:created, :updated, :deleted] do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp admin?(%{user: %{is_admin: true}}), do: true
|
|
defp admin?(_), do: false
|
|
|
|
defp bearing_label(nil), do: "Omni"
|
|
defp bearing_label("omni"), do: "Omni"
|
|
defp bearing_label(value), do: "#{value}°"
|
|
|
|
defp format_coord(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 6)
|
|
defp format_coord(value), do: to_string(value)
|
|
|
|
defp format_freq(hz) when is_integer(hz) and hz >= 1_000_000 do
|
|
mhz = Float.round(hz / 1_000_000, 3)
|
|
|
|
"#{mhz} MHz"
|
|
end
|
|
|
|
defp format_freq(hz) when is_integer(hz), do: "#{hz} Hz"
|
|
defp format_freq(_), do: ""
|
|
|
|
defp path_calc_link(%Beacon{} = beacon) do
|
|
base = %{
|
|
"destination" => precise_grid(beacon),
|
|
"dst_height_ft" => to_string(beacon.height_ft)
|
|
}
|
|
|
|
{tx_power_dbm, src_gain_dbi} = baseline_tx_for_eirp(beacon.power_mw)
|
|
|
|
params =
|
|
base
|
|
|> maybe_put("band", BandResolver.resolve_as_string(beacon.frequency_mhz))
|
|
|> maybe_put("tx_power_dbm", tx_power_dbm)
|
|
|> maybe_put("src_gain_dbi", src_gain_dbi)
|
|
|
|
"/path?" <> URI.encode_query(params)
|
|
end
|
|
|
|
# Splits the beacon's EIRP (the beacon schema's `power_mw` field is
|
|
# already an EIRP value, per Beacons.RangeEstimate) into a
|
|
# tx_power_dbm + src_gain_dbi pair so the path calculator's TX chain
|
|
# emits the same EIRP as the beacon. We default the antenna gain to
|
|
# 30 dBi (typical 2-3 ft microwave dish) and derive the TX power from
|
|
# there, clamping to a 0 dBm / 1 mW minimum so very-low-EIRP beacons
|
|
# produce sane baseline values instead of -10 dBm absurdities.
|
|
defp baseline_tx_for_eirp(nil), do: {nil, nil}
|
|
defp baseline_tx_for_eirp(power_mw) when power_mw <= 0, do: {nil, nil}
|
|
|
|
defp baseline_tx_for_eirp(power_mw) do
|
|
eirp_dbm = 10.0 * :math.log10(power_mw)
|
|
default_gain = 30.0
|
|
min_tx_dbm = 0.0
|
|
|
|
tx_dbm = max(min_tx_dbm, eirp_dbm - default_gain)
|
|
gain_dbi = eirp_dbm - tx_dbm
|
|
|
|
{format_dbm(tx_dbm), format_dbi(gain_dbi)}
|
|
end
|
|
|
|
defp format_dbm(v), do: v |> Float.round(1) |> to_string()
|
|
defp format_dbi(v), do: v |> Float.round(1) |> to_string()
|
|
|
|
defp precise_grid(%Beacon{lat: lat, lon: lon}) when is_number(lat) and is_number(lon) do
|
|
Maidenhead.from_latlon(lat, lon, 8)
|
|
end
|
|
|
|
defp precise_grid(%Beacon{grid: grid}), do: grid
|
|
|
|
defp maybe_put(map, _key, nil), do: map
|
|
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
|
|
|
attr :label, :string, required: true
|
|
slot :inner_block, required: true
|
|
|
|
defp stat_field(assigns) do
|
|
~H"""
|
|
<div class="min-w-0">
|
|
<dt class="font-semibold text-[11px] uppercase tracking-wider opacity-60">
|
|
{@label}
|
|
</dt>
|
|
<dd class="truncate">{render_slot(@inner_block)}</dd>
|
|
</div>
|
|
"""
|
|
end
|
|
end
|