beacon monitor work

This commit is contained in:
Graham McIntire 2026-07-22 10:30:09 -05:00
parent 49ade78766
commit f35da3a935
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
10 changed files with 976 additions and 4 deletions

View file

@ -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 <token>` 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.

View file

@ -90,7 +90,8 @@ defmodule Microwaveprop.BeaconMeasurements do
from m in BeaconMeasurement, from m in BeaconMeasurement,
where: m.beacon_id == ^beacon_id, where: m.beacon_id == ^beacon_id,
order_by: [desc: m.measured_at], order_by: [desc: m.measured_at],
limit: ^limit limit: ^limit,
preload: [:monitor]
) )
end end

View file

@ -33,6 +33,7 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
field :antenna_gain_dbi, :float field :antenna_gain_dbi, :float
field :lat, :float field :lat, :float
field :lon, :float field :lon, :float
field :callsign, :string
# Active configuration (admin + assigned user can change) # Active configuration (admin + assigned user can change)
field :config_frequency_hz, :integer field :config_frequency_hz, :integer
@ -77,6 +78,7 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
:antenna_gain_dbi, :antenna_gain_dbi,
:lat, :lat,
:lon, :lon,
:callsign,
:user_id, :user_id,
:assigned_by_id :assigned_by_id
]) ])
@ -86,6 +88,7 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
|> validate_length(:hardware_id, max: 255) |> validate_length(:hardware_id, max: 255)
|> validate_length(:firmware_version, max: 50) |> validate_length(:firmware_version, max: 50)
|> validate_length(:antenna_type, max: 100) |> 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(: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(: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) |> 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() @spec config_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def config_changeset(monitor, attrs) do def config_changeset(monitor, attrs) do
monitor 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_required([:name])
|> validate_length(:name, min: 1, max: 100) |> validate_length(:name, min: 1, max: 100)
|> validate_length(:config_mode, max: 50) |> validate_length(:config_mode, max: 50)

View file

@ -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"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
<.header>
Beacon monitors
<:subtitle>Provision and manage physical monitor hardware.</:subtitle>
</.header>
<div class="flex justify-end mb-4">
<.button phx-click={JS.push("show-create-form", target: @myself)} variant="primary">
<.icon name="hero-plus" class="size-4" /> New monitor
</.button>
</div>
<div
:if={@form.source.action in [:insert, nil]}
id="create-form"
class="card bg-base-200 shadow-sm mb-6"
>
<div class="card-body">
<h3 class="card-title text-sm">Register new hardware</h3>
<.form for={@form} id="monitor-form" phx-submit="create" phx-change="validate">
<div class="grid grid-cols-2 gap-3">
<.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)"
/>
</div>
<div class="grid grid-cols-2 gap-3 mt-3">
<.input field={@form[:lat]} type="number" step="any" label="Latitude" />
<.input field={@form[:lon]} type="number" step="any" label="Longitude" />
</div>
<footer class="mt-4 flex gap-2">
<.button variant="primary" phx-disable-with="Creating...">Create</.button>
<.button phx-click={JS.push("hide-create-form", target: @myself)}>Cancel</.button>
</footer>
</.form>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm">
<thead>
<tr>
<th>Name</th>
<th>Hardware</th>
<th>Assigned to</th>
<th>Monitoring</th>
<th>Last seen</th>
<th class="w-1"></th>
</tr>
</thead>
<tbody>
<tr :for={{id, monitor} <- @streams.monitors}>
<td class="font-semibold">{monitor.name}</td>
<td class="text-sm">
<%= if monitor.hardware_type do %>
{monitor.hardware_type}
<span :if={monitor.hardware_id} class="opacity-60 text-xs"> ({monitor.hardware_id})</span>
<% else %>
<span class="opacity-50">&mdash;</span>
<% end %>
</td>
<td class="text-sm">
<%= if monitor.user do %>
<.link navigate={~p"/u/#{monitor.user.callsign}"} class="link link-hover font-mono">
{monitor.user.callsign}
</.link>
<% else %>
<span class="opacity-50">Unassigned</span>
<% end %>
</td>
<td class="text-sm">
<%= if monitor.beacon do %>
<.link
navigate={~p"/beacons/#{monitor.beacon.id}"}
class="link link-hover font-mono"
>
{monitor.beacon.callsign}
</.link>
<% else %>
<span class="opacity-50">&mdash;</span>
<% end %>
</td>
<td class="text-sm opacity-70">
<%= if monitor.last_seen_at do %>
{Calendar.strftime(monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
never
<% end %>
</td>
<td>
<div class="flex gap-1">
<.link
navigate={~p"/admin/beacon-monitors/#{monitor.id}"}
class="btn btn-xs btn-ghost"
>
Detail
</.link>
<.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
</.link>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</Layouts.app>
"""
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

View file

@ -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"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-5xl">
<.header>
Monitor: {@monitor.name}
<:subtitle>Hardware detail and configuration</:subtitle>
</.header>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<h2 class="card-title text-sm">Hardware</h2>
<dl class="text-sm space-y-1">
<div class="flex justify-between">
<dt class="opacity-70">Type</dt>
<dd>{@monitor.hardware_type}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">ID / Serial</dt>
<dd>{@monitor.hardware_id}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Firmware</dt>
<dd>{@monitor.firmware_version}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Antenna</dt>
<dd>{@monitor.antenna_type}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Antenna gain</dt>
<dd>{@monitor.antenna_gain_dbi && "#{@monitor.antenna_gain_dbi} dBi"}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Location</dt>
<dd>
<%= if @monitor.lat && @monitor.lon do %>
{@monitor.lat}, {@monitor.lon}
<% else %>
<span class="opacity-50">&mdash;</span>
<% end %>
</dd>
</div>
</dl>
</div>
</div>
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<h2 class="card-title text-sm">Assignment</h2>
<dl class="text-sm space-y-1">
<div class="flex justify-between">
<dt class="opacity-70">Assigned to</dt>
<dd>
<%= if @monitor.user do %>
<.link
navigate={~p"/u/#{@monitor.user.callsign}"}
class="link link-hover font-mono"
>
{@monitor.user.callsign}
</.link>
<% else %>
<span class="opacity-50">Unassigned</span>
<% end %>
</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Auth token</dt>
<dd>
<code class="text-xs select-all break-all">{@monitor.token}</code>
</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Last seen</dt>
<dd class="opacity-70">
<%= if @monitor.last_seen_at do %>
{Calendar.strftime(@monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
never
<% end %>
</dd>
</div>
</dl>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-sm mb-6">
<div class="card-body">
<h2 class="card-title text-sm">Configuration</h2>
<.form for={@form} id="config-form" phx-submit="update-config">
<div class="grid grid-cols-2 gap-3">
<.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" />
<p class="text-xs opacity-60 -mt-2">For unregistered operators</p>
<.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)" />
<p class="text-xs opacity-60 -mt-2">Override leave blank to use beacon's frequency</p>
<.input field={@form[:config_integration_s]} type="number" label="Integration (s)" />
<p class="text-xs opacity-60 -mt-2">Override leave blank for default (60s)</p>
<.input
field={@form[:config_mode]}
type="text"
label="Mode"
placeholder="e.g. wspr, q65a_30"
/>
</div>
<footer class="mt-4">
<.button variant="primary" phx-disable-with="Saving...">Save configuration</.button>
</footer>
</.form>
</div>
</div>
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between mb-2">
<h2 class="card-title text-sm">Recent measurements</h2>
<.link navigate={~p"/admin/beacon-monitors"} class="btn btn-ghost btn-xs">
&larr; Back to monitors
</.link>
</div>
<%= if @measurements == [] do %>
<p class="text-sm opacity-70">No measurements yet.</p>
<% else %>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm">
<thead>
<tr>
<th>Time</th>
<th>Frequency</th>
<th>SNR avg</th>
<th>SNR peak</th>
<th>Noise floor</th>
<th>Gain</th>
<th>Integration</th>
</tr>
</thead>
<tbody>
<tr :for={m <- @measurements}>
<td class="whitespace-nowrap text-xs">
{Calendar.strftime(m.measured_at, "%H:%M UTC")}
</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>
<td class="text-xs">{m.integration_s}s</td>
</tr>
</tbody>
</table>
</div>
<% end %>
</div>
</div>
</Layouts.app>
"""
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

View file

@ -2,6 +2,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
@moduledoc "Single-beacon detail page with reception-log history at `/beacons/:id`." @moduledoc "Single-beacon detail page with reception-log history at `/beacons/:id`."
use MicrowavepropWeb, :live_view use MicrowavepropWeb, :live_view
alias Microwaveprop.BeaconMeasurements
alias Microwaveprop.Beacons alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon alias Microwaveprop.Beacons.Beacon
alias Microwaveprop.Beacons.RangeEstimate alias Microwaveprop.Beacons.RangeEstimate
@ -152,6 +153,44 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
</div> </div>
</div> </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> </Layouts.app>
""" """
end end
@ -178,7 +217,8 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|> assign(:coverage_supported, coverage_supported?(beacon)) |> assign(:coverage_supported, coverage_supported?(beacon))
# Estimate is lazy — computed on the first toggle-on to avoid # Estimate is lazy — computed on the first toggle-on to avoid
# paying the bbox grid cost on every page load. # 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
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) when is_float(value), do: :erlang.float_to_binary(value, decimals: 6)
defp format_coord(value), do: to_string(value) 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 defp path_calc_link(%Beacon{} = beacon) do
base = %{ base = %{
"destination" => precise_grid(beacon), "destination" => precise_grid(beacon),

View file

@ -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"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-4xl">
<.header>
{@monitor.name}
<:subtitle>Your beacon monitor</:subtitle>
</.header>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<h2 class="card-title text-sm">Hardware</h2>
<dl class="text-sm space-y-1">
<div class="flex justify-between">
<dt class="opacity-70">Type</dt>
<dd>{@monitor.hardware_type}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Serial / ID</dt>
<dd>{@monitor.hardware_id}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Firmware</dt>
<dd>{@monitor.firmware_version}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Antenna</dt>
<dd>{@monitor.antenna_type}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Antenna gain</dt>
<dd>{@monitor.antenna_gain_dbi && "#{@monitor.antenna_gain_dbi} dBi"}</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Location</dt>
<dd>
<%= if @monitor.lat && @monitor.lon do %>
{@monitor.lat}, {@monitor.lon}
<% else %>
<span class="opacity-50">&mdash;</span>
<% end %>
</dd>
</div>
</dl>
</div>
</div>
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<h2 class="card-title text-sm">Connection</h2>
<dl class="text-sm space-y-1">
<div class="flex justify-between">
<dt class="opacity-70">Auth token</dt>
<dd><code class="text-xs select-all break-all">{@monitor.token}</code></dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Last seen</dt>
<dd>
<%= if @monitor.last_seen_at do %>
{Calendar.strftime(@monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
<span class="opacity-50">Never</span>
<% end %>
</dd>
</div>
<div class="flex justify-between">
<dt class="opacity-70">Status</dt>
<dd>
<span class={[
"badge badge-sm",
monitor_online?(@monitor) && "badge-success",
!monitor_online?(@monitor) && "badge-warning"
]}>
{if monitor_online?(@monitor), do: "Online", else: "Offline"}
</span>
</dd>
</div>
</dl>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-sm mb-6">
<div class="card-body">
<h2 class="card-title text-sm">Configuration</h2>
<p class="text-xs opacity-70 mb-3">Update what your monitor listens for.</p>
<.form for={@form} id="config-form" phx-submit="update-config">
<div class="grid grid-cols-2 gap-3">
<.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)" />
<p class="text-xs opacity-60 -mt-2">Override leave blank to use beacon's frequency</p>
<.input field={@form[:config_integration_s]} type="number" label="Integration (s)" />
<p class="text-xs opacity-60 -mt-2">Override leave blank for default (60s)</p>
<.input
field={@form[:config_mode]}
type="text"
label="Mode"
placeholder="e.g. wspr, q65a_30"
/>
</div>
<footer class="mt-4">
<.button variant="primary" phx-disable-with="Saving...">Save</.button>
</footer>
</.form>
</div>
</div>
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<h2 class="card-title text-sm mb-2">Recent measurements</h2>
<%= if @measurements == [] do %>
<p class="text-sm opacity-70">
No measurements yet. Once the monitor starts reporting, they'll appear here.
</p>
<% else %>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm">
<thead>
<tr>
<th>Time</th>
<th>Frequency</th>
<th>SNR avg</th>
<th>SNR peak</th>
<th>Noise floor</th>
<th>Gain</th>
<th>Integration</th>
</tr>
</thead>
<tbody>
<tr :for={m <- @measurements}>
<td class="whitespace-nowrap text-xs">
{Calendar.strftime(m.measured_at, "%H:%M UTC")}
</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>
<td class="text-xs">{m.integration_s}s</td>
</tr>
</tbody>
</table>
</div>
<% end %>
</div>
</div>
</Layouts.app>
"""
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

View file

@ -184,7 +184,14 @@ defmodule MicrowavepropWeb.UserProfileLive do
</thead> </thead>
<tbody> <tbody>
<tr :for={monitor <- @beacon_monitors}> <tr :for={monitor <- @beacon_monitors}>
<td class="font-semibold">{monitor.name}</td> <td>
<.link
navigate={~p"/beacon-monitors/#{monitor.id}"}
class="link link-hover font-semibold"
>
{monitor.name}
</.link>
</td>
<td class="text-sm"> <td class="text-sm">
<%= if monitor.hardware_type do %> <%= if monitor.hardware_type do %>
{monitor.hardware_type}<br /> {monitor.hardware_type}<br />

View file

@ -228,6 +228,9 @@ defmodule MicrowavepropWeb.Router do
live "/users", UserManagementLive.Index, :index live "/users", UserManagementLive.Index, :index
live "/users/:id/edit", UserManagementLive.Edit, :edit 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
end end
@ -278,6 +281,9 @@ defmodule MicrowavepropWeb.Router do
# Public per-user profile (contributions: contacts + beacons submitted) # Public per-user profile (contributions: contacts + beacons submitted)
live "/u/:callsign", UserProfileLive live "/u/:callsign", UserProfileLive
# User-facing beacon-monitor detail (assigned user can view + configure)
live "/beacon-monitors/:id", MonitorLive.Show, :show
end end
# Redirect old /qsos routes # Redirect old /qsos routes

View file

@ -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