prop/lib/microwaveprop/beacon_monitors.ex
Graham McIntire bd731685de
feat(api): ingest endpoint for propmonitor beacon measurements
POST /api/v1/beacon-monitor/measurements accepts one measurement per
integration window from a propmonitor client, authenticated by the
BeaconMonitor token. Records noise floor, signal peak/avg dBFS, SNR,
signal-active-fraction, gain, and frequency for later correlation
against weather and propagation scores.

Beacon UUID must resolve to an approved + on-the-air beacon (404
otherwise — the client drops 404s without retry). Retries with the
same (monitor, beacon, measured_at) are idempotent: a unique index
makes the second insert a 409. Every accepted upload stamps the
monitor's last_seen_at — measurements *are* the heartbeat.

MonitorAuth is a separate plug from the user API-token Auth plug;
monitors are not users. The rate limiter buckets each monitor by id so
retry storms after a 5xx don't burn the shared anon-IP bucket.
2026-05-13 16:07:04 -05:00

96 lines
2.9 KiB
Elixir

defmodule Microwaveprop.BeaconMonitors do
@moduledoc """
The BeaconMonitors context: manages the monitor stations a user
has registered. Each monitor has a unique random token the remote
program uses to authenticate its reports.
"""
import Ecto.Query
alias Microwaveprop.Accounts.User
alias Microwaveprop.BeaconMonitors.BeaconMonitor
alias Microwaveprop.Repo
@token_bytes 32
@doc """
Returns all monitors for the given user, newest first.
"""
@spec list_monitors_for_user(User.t()) :: [BeaconMonitor.t()]
def list_monitors_for_user(%User{id: user_id}) do
Repo.all(
from m in BeaconMonitor,
where: m.user_id == ^user_id,
order_by: [desc: m.inserted_at]
)
end
@doc """
Creates a new monitor for the given user with a freshly generated token.
"""
@spec create_monitor(User.t(), map()) :: {:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def create_monitor(%User{} = user, attrs) do
%BeaconMonitor{user_id: user.id, token: generate_token()}
|> BeaconMonitor.changeset(attrs)
|> Repo.insert()
end
@doc """
Deletes a monitor owned by the given user.
Returns `{:error, :not_found}` if the monitor does not exist or
belongs to another user.
"""
@spec delete_monitor(User.t(), Ecto.UUID.t()) ::
{:ok, BeaconMonitor.t()} | {:error, :not_found} | {:error, Ecto.Changeset.t()}
def delete_monitor(%User{id: user_id}, monitor_id) do
query =
from m in BeaconMonitor,
where: m.id == ^monitor_id and m.user_id == ^user_id
case Repo.one(query) do
nil -> {:error, :not_found}
monitor -> Repo.delete(monitor)
end
rescue
Ecto.Query.CastError -> {:error, :not_found}
end
@doc """
Looks up a monitor by its token. Returns nil if not found.
"""
@spec get_monitor_by_token(String.t()) :: BeaconMonitor.t() | nil
def get_monitor_by_token(token) when is_binary(token) do
Repo.get_by(BeaconMonitor, token: token)
end
@doc """
Stamps `last_seen_at` to the given timestamp (defaults to "now") for
the supplied monitor. Used by the measurement ingest endpoint as
effectively a heartbeat — every accepted upload nudges the monitor's
last-seen marker forward.
"""
@spec touch_last_seen(BeaconMonitor.t(), DateTime.t() | nil) :: {non_neg_integer(), nil}
def touch_last_seen(%BeaconMonitor{id: id}, at \\ nil) do
at = DateTime.truncate(at || DateTime.utc_now(), :second)
Repo.update_all(
from(m in BeaconMonitor, where: m.id == ^id),
set: [last_seen_at: at]
)
end
@doc """
Returns a blank changeset for rendering the new-monitor form.
"""
@spec change_monitor(map()) :: Ecto.Changeset.t()
def change_monitor(attrs \\ %{}) do
BeaconMonitor.changeset(%BeaconMonitor{}, attrs)
end
defp generate_token do
@token_bytes
|> :crypto.strong_rand_bytes()
|> Base.url_encode64(padding: false)
end
end