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""" <.header> {@beacon.callsign} <:subtitle> {Beacon.format_freq(@beacon.frequency_mhz)} MHz · {@beacon.grid} Pending approval <:actions> <.button :if={admin?(@current_scope) and not @beacon.approved} variant="primary" phx-click="approve" > <.icon name="hero-check" /> Approve <.button navigate={~p"/beacons"}> <.icon name="hero-arrow-left" /> <.button navigate={path_calc_link(@beacon)}> <.icon name="hero-map" /> Plot path to beacon <.button :if={admin?(@current_scope)} variant="primary" navigate={~p"/beacons/#{@beacon}/edit?return_to=show"} > <.icon name="hero-pencil-square" /> Edit
@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)} >
Band {@estimate.band_label} · EIRP {@estimate.eirp_dbm} dBm · Atm loss {@estimate.atm_per_km} dB/km Beacon grid score {@estimate.center_score} <%= 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 %> · {length(@estimate.cells)} cells rendered
<%= for tier <- @estimate.tiers do %>
{tier.label} (≥ {tier.min_dbm} dBm)
<% end %>
<.stat_field label="Frequency"> {Beacon.format_freq(@beacon.frequency_mhz)} MHz <.stat_field label="Callsign">{@beacon.callsign} <.stat_field label="Keying">{Beacon.keying_label(@beacon.keying)} <.stat_field label="On the air"> {if @beacon.on_the_air, do: "Yes", else: "No"} <.stat_field label="Grid">{@beacon.grid} <.stat_field label="Latitude">{format_coord(@beacon.lat)} <.stat_field label="Longitude">{format_coord(@beacon.lon)} <.stat_field label="EIRP">{Beacon.format_mw(@beacon.power_mw)} mW <.stat_field label="Height AGL">{@beacon.height_ft} ft <.stat_field label="Bearing">{bearing_label(@beacon.bearing)} <.stat_field :if={@beacon.beamwidth_deg} label="Beamwidth"> {Beacon.format_mw(@beacon.beamwidth_deg)}°
Notes
{@beacon.notes}
Submitted by {@beacon.user.callsign}

Recent reception reports

<%= if @measurements == [] do %>

No reception reports yet.

<% else %>
Time Monitor Frequency SNR avg SNR peak Noise floor Gain
{Calendar.strftime(m.measured_at, "%Y-%m-%d %H:%M UTC")} {m.monitor.name} {format_freq(m.frequency_hz)} {Float.round(m.snr_avg_db, 1)} dB {Float.round(m.snr_peak_db, 1)} dB {Float.round(m.noise_floor_dbfs, 1)} dBFS {Float.round(m.gain_db, 1)} dB
<% end %>
""" 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"""
{@label}
{render_slot(@inner_block)}
""" end end