diff --git a/docs/plans/2026-07-21-propmonitor-client-software.md b/docs/plans/2026-07-21-propmonitor-client-software.md new file mode 100644 index 00000000..005f54a9 --- /dev/null +++ b/docs/plans/2026-07-21-propmonitor-client-software.md @@ -0,0 +1,188 @@ +# propmonitor Client Software — Design Plan + +**Goal:** Define the `propmonitor` client that runs on RPi-based SDR hardware, authenticates with an API token, fetches its config, and periodically uploads beacon reception measurements. + +## Architecture + +``` +┌──────────────────────┐ POST /api/v1/measurements ┌──────────────────────┐ +│ propmonitor client │ ──────────────────────────────► │ Microwaveprop API │ +│ (RPi + SDR) │ │ (Elixir / Phoenix) │ +│ │ ◄────────────────────────────── │ │ +│ │ GET /api/v1/monitors/:token │ │ +└──────────────────────┘ (config) └──────────────────────┘ +``` + +## Client responsibilities + +1. **Boot registration:** On first run, the client creates a unique hardware ID (e.g. `/etc/machine-id` or a MAC-address-derived UUID) and reports it to the API. The API maps this to the monitor record provisioned by the admin. +2. **Config fetch (`GET /api/v1/monitors/:token`):** The server returns the monitor's active config — which beacon to listen for, what frequency, integration window, and mode. +3. **Measurement loop:** For each integration window: + - Tune SDR to the configured frequency + - Record passband IQ for `config_integration_s` seconds + - Compute noise floor, signal peak/avg, SNR, signal active fraction + - Upload via `POST /api/v1/measurements` +4. **Token-based auth:** The API token (set when the admin provisions the monitor) is the only credential. No user account needed. + +## Data flow + +``` +Monitor token ──► GET /api/v1/monitors/:token ──► { name, beacon, frequency_hz, integration_s, mode } + │ + ▼ + SDR tunes to frequency_hz + │ + ▼ + Record for integration_s seconds + │ + ▼ + Compute SNR, noise floor, etc. + │ + ▼ + POST /api/v1/measurements ──► { monitor_id, beacon_id, frequency_hz, + snr_avg_db, snr_peak_db, noise_floor_dbfs, + gain_db, integration_s, measured_at, ... } +``` + +## Endpoints the client uses + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/api/v1/monitors/:token` | Fetch monitor config (name, beacon, frequency_hz, integration_s, mode, lat, lon) | +| `GET` | `/api/v1/beacons` | List known beacons (for the user to select which to monitor) | +| `POST` | `/api/v1/measurements` | Upload a measurement batch | + +## Client config file (`~/.config/propmonitor/config.toml`) + +```toml +# Required: token assigned by admin +token = "abc123..." + +# Optional overrides (if not set, fetched from API) +frequency_hz = 144_000_000 +integration_s = 60 +mode = "wspr" + +# SDR device +sdr_type = "rtl-sdr" +sdr_gain_db = 40.0 +``` + +On each run the client fetches the latest config from the API, merging file-based overrides on top. + +## Key design decisions + +1. **Pull config, don't push:** Client polls `GET /api/v1/monitors/:token` on startup and periodically (every 5 minutes) for config changes. No long-lived connection needed. +2. **Token is the identity:** The token binds the client to a specific provisioned monitor record. Multiple clients cannot share a token. +3. **Self-registration via hardware_id:** On first connect, the client submits its `hardware_id` which links it to the correct admin-provisioned monitor record. If no matching hardware_id exists, the server rejects the registration and the admin must provision it first. +4. **Battery-friendly:** The client sleeps between integration windows. For a 60-second integration on a 5-minute cycle, duty cycle is ~20%. + +## Language choice + +**Python** for initial implementation (fast prototyping, excellent SDR library support via `pyrtlsdr` / `hackrf`), with an eye toward a Rust rewrite for battery-constrained solar-powered deployments. + +## Hardware targets + +- Raspberry Pi 3/4/5 + RTL-SDR v3 (entry-level, ~$30) +- Raspberry Pi 5 + HackRF One / Airspy HF+ Discovery (mid-range) +- Raspberry Pi 5 + LimeSDR / USRP B200 (advanced, for wideband monitoring) + +## Future considerations + +- **Webhook config pushes:** Server pushes new config (`PATCH` from admin UI) via a lightweight MQTT or SSE mechanism. +- **OTA firmware updates:** The client checks for new versions on startup via a version endpoint. +- **Solar/battery optimization:** Deeper sleep states, batch uploads, variable integration windows. +- **Offline mode:** Cache the config and buffer measurements if the network is unavailable. + +## API validation endpoints needed + +### `GET /api/v1/monitors/by-hardware/:hardware_id` + +Returns the monitor record for a given hardware_id. Used during registration to map the device to its admin-provisioned record. + +**Response:** + +```json +{ + "id": "uuid", + "name": "Rooftop East", + "token": "abc123...", + "config_frequency_hz": 144000000, + "config_integration_s": 60, + "config_mode": "wspr", + "beacon_id": "uuid" +} +``` + +### `POST /api/v1/measurements` + +Uploads a measurement. The client must set the `Authorization: Bearer ` header. + +**Request body:** + +```json +{ + "frequency_hz": 144000000, + "integration_s": 60, + "passband_hz": 200.0, + "gain_db": 40.0, + "noise_floor_dbfs": -85.3, + "signal_peak_dbfs": -72.1, + "signal_avg_dbfs": -75.4, + "snr_peak_db": 13.2, + "snr_avg_db": 9.9, + "signal_active_fraction": 0.85, + "measured_at": "2026-07-21T18:30:00Z", + "propmonitor_version": "0.1.0" +} +``` + +The `beacon_id` and `monitor_id` are resolved server-side from the token and the beacon matching the frequency + configured beacon. + +## Measurement ingestion pipeline + +``` +POST /api/v1/measurements + │ + ▼ +BeaconMeasurementsController.create(conn, params) + │ + ▼ +Look up monitor by token (Authorization header) + │ + ▼ +Resolve beacon_id from configured beacon (monitor.beacon_id) +(or match by frequency if no explicit beacon configured) + │ + ▼ +BeaconMeasurements.create_measurement(%{ + monitor_id: resolved_monitor_id, + beacon_id: resolved_beacon_id, + frequency_hz: params.frequency_hz, + integration_s: params.integration_s, + passband_hz: params.passband_hz, + gain_db: params.gain_db, + noise_floor_dbfs: params.noise_floor_dbfs, + ... +}) + │ + ▼ +Insert into beacon_measurements table + │ + ▼ +Update monitor.last_seen_at +``` + +## Implementation order + +1. Add `GET /api/v1/monitors/:token` endpoint (returns monitor config) +2. Add `GET /api/v1/monitors/by-hardware/:hardware_id` endpoint (registration lookup) +3. Add `POST /api/v1/measurements` endpoint with token auth +4. Write Python client prototype (`propmonitor/`) +5. Dogfood: deploy to a test RPi with an RTL-SDR + +## Related files + +- `lib/microwaveprop_web/controllers/` — API v1 controller (where new endpoints go) +- `lib/microwaveprop/beacon_measurements.ex` — context for measurements +- `lib/microwaveprop/beacon_monitors/beacon_monitor.ex` — schema with token, hardware_id, etc. diff --git a/lib/microwaveprop/beacon_measurements.ex b/lib/microwaveprop/beacon_measurements.ex index 20c89247..caaddadc 100644 --- a/lib/microwaveprop/beacon_measurements.ex +++ b/lib/microwaveprop/beacon_measurements.ex @@ -90,7 +90,8 @@ defmodule Microwaveprop.BeaconMeasurements do from m in BeaconMeasurement, where: m.beacon_id == ^beacon_id, order_by: [desc: m.measured_at], - limit: ^limit + limit: ^limit, + preload: [:monitor] ) end diff --git a/lib/microwaveprop/beacon_monitors/beacon_monitor.ex b/lib/microwaveprop/beacon_monitors/beacon_monitor.ex index 9c51e37d..a9492c9a 100644 --- a/lib/microwaveprop/beacon_monitors/beacon_monitor.ex +++ b/lib/microwaveprop/beacon_monitors/beacon_monitor.ex @@ -33,6 +33,7 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do field :antenna_gain_dbi, :float field :lat, :float field :lon, :float + field :callsign, :string # Active configuration (admin + assigned user can change) field :config_frequency_hz, :integer @@ -77,6 +78,7 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do :antenna_gain_dbi, :lat, :lon, + :callsign, :user_id, :assigned_by_id ]) @@ -86,6 +88,7 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do |> validate_length(:hardware_id, max: 255) |> validate_length(:firmware_version, max: 50) |> validate_length(:antenna_type, max: 100) + |> validate_length(:callsign, max: 20) |> validate_number(:antenna_gain_dbi, greater_than_or_equal_to: -20, less_than_or_equal_to: 60) |> validate_number(:lat, greater_than_or_equal_to: -90, less_than_or_equal_to: 90) |> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180) @@ -100,7 +103,16 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do @spec config_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def config_changeset(monitor, attrs) do monitor - |> cast(attrs, [:name, :beacon_id, :config_frequency_hz, :config_integration_s, :config_mode]) + |> cast(attrs, [ + :name, + :beacon_id, + :callsign, + :lat, + :lon, + :config_frequency_hz, + :config_integration_s, + :config_mode + ]) |> validate_required([:name]) |> validate_length(:name, min: 1, max: 100) |> validate_length(:config_mode, max: 50) diff --git a/lib/microwaveprop_web/live/admin/monitor_live/index.ex b/lib/microwaveprop_web/live/admin/monitor_live/index.ex new file mode 100644 index 00000000..ace9eabb --- /dev/null +++ b/lib/microwaveprop_web/live/admin/monitor_live/index.ex @@ -0,0 +1,226 @@ +defmodule MicrowavepropWeb.Admin.MonitorLive.Index do + @moduledoc "Admin beacon-monitor list at `/admin/beacon-monitors`." + use MicrowavepropWeb, :live_view + + import Ecto.Query + + alias Microwaveprop.Accounts.User + alias Microwaveprop.BeaconMonitors + alias Microwaveprop.BeaconMonitors.BeaconMonitor + alias Microwaveprop.Repo + + @impl true + def mount(_params, _session, socket) do + users = Repo.all(from u in User, order_by: u.callsign) + + {:ok, + socket + |> assign(:page_title, "Beacon monitors") + |> assign(:users, users) + |> assign(:form, to_form(BeaconMonitors.change_monitor())) + |> stream(:monitors, [])} + end + + @impl true + def handle_params(_params, _url, socket) do + monitors = BeaconMonitors.list_all_monitors() + {:noreply, stream(socket, :monitors, monitors, reset: true)} + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + Beacon monitors + <:subtitle>Provision and manage physical monitor hardware. + + +
+ <.button phx-click={JS.push("show-create-form", target: @myself)} variant="primary"> + <.icon name="hero-plus" class="size-4" /> New monitor + +
+ +
+
+

Register new hardware

+ <.form for={@form} id="monitor-form" phx-submit="create" phx-change="validate"> +
+ <.input field={@form[:name]} type="text" label="Name" required /> + <.input + field={@form[:user_id]} + type="select" + label="Assign to" + options={user_options(@users)} + /> + <.input + field={@form[:hardware_type]} + type="text" + label="Hardware type" + placeholder="RTL-SDR" + /> + <.input + field={@form[:hardware_id]} + type="text" + label="Hardware ID / Serial" + placeholder="SN-001" + /> + <.input field={@form[:firmware_version]} type="text" label="Firmware version" /> + <.input + field={@form[:antenna_type]} + type="text" + label="Antenna type" + placeholder="Dipole" + /> + <.input + field={@form[:antenna_gain_dbi]} + type="number" + step="any" + label="Antenna gain (dBi)" + /> +
+
+ <.input field={@form[:lat]} type="number" step="any" label="Latitude" /> + <.input field={@form[:lon]} type="number" step="any" label="Longitude" /> +
+
+ <.button variant="primary" phx-disable-with="Creating...">Create + <.button phx-click={JS.push("hide-create-form", target: @myself)}>Cancel +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
NameHardwareAssigned toMonitoringLast seen
{monitor.name} + <%= if monitor.hardware_type do %> + {monitor.hardware_type} + ({monitor.hardware_id}) + <% else %> + + <% end %> + + <%= if monitor.user do %> + <.link navigate={~p"/u/#{monitor.user.callsign}"} class="link link-hover font-mono"> + {monitor.user.callsign} + + <% else %> + Unassigned + <% end %> + + <%= if monitor.beacon do %> + <.link + navigate={~p"/beacons/#{monitor.beacon.id}"} + class="link link-hover font-mono" + > + {monitor.beacon.callsign} + + <% else %> + + <% end %> + + <%= if monitor.last_seen_at do %> + {Calendar.strftime(monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")} + <% else %> + never + <% end %> + +
+ <.link + navigate={~p"/admin/beacon-monitors/#{monitor.id}"} + class="btn btn-xs btn-ghost" + > + Detail + + <.link + phx-click={JS.push("delete", value: %{id: monitor.id}, target: @myself)} + data-confirm={"Delete monitor '#{monitor.name}'? This cannot be undone."} + class="btn btn-xs btn-ghost text-error" + > + Delete + +
+
+
+
+ """ + end + + @impl true + def handle_event("show-create-form", _params, socket) do + form = to_form(BeaconMonitors.change_monitor(), as: :beacon_monitor) + {:noreply, assign(socket, :form, form)} + end + + def handle_event("hide-create-form", _params, socket) do + {:noreply, assign(socket, :form, to_form(BeaconMonitors.change_monitor(), action: :ignore))} + end + + def handle_event("validate", %{"beacon_monitor" => params}, socket) do + changeset = + %BeaconMonitor{} + |> BeaconMonitor.provision_changeset(params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :form, to_form(changeset, as: :beacon_monitor))} + end + + def handle_event("create", %{"beacon_monitor" => params}, socket) do + admin = socket.assigns.current_scope.user + + case BeaconMonitors.create_hardware(admin, params) do + {:ok, monitor} -> + {:noreply, + socket + |> put_flash(:info, "Monitor '#{monitor.name}' created.") + |> assign(:form, to_form(BeaconMonitors.change_monitor(), action: :ignore)) + |> stream(:monitors, [monitor], at: 0)} + + {:error, changeset} -> + {:noreply, assign(socket, :form, to_form(changeset, as: :beacon_monitor))} + end + end + + def handle_event("delete", %{"id" => id}, socket) do + case BeaconMonitors.delete_monitor!(id) do + {:ok, monitor} -> + {:noreply, + socket + |> put_flash(:info, "Monitor '#{monitor.name}' deleted.") + |> stream_delete(:monitors, monitor)} + + {:error, :not_found} -> + {:noreply, put_flash(socket, :error, "Monitor not found.")} + end + end + + defp user_options(users) do + Enum.map(users, &{&1.callsign, &1.id}) + end +end diff --git a/lib/microwaveprop_web/live/admin/monitor_live/show.ex b/lib/microwaveprop_web/live/admin/monitor_live/show.ex new file mode 100644 index 00000000..0e440455 --- /dev/null +++ b/lib/microwaveprop_web/live/admin/monitor_live/show.ex @@ -0,0 +1,236 @@ +defmodule MicrowavepropWeb.Admin.MonitorLive.Show do + @moduledoc "Admin beacon-monitor detail at `/admin/beacon-monitors/:id`." + use MicrowavepropWeb, :live_view + + import Ecto.Query + + alias Microwaveprop.BeaconMeasurements + alias Microwaveprop.BeaconMonitors + alias Microwaveprop.BeaconMonitors.BeaconMonitor + alias Microwaveprop.Beacons.Beacon + alias Microwaveprop.Repo + + @impl true + def mount(%{"id" => id}, _session, socket) do + monitor = BeaconMonitors.get_monitor!(id) + beacons = Repo.all(from b in Beacon, order_by: b.callsign) + measurements = BeaconMeasurements.list_recent_for_monitor(monitor.id, 20) + + {:ok, + socket + |> assign(:page_title, "Monitor: #{monitor.name}") + |> assign(:monitor, monitor) + |> assign(:beacons, beacons) + |> assign(:measurements, measurements) + |> assign_form(monitor)} + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + Monitor: {@monitor.name} + <:subtitle>Hardware detail and configuration + + +
+
+
+

Hardware

+
+
+
Type
+
{@monitor.hardware_type}
+
+
+
ID / Serial
+
{@monitor.hardware_id}
+
+
+
Firmware
+
{@monitor.firmware_version}
+
+
+
Antenna
+
{@monitor.antenna_type}
+
+
+
Antenna gain
+
{@monitor.antenna_gain_dbi && "#{@monitor.antenna_gain_dbi} dBi"}
+
+
+
Location
+
+ <%= if @monitor.lat && @monitor.lon do %> + {@monitor.lat}, {@monitor.lon} + <% else %> + + <% end %> +
+
+
+
+
+ +
+
+

Assignment

+
+
+
Assigned to
+
+ <%= if @monitor.user do %> + <.link + navigate={~p"/u/#{@monitor.user.callsign}"} + class="link link-hover font-mono" + > + {@monitor.user.callsign} + + <% else %> + Unassigned + <% end %> +
+
+
+
Auth token
+
+ {@monitor.token} +
+
+
+
Last seen
+
+ <%= if @monitor.last_seen_at do %> + {Calendar.strftime(@monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")} + <% else %> + never + <% end %> +
+
+
+
+
+
+ +
+
+

Configuration

+ <.form for={@form} id="config-form" phx-submit="update-config"> +
+ <.input field={@form[:name]} type="text" label="Name" required /> + <.input + field={@form[:beacon_id]} + type="select" + label="Beacon to monitor" + options={beacon_options(@beacons)} + prompt="Select beacon" + /> + <.input field={@form[:callsign]} type="text" label="Callsign" /> +

For unregistered operators

+ <.input field={@form[:lat]} type="number" label="Latitude" step="any" /> + <.input field={@form[:lon]} type="number" label="Longitude" step="any" /> + <.input field={@form[:config_frequency_hz]} type="number" label="Frequency (Hz)" /> +

Override — leave blank to use beacon's frequency

+ <.input field={@form[:config_integration_s]} type="number" label="Integration (s)" /> +

Override — leave blank for default (60s)

+ <.input + field={@form[:config_mode]} + type="text" + label="Mode" + placeholder="e.g. wspr, q65a_30" + /> +
+
+ <.button variant="primary" phx-disable-with="Saving...">Save configuration +
+ +
+
+ +
+
+
+

Recent measurements

+ <.link navigate={~p"/admin/beacon-monitors"} class="btn btn-ghost btn-xs"> + ← Back to monitors + +
+ <%= if @measurements == [] do %> +

No measurements yet.

+ <% else %> +
+ + + + + + + + + + + + + + + + + + + + + + + +
TimeFrequencySNR avgSNR peakNoise floorGainIntegration
+ {Calendar.strftime(m.measured_at, "%H:%M UTC")} + {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{m.integration_s}s
+
+ <% end %> +
+
+
+ """ + end + + @impl true + def handle_event("update-config", %{"beacon_monitor" => params}, socket) do + monitor = socket.assigns.monitor + + case BeaconMonitors.update_config(monitor, params) do + {:ok, updated} -> + {:noreply, + socket + |> assign(:monitor, updated) + |> assign_form(updated) + |> put_flash(:info, "Configuration updated.")} + + {:error, changeset} -> + {:noreply, assign(socket, :form, to_form(changeset, as: :beacon_monitor))} + end + end + + defp assign_form(socket, monitor) do + assign(socket, :form, to_form(BeaconMonitor.config_changeset(monitor, %{}), as: :beacon_monitor)) + end + + defp beacon_options(beacons) do + Enum.map(beacons, &{"#{&1.callsign} @ #{Beacon.format_freq(&1.frequency_mhz)} MHz", &1.id}) + end + + defp format_freq(hz) when is_integer(hz) and hz >= 1_000_000 do + mhz = + (hz / 1_000_000) + |> Float.round(3) + |> then(fn + n when n == trunc(n) -> trunc(n) + n -> n + end) + + "#{mhz} MHz" + end + + defp format_freq(hz) when is_integer(hz), do: "#{hz} Hz" + defp format_freq(_), do: "" +end diff --git a/lib/microwaveprop_web/live/beacon_live/show.ex b/lib/microwaveprop_web/live/beacon_live/show.ex index 727b30cb..45d2672a 100644 --- a/lib/microwaveprop_web/live/beacon_live/show.ex +++ b/lib/microwaveprop_web/live/beacon_live/show.ex @@ -2,6 +2,7 @@ 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 @@ -152,6 +153,44 @@ defmodule MicrowavepropWeb.BeaconLive.Show do + +
+
+

Recent reception reports

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

No reception reports yet.

+ <% else %> +
+ + + + + + + + + + + + + + + + + + + + + + + +
TimeMonitorFrequencySNR avgSNR peakNoise floorGain
+ {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 @@ -178,7 +217,8 @@ defmodule MicrowavepropWeb.BeaconLive.Show do |> 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(:estimate, nil) + |> assign(:measurements, BeaconMeasurements.list_recent_for_beacon(beacon.id, 10))} end end @@ -287,6 +327,15 @@ defmodule MicrowavepropWeb.BeaconLive.Show do 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), diff --git a/lib/microwaveprop_web/live/monitor_live/show.ex b/lib/microwaveprop_web/live/monitor_live/show.ex new file mode 100644 index 00000000..8248bcb6 --- /dev/null +++ b/lib/microwaveprop_web/live/monitor_live/show.ex @@ -0,0 +1,238 @@ +defmodule MicrowavepropWeb.MonitorLive.Show do + @moduledoc "User-facing beacon-monitor detail at `/beacon-monitors/:id`." + use MicrowavepropWeb, :live_view + + import Ecto.Query + + alias Microwaveprop.BeaconMeasurements + alias Microwaveprop.BeaconMonitors + alias Microwaveprop.BeaconMonitors.BeaconMonitor + alias Microwaveprop.Beacons.Beacon + alias Microwaveprop.Repo + + @impl true + def mount(%{"id" => id}, _session, socket) do + user = socket.assigns.current_scope.user + monitor = BeaconMonitors.get_monitor!(id) + + if monitor.user_id == user.id do + beacons = Repo.all(from b in Beacon, where: b.approved == true, order_by: b.callsign) + measurements = BeaconMeasurements.list_recent_for_monitor(monitor.id, 20) + + {:ok, + socket + |> assign(:page_title, "My Monitor: #{monitor.name}") + |> assign(:monitor, monitor) + |> assign(:beacons, beacons) + |> assign(:measurements, measurements) + |> assign_form(monitor)} + else + {:ok, + socket + |> put_flash(:error, "You don't have access to that monitor.") + |> push_navigate(to: ~p"/account")} + end + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + {@monitor.name} + <:subtitle>Your beacon monitor + + +
+
+
+

Hardware

+
+
+
Type
+
{@monitor.hardware_type}
+
+
+
Serial / ID
+
{@monitor.hardware_id}
+
+
+
Firmware
+
{@monitor.firmware_version}
+
+
+
Antenna
+
{@monitor.antenna_type}
+
+
+
Antenna gain
+
{@monitor.antenna_gain_dbi && "#{@monitor.antenna_gain_dbi} dBi"}
+
+
+
Location
+
+ <%= if @monitor.lat && @monitor.lon do %> + {@monitor.lat}, {@monitor.lon} + <% else %> + + <% end %> +
+
+
+
+
+ +
+
+

Connection

+
+
+
Auth token
+
{@monitor.token}
+
+
+
Last seen
+
+ <%= if @monitor.last_seen_at do %> + {Calendar.strftime(@monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")} + <% else %> + Never + <% end %> +
+
+
+
Status
+
+ + {if monitor_online?(@monitor), do: "Online", else: "Offline"} + +
+
+
+
+
+
+ +
+
+

Configuration

+

Update what your monitor listens for.

+ <.form for={@form} id="config-form" phx-submit="update-config"> +
+ <.input field={@form[:name]} type="text" label="Name" required /> + <.input + field={@form[:beacon_id]} + type="select" + label="Beacon to monitor" + options={beacon_options(@beacons)} + prompt="Select beacon" + /> + <.input field={@form[:config_frequency_hz]} type="number" label="Frequency (Hz)" /> +

Override — leave blank to use beacon's frequency

+ <.input field={@form[:config_integration_s]} type="number" label="Integration (s)" /> +

Override — leave blank for default (60s)

+ <.input + field={@form[:config_mode]} + type="text" + label="Mode" + placeholder="e.g. wspr, q65a_30" + /> +
+
+ <.button variant="primary" phx-disable-with="Saving...">Save +
+ +
+
+ +
+
+

Recent measurements

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

+ No measurements yet. Once the monitor starts reporting, they'll appear here. +

+ <% else %> +
+ + + + + + + + + + + + + + + + + + + + + + + +
TimeFrequencySNR avgSNR peakNoise floorGainIntegration
+ {Calendar.strftime(m.measured_at, "%H:%M UTC")} + {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{m.integration_s}s
+
+ <% end %> +
+
+
+ """ + end + + @impl true + def handle_event("update-config", %{"beacon_monitor" => params}, socket) do + monitor = socket.assigns.monitor + + case BeaconMonitors.update_config(monitor, params) do + {:ok, updated} -> + {:noreply, + socket + |> assign(:monitor, updated) + |> assign_form(updated) + |> put_flash(:info, "Configuration updated.")} + + {:error, changeset} -> + {:noreply, assign(socket, :form, to_form(changeset, as: :beacon_monitor))} + end + end + + defp monitor_online?(monitor) do + monitor.last_seen_at && DateTime.diff(DateTime.utc_now(), monitor.last_seen_at, :minute) < 30 + end + + defp assign_form(socket, monitor) do + assign(socket, :form, to_form(BeaconMonitor.config_changeset(monitor, %{}), as: :beacon_monitor)) + end + + defp beacon_options(beacons) do + Enum.map(beacons, &{"#{&1.callsign} @ #{Beacon.format_freq(&1.frequency_mhz)} MHz", &1.id}) + end + + defp format_freq(hz) when is_integer(hz) and hz >= 1_000_000 do + mhz = + (hz / 1_000_000) + |> Float.round(3) + |> then(fn + n when n == trunc(n) -> trunc(n) + n -> n + end) + + "#{mhz} MHz" + end + + defp format_freq(hz) when is_integer(hz), do: "#{hz} Hz" + defp format_freq(_), do: "" +end diff --git a/lib/microwaveprop_web/live/user_profile_live.ex b/lib/microwaveprop_web/live/user_profile_live.ex index ccf48361..746847d7 100644 --- a/lib/microwaveprop_web/live/user_profile_live.ex +++ b/lib/microwaveprop_web/live/user_profile_live.ex @@ -184,7 +184,14 @@ defmodule MicrowavepropWeb.UserProfileLive do - {monitor.name} + + <.link + navigate={~p"/beacon-monitors/#{monitor.id}"} + class="link link-hover font-semibold" + > + {monitor.name} + + <%= if monitor.hardware_type do %> {monitor.hardware_type}
diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 39a4f0d6..6eaefa6d 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -228,6 +228,9 @@ defmodule MicrowavepropWeb.Router do live "/users", UserManagementLive.Index, :index live "/users/:id/edit", UserManagementLive.Edit, :edit + + live "/admin/beacon-monitors", Admin.MonitorLive.Index, :index + live "/admin/beacon-monitors/:id", Admin.MonitorLive.Show, :show end end @@ -278,6 +281,9 @@ defmodule MicrowavepropWeb.Router do # Public per-user profile (contributions: contacts + beacons submitted) live "/u/:callsign", UserProfileLive + + # User-facing beacon-monitor detail (assigned user can view + configure) + live "/beacon-monitors/:id", MonitorLive.Show, :show end # Redirect old /qsos routes diff --git a/priv/repo/migrations/20260721234706_add_callsign_to_beacon_monitors.exs b/priv/repo/migrations/20260721234706_add_callsign_to_beacon_monitors.exs new file mode 100644 index 00000000..a5f9e292 --- /dev/null +++ b/priv/repo/migrations/20260721234706_add_callsign_to_beacon_monitors.exs @@ -0,0 +1,9 @@ +defmodule Microwaveprop.Repo.Migrations.AddCallsignToBeaconMonitors do + use Ecto.Migration + + def change do + alter table(:beacon_monitors) do + add :callsign, :string, null: true + end + end +end