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
|
||||
"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:
|
||||
get:
|
||||
tags: [contacts]
|
||||
|
|
@ -661,6 +691,74 @@ components:
|
|||
type: array
|
||||
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:
|
||||
type: object
|
||||
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)
|
||||
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.
|
||||
"""
|
||||
|
|
|
|||
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
|
||||
|
||||
alias MicrowavepropWeb.Api.Auth
|
||||
alias MicrowavepropWeb.Api.MonitorAuth
|
||||
alias MicrowavepropWeb.Api.RateLimiter
|
||||
alias MicrowavepropWeb.Api.V1
|
||||
|
||||
|
|
@ -161,6 +162,16 @@ defmodule MicrowavepropWeb.Router do
|
|||
plug RateLimiter, anon_limit: 30
|
||||
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.
|
||||
# /live = liveness, BEAM only. /health = readiness, also pings Repo.
|
||||
# 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
|
||||
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
|
||||
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