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.
96 lines
2.9 KiB
Elixir
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
|