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.
This commit is contained in:
parent
c6adc989e9
commit
bd731685de
10 changed files with 771 additions and 0 deletions
|
|
@ -180,6 +180,36 @@ paths:
|
||||||
description: No content
|
description: No content
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
|
/beacon-monitor/measurements:
|
||||||
|
post:
|
||||||
|
tags: [monitors]
|
||||||
|
summary: Upload a beacon-signal-level measurement
|
||||||
|
description: |
|
||||||
|
Ingest endpoint for the `propmonitor` client. The Bearer token here
|
||||||
|
is the `BeaconMonitor` token (issued once by
|
||||||
|
`POST /me/beacon-monitors`), not a user API token. One POST per
|
||||||
|
integration window per monitor; payloads are never batched.
|
||||||
|
|
||||||
|
`signal_peak_dbfs`, `signal_avg_dbfs`, and the `snr_*` fields are
|
||||||
|
computed only over FFT frames whose in-band power exceeded
|
||||||
|
`noise_floor + 3 dB`. `signal_active_fraction` reports the duty
|
||||||
|
cycle of those frames over the window.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/BeaconMeasurementCreate" }
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Accepted and recorded. The monitor's `last_seen_at` is updated.
|
||||||
|
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||||
|
"404":
|
||||||
|
description: The supplied `beacon_id` is unknown, unapproved, or off-the-air.
|
||||||
|
"409":
|
||||||
|
description: A measurement for this monitor + beacon + measured_at already exists.
|
||||||
|
"422": { $ref: "#/components/responses/ValidationFailed" }
|
||||||
|
"429": { $ref: "#/components/responses/RateLimited" }
|
||||||
|
|
||||||
/contacts:
|
/contacts:
|
||||||
get:
|
get:
|
||||||
tags: [contacts]
|
tags: [contacts]
|
||||||
|
|
@ -661,6 +691,74 @@ components:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: "#/components/schemas/BeaconMonitor" }
|
items: { $ref: "#/components/schemas/BeaconMonitor" }
|
||||||
|
|
||||||
|
BeaconMeasurementCreate:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- beacon_id
|
||||||
|
- frequency_hz
|
||||||
|
- measured_at
|
||||||
|
- integration_s
|
||||||
|
- passband_hz
|
||||||
|
- gain_db
|
||||||
|
- noise_floor_dbfs
|
||||||
|
- signal_peak_dbfs
|
||||||
|
- signal_avg_dbfs
|
||||||
|
- snr_peak_db
|
||||||
|
- snr_avg_db
|
||||||
|
- signal_active_fraction
|
||||||
|
- propmonitor_version
|
||||||
|
properties:
|
||||||
|
beacon_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: The beacon this measurement is for.
|
||||||
|
frequency_hz:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
description: SDR tuned center frequency in Hz.
|
||||||
|
measured_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: UTC ISO-8601 timestamp at the start of the integration window.
|
||||||
|
integration_s:
|
||||||
|
type: integer
|
||||||
|
minimum: 5
|
||||||
|
maximum: 3600
|
||||||
|
description: Integration window length in seconds.
|
||||||
|
passband_hz:
|
||||||
|
type: number
|
||||||
|
exclusiveMinimum: 0
|
||||||
|
description: Bandwidth of the in-band power window.
|
||||||
|
gain_db:
|
||||||
|
type: number
|
||||||
|
minimum: -10
|
||||||
|
maximum: 80
|
||||||
|
description: SDR-reported gain at upload time.
|
||||||
|
noise_floor_dbfs:
|
||||||
|
type: number
|
||||||
|
description: Median out-of-passband bin power, dBFS.
|
||||||
|
signal_peak_dbfs:
|
||||||
|
type: number
|
||||||
|
description: Peak in-passband power across detected-on-air frames, dBFS.
|
||||||
|
signal_avg_dbfs:
|
||||||
|
type: number
|
||||||
|
description: Mean in-passband power across detected-on-air frames, dBFS.
|
||||||
|
snr_peak_db:
|
||||||
|
type: number
|
||||||
|
description: signal_peak_dbfs minus noise_floor_dbfs.
|
||||||
|
snr_avg_db:
|
||||||
|
type: number
|
||||||
|
description: signal_avg_dbfs minus noise_floor_dbfs.
|
||||||
|
signal_active_fraction:
|
||||||
|
type: number
|
||||||
|
minimum: 0
|
||||||
|
maximum: 1
|
||||||
|
description: Fraction of FFT frames where in-band power exceeded noise_floor + 3 dB.
|
||||||
|
propmonitor_version:
|
||||||
|
type: string
|
||||||
|
maxLength: 32
|
||||||
|
description: Build version of the reporting client (diagnostic).
|
||||||
|
|
||||||
ProfileResponse:
|
ProfileResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|
|
||||||
109
lib/microwaveprop/beacon_measurements.ex
Normal file
109
lib/microwaveprop/beacon_measurements.ex
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
defmodule Microwaveprop.BeaconMeasurements do
|
||||||
|
@moduledoc """
|
||||||
|
The BeaconMeasurements context: ingests beacon-signal-level
|
||||||
|
measurements uploaded by propmonitor clients.
|
||||||
|
|
||||||
|
See `Microwaveprop.BeaconMeasurements.BeaconMeasurement` for the
|
||||||
|
schema and field semantics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Microwaveprop.BeaconMeasurements.BeaconMeasurement
|
||||||
|
alias Microwaveprop.BeaconMonitors.BeaconMonitor
|
||||||
|
alias Microwaveprop.Repo
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Inserts a measurement for the given monitor. `attrs` is expected to
|
||||||
|
carry the wire fields from `POST /api/v1/beacon-monitor/measurements`
|
||||||
|
(including `beacon_id` as a UUID string).
|
||||||
|
|
||||||
|
Returns `{:error, :beacon_not_found}` if the beacon UUID does not
|
||||||
|
resolve to an approved on-the-air beacon — the propmonitor client
|
||||||
|
drops the measurement on this, so we keep the differentiation from
|
||||||
|
generic 422s.
|
||||||
|
"""
|
||||||
|
@spec create_measurement(BeaconMonitor.t(), map()) ::
|
||||||
|
{:ok, BeaconMeasurement.t()}
|
||||||
|
| {:error, :beacon_not_found}
|
||||||
|
| {:error, Ecto.Changeset.t()}
|
||||||
|
def create_measurement(%BeaconMonitor{id: monitor_id}, attrs) when is_map(attrs) do
|
||||||
|
case lookup_beacon(attrs) do
|
||||||
|
:not_found ->
|
||||||
|
{:error, :beacon_not_found}
|
||||||
|
|
||||||
|
{:ok, beacon_id} ->
|
||||||
|
attrs =
|
||||||
|
attrs
|
||||||
|
|> normalize_attrs()
|
||||||
|
|> Map.put("monitor_id", monitor_id)
|
||||||
|
|> Map.put("beacon_id", beacon_id)
|
||||||
|
|
||||||
|
%BeaconMeasurement{}
|
||||||
|
|> BeaconMeasurement.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp lookup_beacon(attrs) do
|
||||||
|
case Map.get(attrs, "beacon_id") || Map.get(attrs, :beacon_id) do
|
||||||
|
id when is_binary(id) and byte_size(id) > 0 ->
|
||||||
|
# Only let monitors report for approved on-the-air beacons. The
|
||||||
|
# client drops 404s without retry, so an admin un-approving a
|
||||||
|
# beacon cleanly stops the monitor pointing at it.
|
||||||
|
query =
|
||||||
|
from b in Microwaveprop.Beacons.Beacon,
|
||||||
|
where: b.id == ^id and b.approved == true and b.on_the_air == true,
|
||||||
|
select: b.id
|
||||||
|
|
||||||
|
case safe_one(query) do
|
||||||
|
nil -> :not_found
|
||||||
|
uuid -> {:ok, uuid}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:not_found
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp safe_one(query) do
|
||||||
|
Repo.one(query)
|
||||||
|
rescue
|
||||||
|
Ecto.Query.CastError -> nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# The wire payload uses string keys (Phoenix JSON params). Make sure
|
||||||
|
# we don't drop a value because some intermediate handed us atom keys.
|
||||||
|
defp normalize_attrs(attrs) do
|
||||||
|
Map.new(attrs, fn
|
||||||
|
{k, v} when is_atom(k) -> {Atom.to_string(k), v}
|
||||||
|
{k, v} -> {k, v}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the most recent measurements for the given beacon, newest first.
|
||||||
|
"""
|
||||||
|
@spec list_recent_for_beacon(Ecto.UUID.t(), pos_integer()) :: [BeaconMeasurement.t()]
|
||||||
|
def list_recent_for_beacon(beacon_id, limit \\ 100) do
|
||||||
|
Repo.all(
|
||||||
|
from m in BeaconMeasurement,
|
||||||
|
where: m.beacon_id == ^beacon_id,
|
||||||
|
order_by: [desc: m.measured_at],
|
||||||
|
limit: ^limit
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the most recent measurements for the given monitor, newest first.
|
||||||
|
"""
|
||||||
|
@spec list_recent_for_monitor(Ecto.UUID.t(), pos_integer()) :: [BeaconMeasurement.t()]
|
||||||
|
def list_recent_for_monitor(monitor_id, limit \\ 100) do
|
||||||
|
Repo.all(
|
||||||
|
from m in BeaconMeasurement,
|
||||||
|
where: m.monitor_id == ^monitor_id,
|
||||||
|
order_by: [desc: m.measured_at],
|
||||||
|
limit: ^limit
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
76
lib/microwaveprop/beacon_measurements/beacon_measurement.ex
Normal file
76
lib/microwaveprop/beacon_measurements/beacon_measurement.ex
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
defmodule Microwaveprop.BeaconMeasurements.BeaconMeasurement do
|
||||||
|
@moduledoc """
|
||||||
|
A single beacon-signal-level measurement uploaded by a propmonitor
|
||||||
|
client. One row per integration window per (monitor, beacon).
|
||||||
|
|
||||||
|
All `*_dbfs` fields are uncalibrated and relative to the SDR ADC
|
||||||
|
full-scale; `gain_db` is the SDR's reported gain at upload time and
|
||||||
|
is needed to interpret dBFS trends across gain changes. SNR is
|
||||||
|
already gain-independent so it is the preferred calibration input.
|
||||||
|
|
||||||
|
`signal_*_dbfs` and `snr_*_db` are computed only over FFT frames
|
||||||
|
whose in-band power exceeded `noise_floor + 3 dB`; `signal_active_fraction`
|
||||||
|
reports the duty cycle of those frames. When `signal_active_fraction
|
||||||
|
== 0.0`, no signal was heard and the peak/avg fields sit near the
|
||||||
|
noise floor (SNR ≈ 0 dB).
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Schema
|
||||||
|
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
alias Microwaveprop.BeaconMonitors.BeaconMonitor
|
||||||
|
alias Microwaveprop.Beacons.Beacon
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
schema "beacon_measurements" do
|
||||||
|
belongs_to :beacon, Beacon
|
||||||
|
belongs_to :monitor, BeaconMonitor
|
||||||
|
|
||||||
|
field :measured_at, :utc_datetime
|
||||||
|
field :frequency_hz, :integer
|
||||||
|
field :integration_s, :integer
|
||||||
|
field :passband_hz, :float
|
||||||
|
field :gain_db, :float
|
||||||
|
|
||||||
|
field :noise_floor_dbfs, :float
|
||||||
|
field :signal_peak_dbfs, :float
|
||||||
|
field :signal_avg_dbfs, :float
|
||||||
|
field :snr_peak_db, :float
|
||||||
|
field :snr_avg_db, :float
|
||||||
|
field :signal_active_fraction, :float
|
||||||
|
|
||||||
|
field :propmonitor_version, :string
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
|
@cast_fields ~w(beacon_id monitor_id measured_at frequency_hz integration_s
|
||||||
|
passband_hz gain_db noise_floor_dbfs signal_peak_dbfs
|
||||||
|
signal_avg_dbfs snr_peak_db snr_avg_db
|
||||||
|
signal_active_fraction propmonitor_version)a
|
||||||
|
|
||||||
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
|
def changeset(measurement, attrs) do
|
||||||
|
measurement
|
||||||
|
|> cast(attrs, @cast_fields)
|
||||||
|
|> validate_required(@cast_fields)
|
||||||
|
|> validate_number(:frequency_hz, greater_than: 0)
|
||||||
|
|> validate_number(:integration_s, greater_than_or_equal_to: 5, less_than_or_equal_to: 3600)
|
||||||
|
|> validate_number(:passband_hz, greater_than: 0.0)
|
||||||
|
|> validate_number(:gain_db, greater_than_or_equal_to: -10.0, less_than_or_equal_to: 80.0)
|
||||||
|
|> validate_number(:signal_active_fraction,
|
||||||
|
greater_than_or_equal_to: 0.0,
|
||||||
|
less_than_or_equal_to: 1.0
|
||||||
|
)
|
||||||
|
|> validate_length(:propmonitor_version, max: 32)
|
||||||
|
|> foreign_key_constraint(:beacon_id)
|
||||||
|
|> foreign_key_constraint(:monitor_id)
|
||||||
|
|> unique_constraint([:monitor_id, :beacon_id, :measured_at],
|
||||||
|
name: :beacon_measurements_monitor_beacon_measured_at_idx
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -64,6 +64,22 @@ defmodule Microwaveprop.BeaconMonitors do
|
||||||
Repo.get_by(BeaconMonitor, token: token)
|
Repo.get_by(BeaconMonitor, token: token)
|
||||||
end
|
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 """
|
@doc """
|
||||||
Returns a blank changeset for rendering the new-monitor form.
|
Returns a blank changeset for rendering the new-monitor form.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
71
lib/microwaveprop_web/api/monitor_auth.ex
Normal file
71
lib/microwaveprop_web/api/monitor_auth.ex
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
defmodule MicrowavepropWeb.Api.MonitorAuth do
|
||||||
|
@moduledoc """
|
||||||
|
Bearer-token authentication for propmonitor uploads.
|
||||||
|
|
||||||
|
Resolves `Authorization: Bearer <token>` against
|
||||||
|
`Microwaveprop.BeaconMonitors.get_monitor_by_token/1`, separate from
|
||||||
|
the user-API-token plug at `MicrowavepropWeb.Api.Auth`. Monitor tokens
|
||||||
|
are issued via `POST /api/v1/me/beacon-monitors` and identify a
|
||||||
|
monitor station, not a user session.
|
||||||
|
|
||||||
|
On success, assigns:
|
||||||
|
|
||||||
|
* `:current_monitor` — the `BeaconMonitor` struct
|
||||||
|
* `:current_api_token` — `{:monitor, monitor_id}` so the
|
||||||
|
rate-limiter buckets the monitor by id rather than IP.
|
||||||
|
|
||||||
|
On failure, halts with a 401 `application/problem+json` response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@behaviour Plug
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
|
||||||
|
alias Microwaveprop.BeaconMonitors
|
||||||
|
alias MicrowavepropWeb.Api.ErrorJSON
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(opts), do: opts
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def call(conn, _opts) do
|
||||||
|
case extract_bearer(conn) do
|
||||||
|
{:ok, token} ->
|
||||||
|
case BeaconMonitors.get_monitor_by_token(token) do
|
||||||
|
nil -> halt_unauthorized(conn, "Bearer token is invalid, expired, or revoked.")
|
||||||
|
monitor -> assign_monitor(conn, monitor)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, :missing_token} ->
|
||||||
|
halt_unauthorized(conn, "Missing bearer token in Authorization header.")
|
||||||
|
|
||||||
|
{:error, :invalid_authorization_header} ->
|
||||||
|
halt_unauthorized(conn, "Authorization header must be `Bearer <token>`.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_bearer(conn) do
|
||||||
|
case get_req_header(conn, "authorization") do
|
||||||
|
["Bearer " <> token] when byte_size(token) > 0 -> {:ok, token}
|
||||||
|
["bearer " <> token] when byte_size(token) > 0 -> {:ok, token}
|
||||||
|
[] -> {:error, :missing_token}
|
||||||
|
_ -> {:error, :invalid_authorization_header}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assign_monitor(conn, monitor) do
|
||||||
|
# The rate-limiter buckets on `current_api_token.id`. Map the
|
||||||
|
# monitor onto that shape so retries from one monitor count
|
||||||
|
# against a per-monitor bucket, not the shared anon-IP bucket
|
||||||
|
# (which would punish co-located stations behind one NAT).
|
||||||
|
conn
|
||||||
|
|> assign(:current_monitor, monitor)
|
||||||
|
|> assign(:current_api_token, %{id: {:monitor, monitor.id}})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp halt_unauthorized(conn, detail) do
|
||||||
|
conn
|
||||||
|
|> put_resp_header("www-authenticate", ~s(Bearer realm="api"))
|
||||||
|
|> ErrorJSON.send_problem(401, "unauthorized", detail)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
defmodule MicrowavepropWeb.Api.V1.BeaconMonitorMeasurementController do
|
||||||
|
@moduledoc """
|
||||||
|
Ingest endpoint for `propmonitor` clients.
|
||||||
|
|
||||||
|
Authenticated by `MicrowavepropWeb.Api.MonitorAuth`: the Bearer token
|
||||||
|
resolves to a `BeaconMonitor`, and the JSON body carries the
|
||||||
|
`beacon_id` plus signal/noise/SNR fields for one integration window.
|
||||||
|
|
||||||
|
Success returns `204 No Content`. Failures match the propmonitor
|
||||||
|
status-classification table in `output.md`:
|
||||||
|
|
||||||
|
* `401` — missing/invalid monitor token (auth plug handles this)
|
||||||
|
* `404` — unknown / unapproved / off-air `beacon_id`
|
||||||
|
* `422` — malformed body
|
||||||
|
* `409` — duplicate `(monitor, beacon, measured_at)` (retried POST)
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.Controller, formats: [:json]
|
||||||
|
|
||||||
|
alias Microwaveprop.BeaconMeasurements
|
||||||
|
alias Microwaveprop.BeaconMonitors
|
||||||
|
alias MicrowavepropWeb.Api.ErrorJSON
|
||||||
|
|
||||||
|
plug :accepts, ["json"]
|
||||||
|
|
||||||
|
@wire_fields ~w(beacon_id frequency_hz measured_at integration_s passband_hz
|
||||||
|
gain_db noise_floor_dbfs signal_peak_dbfs signal_avg_dbfs
|
||||||
|
snr_peak_db snr_avg_db signal_active_fraction
|
||||||
|
propmonitor_version)
|
||||||
|
|
||||||
|
def create(conn, params) do
|
||||||
|
monitor = conn.assigns.current_monitor
|
||||||
|
attrs = Map.take(params, @wire_fields)
|
||||||
|
|
||||||
|
case BeaconMeasurements.create_measurement(monitor, attrs) do
|
||||||
|
{:ok, _measurement} ->
|
||||||
|
# Heartbeat: every accepted POST nudges last_seen_at forward.
|
||||||
|
# propmonitor doesn't send a separate keepalive.
|
||||||
|
_ = BeaconMonitors.touch_last_seen(monitor)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> send_resp(204, "")
|
||||||
|
|> halt()
|
||||||
|
|
||||||
|
{:error, :beacon_not_found} ->
|
||||||
|
ErrorJSON.send_problem(
|
||||||
|
conn,
|
||||||
|
404,
|
||||||
|
"not_found",
|
||||||
|
"beacon_id is unknown, unapproved, or marked off-the-air."
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
|
if duplicate?(changeset) do
|
||||||
|
ErrorJSON.send_problem(
|
||||||
|
conn,
|
||||||
|
409,
|
||||||
|
"conflict",
|
||||||
|
"A measurement for this monitor, beacon, and measured_at already exists."
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ErrorJSON.send_changeset(conn, changeset)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp duplicate?(%Ecto.Changeset{errors: errors}) do
|
||||||
|
Enum.any?(errors, fn
|
||||||
|
{_field, {_msg, opts}} -> Keyword.get(opts, :constraint) == :unique
|
||||||
|
_ -> false
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -6,6 +6,7 @@ defmodule MicrowavepropWeb.Router do
|
||||||
import Phoenix.LiveDashboard.Router
|
import Phoenix.LiveDashboard.Router
|
||||||
|
|
||||||
alias MicrowavepropWeb.Api.Auth
|
alias MicrowavepropWeb.Api.Auth
|
||||||
|
alias MicrowavepropWeb.Api.MonitorAuth
|
||||||
alias MicrowavepropWeb.Api.RateLimiter
|
alias MicrowavepropWeb.Api.RateLimiter
|
||||||
alias MicrowavepropWeb.Api.V1
|
alias MicrowavepropWeb.Api.V1
|
||||||
|
|
||||||
|
|
@ -161,6 +162,16 @@ defmodule MicrowavepropWeb.Router do
|
||||||
plug RateLimiter, anon_limit: 30
|
plug RateLimiter, anon_limit: 30
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# propmonitor beacon-measurement uploads. Bearer token resolves to a
|
||||||
|
# `BeaconMonitor` rather than a user. One POST per integration window
|
||||||
|
# (default 60 s); allow a comfortable burst headroom for retry storms
|
||||||
|
# after a 5xx — well above the steady-state 1/min/monitor rate.
|
||||||
|
pipeline :api_v1_monitor do
|
||||||
|
plug :accepts, ["json"]
|
||||||
|
plug MonitorAuth
|
||||||
|
plug RateLimiter, auth_limit: 60
|
||||||
|
end
|
||||||
|
|
||||||
# Health checks — no pipeline, minimal overhead.
|
# Health checks — no pipeline, minimal overhead.
|
||||||
# /live = liveness, BEAM only. /health = readiness, also pings Repo.
|
# /live = liveness, BEAM only. /health = readiness, also pings Repo.
|
||||||
# Pods that serve a BEAM-alive /live but a failing /health get taken
|
# Pods that serve a BEAM-alive /live but a failing /health get taken
|
||||||
|
|
@ -297,6 +308,14 @@ defmodule MicrowavepropWeb.Router do
|
||||||
post "/beacons", BeaconController, :create
|
post "/beacons", BeaconController, :create
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# propmonitor uploads. Separate pipeline because the Bearer here is a
|
||||||
|
# `BeaconMonitor` token, not a user API token.
|
||||||
|
scope "/api/v1", V1 do
|
||||||
|
pipe_through :api_v1_monitor
|
||||||
|
|
||||||
|
post "/beacon-monitor/measurements", BeaconMonitorMeasurementController, :create
|
||||||
|
end
|
||||||
|
|
||||||
scope "/" do
|
scope "/" do
|
||||||
pipe_through [:browser, :require_authenticated_user]
|
pipe_through [:browser, :require_authenticated_user]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
defmodule Microwaveprop.Repo.Migrations.CreateBeaconMeasurements do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:beacon_measurements, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
|
||||||
|
add :beacon_id, references(:beacons, type: :binary_id, on_delete: :delete_all), null: false
|
||||||
|
|
||||||
|
add :monitor_id, references(:beacon_monitors, type: :binary_id, on_delete: :delete_all),
|
||||||
|
null: false
|
||||||
|
|
||||||
|
add :measured_at, :utc_datetime, null: false
|
||||||
|
add :frequency_hz, :bigint, null: false
|
||||||
|
add :integration_s, :integer, null: false
|
||||||
|
add :passband_hz, :float, null: false
|
||||||
|
add :gain_db, :float, null: false
|
||||||
|
|
||||||
|
add :noise_floor_dbfs, :float, null: false
|
||||||
|
add :signal_peak_dbfs, :float, null: false
|
||||||
|
add :signal_avg_dbfs, :float, null: false
|
||||||
|
add :snr_peak_db, :float, null: false
|
||||||
|
add :snr_avg_db, :float, null: false
|
||||||
|
add :signal_active_fraction, :float, null: false
|
||||||
|
|
||||||
|
add :propmonitor_version, :string, null: false
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Time-series query patterns: latest-N for one monitor, latest-N for one
|
||||||
|
# beacon, and joint monitor+beacon trends.
|
||||||
|
create index(:beacon_measurements, [:monitor_id, "measured_at DESC"])
|
||||||
|
create index(:beacon_measurements, [:beacon_id, "measured_at DESC"])
|
||||||
|
|
||||||
|
# Idempotency: the client retries on 5xx with the same `measured_at`. A
|
||||||
|
# given monitor cannot legitimately produce two distinct measurements for
|
||||||
|
# the same beacon at the same start-of-window timestamp.
|
||||||
|
create unique_index(:beacon_measurements, [:monitor_id, :beacon_id, :measured_at],
|
||||||
|
name: :beacon_measurements_monitor_beacon_measured_at_idx
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
139
test/microwaveprop/beacon_measurements_test.exs
Normal file
139
test/microwaveprop/beacon_measurements_test.exs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
defmodule Microwaveprop.BeaconMeasurementsTest do
|
||||||
|
use Microwaveprop.DataCase
|
||||||
|
|
||||||
|
import Microwaveprop.AccountsFixtures
|
||||||
|
import Microwaveprop.BeaconsFixtures
|
||||||
|
|
||||||
|
alias Microwaveprop.BeaconMeasurements
|
||||||
|
alias Microwaveprop.BeaconMeasurements.BeaconMeasurement
|
||||||
|
alias Microwaveprop.BeaconMonitors
|
||||||
|
alias Microwaveprop.Beacons
|
||||||
|
|
||||||
|
defp setup_actors(_) do
|
||||||
|
user = user_fixture()
|
||||||
|
{:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Shack Pi"})
|
||||||
|
|
||||||
|
beacon = beacon_fixture(user)
|
||||||
|
{:ok, beacon} = Beacons.approve_beacon(beacon)
|
||||||
|
|
||||||
|
%{user: user, monitor: monitor, beacon: beacon}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp wire_attrs(beacon, overrides \\ %{}) do
|
||||||
|
Map.merge(
|
||||||
|
%{
|
||||||
|
"beacon_id" => beacon.id,
|
||||||
|
"frequency_hz" => 28_330_000,
|
||||||
|
"measured_at" => "2026-05-13T15:30:00Z",
|
||||||
|
"integration_s" => 60,
|
||||||
|
"passband_hz" => 300.0,
|
||||||
|
"gain_db" => 10.0,
|
||||||
|
"noise_floor_dbfs" => -110.2,
|
||||||
|
"signal_peak_dbfs" => -88.4,
|
||||||
|
"signal_avg_dbfs" => -89.1,
|
||||||
|
"snr_peak_db" => 21.8,
|
||||||
|
"snr_avg_db" => 21.1,
|
||||||
|
"signal_active_fraction" => 0.48,
|
||||||
|
"propmonitor_version" => "0.1.0"
|
||||||
|
},
|
||||||
|
overrides
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "create_measurement/2" do
|
||||||
|
setup :setup_actors
|
||||||
|
|
||||||
|
test "inserts a measurement for an approved on-air beacon", %{monitor: m, beacon: b} do
|
||||||
|
assert {:ok, %BeaconMeasurement{} = meas} =
|
||||||
|
BeaconMeasurements.create_measurement(m, wire_attrs(b))
|
||||||
|
|
||||||
|
assert meas.monitor_id == m.id
|
||||||
|
assert meas.beacon_id == b.id
|
||||||
|
assert meas.frequency_hz == 28_330_000
|
||||||
|
assert meas.signal_active_fraction == 0.48
|
||||||
|
assert meas.propmonitor_version == "0.1.0"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :beacon_not_found for an unknown beacon UUID", %{monitor: m} do
|
||||||
|
attrs = wire_attrs(%{id: "00000000-0000-0000-0000-000000000000"})
|
||||||
|
|
||||||
|
assert {:error, :beacon_not_found} =
|
||||||
|
BeaconMeasurements.create_measurement(m, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :beacon_not_found for a malformed beacon UUID", %{monitor: m, beacon: b} do
|
||||||
|
attrs = b |> wire_attrs() |> Map.put("beacon_id", "not-a-uuid")
|
||||||
|
assert {:error, :beacon_not_found} = BeaconMeasurements.create_measurement(m, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :beacon_not_found for an unapproved beacon", %{monitor: m, user: u} do
|
||||||
|
pending = beacon_fixture(u, callsign: "K5HOLD")
|
||||||
|
attrs = wire_attrs(pending)
|
||||||
|
|
||||||
|
assert {:error, :beacon_not_found} =
|
||||||
|
BeaconMeasurements.create_measurement(m, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :beacon_not_found for a beacon marked off-the-air", %{monitor: m, beacon: b} do
|
||||||
|
{:ok, _} = Beacons.update_beacon(b, %{"on_the_air" => false})
|
||||||
|
assert {:error, :beacon_not_found} = BeaconMeasurements.create_measurement(m, wire_attrs(b))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects out-of-range signal_active_fraction", %{monitor: m, beacon: b} do
|
||||||
|
attrs = wire_attrs(b, %{"signal_active_fraction" => 1.5})
|
||||||
|
assert {:error, changeset} = BeaconMeasurements.create_measurement(m, attrs)
|
||||||
|
assert %{signal_active_fraction: [_ | _]} = errors_on(changeset)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects integration_s below the floor", %{monitor: m, beacon: b} do
|
||||||
|
attrs = wire_attrs(b, %{"integration_s" => 1})
|
||||||
|
assert {:error, changeset} = BeaconMeasurements.create_measurement(m, attrs)
|
||||||
|
assert %{integration_s: [_ | _]} = errors_on(changeset)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects missing required fields", %{monitor: m, beacon: b} do
|
||||||
|
attrs = Map.delete(wire_attrs(b), "noise_floor_dbfs")
|
||||||
|
assert {:error, changeset} = BeaconMeasurements.create_measurement(m, attrs)
|
||||||
|
assert %{noise_floor_dbfs: ["can't be blank"]} = errors_on(changeset)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "duplicate (monitor, beacon, measured_at) returns a unique-constraint error",
|
||||||
|
%{monitor: m, beacon: b} do
|
||||||
|
attrs = wire_attrs(b)
|
||||||
|
assert {:ok, _} = BeaconMeasurements.create_measurement(m, attrs)
|
||||||
|
|
||||||
|
assert {:error, %Ecto.Changeset{} = changeset} =
|
||||||
|
BeaconMeasurements.create_measurement(m, attrs)
|
||||||
|
|
||||||
|
assert Enum.any?(changeset.errors, fn {_, {_, opts}} ->
|
||||||
|
Keyword.get(opts, :constraint) == :unique
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "two monitors can report for the same beacon at the same instant",
|
||||||
|
%{monitor: m, beacon: b, user: u} do
|
||||||
|
{:ok, m2} = BeaconMonitors.create_monitor(u, %{"name" => "Other Pi"})
|
||||||
|
attrs = wire_attrs(b)
|
||||||
|
assert {:ok, _} = BeaconMeasurements.create_measurement(m, attrs)
|
||||||
|
assert {:ok, _} = BeaconMeasurements.create_measurement(m2, attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "list_recent_for_beacon/2 and list_recent_for_monitor/2" do
|
||||||
|
setup :setup_actors
|
||||||
|
|
||||||
|
test "returns measurements newest first", %{monitor: m, beacon: b} do
|
||||||
|
a1 = wire_attrs(b, %{"measured_at" => "2026-05-13T15:30:00Z"})
|
||||||
|
a2 = wire_attrs(b, %{"measured_at" => "2026-05-13T15:31:00Z"})
|
||||||
|
|
||||||
|
{:ok, _} = BeaconMeasurements.create_measurement(m, a1)
|
||||||
|
{:ok, _} = BeaconMeasurements.create_measurement(m, a2)
|
||||||
|
|
||||||
|
[latest, prev] = BeaconMeasurements.list_recent_for_beacon(b.id, 10)
|
||||||
|
assert latest.measured_at > prev.measured_at
|
||||||
|
|
||||||
|
[latest_m, _] = BeaconMeasurements.list_recent_for_monitor(m.id, 10)
|
||||||
|
assert latest_m.measured_at == latest.measured_at
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
defmodule MicrowavepropWeb.Api.V1.BeaconMonitorMeasurementControllerTest do
|
||||||
|
use MicrowavepropWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
import Microwaveprop.AccountsFixtures
|
||||||
|
import Microwaveprop.BeaconsFixtures
|
||||||
|
|
||||||
|
alias Microwaveprop.BeaconMeasurements
|
||||||
|
alias Microwaveprop.BeaconMonitors
|
||||||
|
alias Microwaveprop.Beacons
|
||||||
|
alias Microwaveprop.Repo
|
||||||
|
alias MicrowavepropWeb.Api.RateLimiter
|
||||||
|
|
||||||
|
@path "/api/v1/beacon-monitor/measurements"
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
RateLimiter.reset()
|
||||||
|
user = user_fixture()
|
||||||
|
{:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Shack Pi"})
|
||||||
|
beacon = beacon_fixture(user)
|
||||||
|
{:ok, beacon} = Beacons.approve_beacon(beacon)
|
||||||
|
%{conn: conn, user: user, monitor: monitor, beacon: beacon}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp wire_body(beacon, overrides \\ %{}) do
|
||||||
|
Map.merge(
|
||||||
|
%{
|
||||||
|
"beacon_id" => beacon.id,
|
||||||
|
"frequency_hz" => 28_330_000,
|
||||||
|
"measured_at" => "2026-05-13T15:30:00Z",
|
||||||
|
"integration_s" => 60,
|
||||||
|
"passband_hz" => 300.0,
|
||||||
|
"gain_db" => 10.0,
|
||||||
|
"noise_floor_dbfs" => -110.2,
|
||||||
|
"signal_peak_dbfs" => -88.4,
|
||||||
|
"signal_avg_dbfs" => -89.1,
|
||||||
|
"snr_peak_db" => 21.8,
|
||||||
|
"snr_avg_db" => 21.1,
|
||||||
|
"signal_active_fraction" => 0.48,
|
||||||
|
"propmonitor_version" => "0.1.0"
|
||||||
|
},
|
||||||
|
overrides
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "POST /api/v1/beacon-monitor/measurements" do
|
||||||
|
test "401 when no Authorization header is supplied", %{conn: conn, beacon: b} do
|
||||||
|
conn = post(conn, @path, wire_body(b))
|
||||||
|
assert response(conn, 401)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "401 when bearer token is unknown", %{conn: conn, beacon: b} do
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> put_req_header("authorization", "Bearer not-a-real-token")
|
||||||
|
|> post(@path, wire_body(b))
|
||||||
|
|
||||||
|
assert response(conn, 401)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "204 on a valid upload, persists row, and stamps last_seen_at",
|
||||||
|
%{conn: conn, monitor: m, beacon: b} do
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> put_req_header("authorization", "Bearer " <> m.token)
|
||||||
|
|> post(@path, wire_body(b))
|
||||||
|
|
||||||
|
assert response(conn, 204) == ""
|
||||||
|
|
||||||
|
[row] = BeaconMeasurements.list_recent_for_monitor(m.id, 10)
|
||||||
|
assert row.beacon_id == b.id
|
||||||
|
assert row.signal_active_fraction == 0.48
|
||||||
|
|
||||||
|
assert %{last_seen_at: %DateTime{}} = Repo.reload!(m)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "404 when beacon_id is unknown", %{conn: conn, monitor: m} do
|
||||||
|
body = wire_body(%{id: "00000000-0000-0000-0000-000000000000"})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> put_req_header("authorization", "Bearer " <> m.token)
|
||||||
|
|> post(@path, body)
|
||||||
|
|
||||||
|
assert json_response(conn, 404)["title"] == "not_found"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "404 when beacon is unapproved", %{conn: conn, monitor: m, user: u} do
|
||||||
|
pending = beacon_fixture(u, callsign: "K5HOLD")
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> put_req_header("authorization", "Bearer " <> m.token)
|
||||||
|
|> post(@path, wire_body(pending))
|
||||||
|
|
||||||
|
assert json_response(conn, 404)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "422 when body is missing required fields", %{conn: conn, monitor: m, beacon: b} do
|
||||||
|
body = Map.delete(wire_body(b), "noise_floor_dbfs")
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> put_req_header("authorization", "Bearer " <> m.token)
|
||||||
|
|> post(@path, body)
|
||||||
|
|
||||||
|
body_json = json_response(conn, 422)
|
||||||
|
assert body_json["title"] == "validation_failed"
|
||||||
|
assert body_json["errors"]["noise_floor_dbfs"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "409 on duplicate (monitor, beacon, measured_at) retry", %{conn: conn, monitor: m, beacon: b} do
|
||||||
|
conn1 =
|
||||||
|
build_conn()
|
||||||
|
|> put_req_header("authorization", "Bearer " <> m.token)
|
||||||
|
|> post(@path, wire_body(b))
|
||||||
|
|
||||||
|
assert response(conn1, 204)
|
||||||
|
|
||||||
|
conn2 =
|
||||||
|
conn
|
||||||
|
|> put_req_header("authorization", "Bearer " <> m.token)
|
||||||
|
|> post(@path, wire_body(b))
|
||||||
|
|
||||||
|
assert json_response(conn2, 409)["title"] == "conflict"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue