Add commercial link monitoring via SNMP polling
Poll UBNT AirFiber radios (AF11X + AF60-LR) every 5 minutes via net-snmp CLI, storing signal metrics in commercial_samples. Fetches ASOS weather alongside each cycle for propagation correlation. Includes 7 seeded link definitions, Oban cron worker, and net-snmp in the Docker image.
This commit is contained in:
parent
8ccd778623
commit
10e8feb486
17 changed files with 3215 additions and 254 deletions
|
|
@ -71,7 +71,7 @@ RUN mix release
|
|||
FROM ${RUNNER_IMAGE} AS final
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates \
|
||||
&& apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates snmp \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the locale
|
||||
|
|
|
|||
|
|
@ -44,14 +44,15 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
|
|||
|
||||
config :microwaveprop, Oban,
|
||||
repo: Microwaveprop.Repo,
|
||||
queues: [solar: 1, weather: 3, enqueue: 1, hrrr: 20, terrain: 4],
|
||||
queues: [solar: 1, weather: 3, enqueue: 1, hrrr: 20, terrain: 4, commercial: 2],
|
||||
plugins: [
|
||||
{Oban.Plugins.Pruner, max_age: 3600 * 24},
|
||||
{Oban.Plugins.Lifeline, rescue_after: to_timeout(minute: 30)},
|
||||
{Oban.Plugins.Cron,
|
||||
crontab: [
|
||||
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
|
||||
{"0 */4 * * *", Microwaveprop.Workers.QsoWeatherEnqueueWorker}
|
||||
{"0 */4 * * *", Microwaveprop.Workers.QsoWeatherEnqueueWorker},
|
||||
{"*/5 * * * *", Microwaveprop.Commercial.PollWorker}
|
||||
]}
|
||||
]
|
||||
|
||||
|
|
|
|||
1255
docs/plans/2026-03-30-commercial-link-monitoring.md
Normal file
1255
docs/plans/2026-03-30-commercial-link-monitoring.md
Normal file
File diff suppressed because it is too large
Load diff
35
lib/microwaveprop/commercial.ex
Normal file
35
lib/microwaveprop/commercial.ex
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
defmodule Microwaveprop.Commercial do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Microwaveprop.Commercial.Link
|
||||
alias Microwaveprop.Commercial.Sample
|
||||
alias Microwaveprop.Repo
|
||||
|
||||
def enabled_links do
|
||||
Link
|
||||
|> where([l], l.enabled == true)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def create_link(attrs) do
|
||||
%Link{}
|
||||
|> Link.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def create_sample(attrs) do
|
||||
%Sample{}
|
||||
|> Sample.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def weather_stations do
|
||||
Link
|
||||
|> where([l], l.enabled == true and not is_nil(l.weather_station))
|
||||
|> select([l], l.weather_station)
|
||||
|> distinct(true)
|
||||
|> Repo.all()
|
||||
end
|
||||
end
|
||||
33
lib/microwaveprop/commercial/link.ex
Normal file
33
lib/microwaveprop/commercial/link.ex
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
defmodule Microwaveprop.Commercial.Link do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "commercial_links" do
|
||||
field :label, :string
|
||||
field :host, :string
|
||||
field :community, :string
|
||||
field :radio_type, :string, default: "af11x"
|
||||
field :endpoint_a, :map
|
||||
field :endpoint_b, :map
|
||||
field :weather_station, :string
|
||||
field :enabled, :boolean, default: true
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@required_fields ~w(label host community radio_type)a
|
||||
@optional_fields ~w(endpoint_a endpoint_b weather_station enabled)a
|
||||
|
||||
def changeset(link, attrs) do
|
||||
link
|
||||
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> validate_inclusion(:radio_type, ~w(af11x af60))
|
||||
|> unique_constraint(:label)
|
||||
end
|
||||
end
|
||||
80
lib/microwaveprop/commercial/poll_worker.ex
Normal file
80
lib/microwaveprop/commercial/poll_worker.ex
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
defmodule Microwaveprop.Commercial.PollWorker do
|
||||
@moduledoc false
|
||||
use Oban.Worker, queue: :commercial, max_attempts: 1
|
||||
|
||||
alias Microwaveprop.Commercial
|
||||
alias Microwaveprop.Commercial.SnmpClient
|
||||
alias Microwaveprop.Weather
|
||||
alias Microwaveprop.Weather.IemClient
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
links = Commercial.enabled_links()
|
||||
|
||||
poll_and_record(links, &SnmpClient.poll/3)
|
||||
fetch_weather(links)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
def poll_and_record(links, poll_fn) do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
Enum.each(links, fn link ->
|
||||
case poll_fn.(link.host, link.community, link.radio_type) do
|
||||
{:ok, data} ->
|
||||
attrs =
|
||||
data
|
||||
|> Map.put(:link_id, link.id)
|
||||
|> Map.put(:sampled_at, now)
|
||||
|
||||
case Commercial.create_sample(attrs) do
|
||||
{:ok, _sample} ->
|
||||
Logger.info("Recorded sample for #{link.label}")
|
||||
|
||||
{:error, changeset} ->
|
||||
Logger.warning("Failed to save sample for #{link.label}: #{inspect(changeset.errors)}")
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("SNMP poll failed for #{link.label} (#{link.host}): #{inspect(reason)}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp fetch_weather(links) do
|
||||
stations =
|
||||
links
|
||||
|> Enum.map(& &1.weather_station)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.uniq()
|
||||
|
||||
now = DateTime.utc_now()
|
||||
start_dt = DateTime.add(now, -3600, :second)
|
||||
|
||||
Enum.each(stations, fn station_code ->
|
||||
{:ok, station} =
|
||||
Weather.find_or_create_station(%{
|
||||
station_code: station_code,
|
||||
station_type: "asos",
|
||||
name: station_code,
|
||||
lat: 0.0,
|
||||
lon: 0.0
|
||||
})
|
||||
|
||||
case IemClient.fetch_asos(station_code, start_dt, now) do
|
||||
{:ok, rows} ->
|
||||
Enum.each(rows, fn row ->
|
||||
if row.observed_at, do: Weather.upsert_surface_observation(station, row)
|
||||
end)
|
||||
|
||||
Logger.info("Fetched #{length(rows)} ASOS observations for #{station_code}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("ASOS fetch failed for #{station_code}: #{inspect(reason)}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
54
lib/microwaveprop/commercial/sample.ex
Normal file
54
lib/microwaveprop/commercial/sample.ex
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
defmodule Microwaveprop.Commercial.Sample do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "commercial_samples" do
|
||||
belongs_to :link, Microwaveprop.Commercial.Link
|
||||
field :sampled_at, :utc_datetime_usec
|
||||
|
||||
# Local radio
|
||||
field :rx_power_0, :float
|
||||
field :rx_power_1, :float
|
||||
field :tx_power, :float
|
||||
field :tx_freq_mhz, :integer
|
||||
field :rx_freq_mhz, :integer
|
||||
field :radio_temp_0_c, :float
|
||||
field :radio_temp_1_c, :float
|
||||
|
||||
# Modulation & capacity
|
||||
field :cur_tx_mod_rate, :integer
|
||||
field :remote_tx_mod_rate, :integer
|
||||
field :rx_capacity, :integer
|
||||
field :tx_capacity, :integer
|
||||
|
||||
# Remote radio
|
||||
field :remote_tx_power, :float
|
||||
field :remote_rx_power_0, :float
|
||||
field :remote_rx_power_1, :float
|
||||
|
||||
# Link status
|
||||
field :link_state, :integer
|
||||
field :link_uptime, :integer
|
||||
field :link_distance_m, :integer
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@required_fields ~w(link_id sampled_at)a
|
||||
@optional_fields ~w(rx_power_0 rx_power_1 tx_power tx_freq_mhz rx_freq_mhz
|
||||
radio_temp_0_c radio_temp_1_c cur_tx_mod_rate remote_tx_mod_rate
|
||||
rx_capacity tx_capacity remote_tx_power remote_rx_power_0 remote_rx_power_1
|
||||
link_state link_uptime link_distance_m)a
|
||||
|
||||
def changeset(sample, attrs) do
|
||||
sample
|
||||
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> foreign_key_constraint(:link_id)
|
||||
end
|
||||
end
|
||||
198
lib/microwaveprop/commercial/snmp_client.ex
Normal file
198
lib/microwaveprop/commercial/snmp_client.ex
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
defmodule Microwaveprop.Commercial.SnmpClient do
|
||||
@moduledoc false
|
||||
|
||||
require Logger
|
||||
|
||||
# UBNT-AirFIBER-MIB OIDs (base 1.3.6.1.4.1.41112.1.3)
|
||||
@af_base "1.3.6.1.4.1.41112.1.3"
|
||||
@af11x_oids %{
|
||||
"#{@af_base}.2.1.11.1" => :rx_power_0,
|
||||
"#{@af_base}.2.1.14.1" => :rx_power_1,
|
||||
"#{@af_base}.1.1.9.1" => :tx_power,
|
||||
"#{@af_base}.2.1.2.1" => :cur_tx_mod_rate,
|
||||
"#{@af_base}.2.1.18.1" => :remote_tx_mod_rate,
|
||||
"#{@af_base}.2.1.5.1" => :rx_capacity,
|
||||
"#{@af_base}.2.1.6.1" => :tx_capacity,
|
||||
"#{@af_base}.2.1.17.1" => :remote_tx_power,
|
||||
"#{@af_base}.2.1.19.1" => :remote_rx_power_0,
|
||||
"#{@af_base}.2.1.22.1" => :remote_rx_power_1,
|
||||
"#{@af_base}.2.1.26.1" => :link_state,
|
||||
"#{@af_base}.2.1.44.1" => :link_uptime,
|
||||
"#{@af_base}.2.1.8.1" => :radio_temp_0_c,
|
||||
"#{@af_base}.2.1.10.1" => :radio_temp_1_c,
|
||||
"#{@af_base}.1.1.5.1" => :tx_freq_mhz,
|
||||
"#{@af_base}.1.1.6.1" => :rx_freq_mhz,
|
||||
"#{@af_base}.2.1.4.1" => :link_distance_m
|
||||
}
|
||||
|
||||
# AF60-LR static OIDs
|
||||
@af60_base "1.3.6.1.4.1.41112.1.11.1"
|
||||
@af60_static_oids %{
|
||||
"#{@af60_base}.2.5.1" => :radio_temp_0_c,
|
||||
"#{@af60_base}.2.6.1" => :link_state,
|
||||
"#{@af60_base}.1.2.1" => :tx_freq_mhz
|
||||
}
|
||||
|
||||
# AF60 station table column -> field name + unit conversion
|
||||
@af60_station_cols %{
|
||||
3 => {:rx_power_0, :raw},
|
||||
4 => {:tx_power, :raw},
|
||||
5 => {:cur_tx_mod_rate, :raw},
|
||||
6 => {:remote_tx_mod_rate, :raw},
|
||||
7 => {:tx_capacity, :kbps_to_mbps},
|
||||
8 => {:rx_capacity, :kbps_to_mbps},
|
||||
15 => {:link_distance_m, :raw},
|
||||
17 => {:link_uptime, :centiseconds_to_seconds},
|
||||
18 => {:remote_rx_power_0, :raw},
|
||||
19 => {:remote_tx_power, :raw}
|
||||
}
|
||||
|
||||
@snmp_timeout 5_000
|
||||
|
||||
def poll(host, community, "af11x"), do: poll_af11x(host, community)
|
||||
def poll(host, community, "af60"), do: poll_af60(host, community)
|
||||
|
||||
def poll_af11x(host, community) do
|
||||
oids = Map.keys(@af11x_oids)
|
||||
args = ["-v2c", "-c", community, "-t", "5", "-r", "1", host | oids]
|
||||
|
||||
case run_cmd("snmpget", args) do
|
||||
{output, 0} ->
|
||||
result = parse_snmpget_output(output, :af11x)
|
||||
if map_size(result) > 0, do: {:ok, result}, else: {:error, :empty_response}
|
||||
|
||||
{output, _exit_code} ->
|
||||
Logger.warning("snmpget failed for #{host}: #{String.trim(output)}")
|
||||
{:error, :snmp_failed}
|
||||
end
|
||||
end
|
||||
|
||||
def poll_af60(host, community) do
|
||||
static_oids = Map.keys(@af60_static_oids)
|
||||
static_args = ["-v2c", "-c", community, "-t", "5", "-r", "1", host | static_oids]
|
||||
|
||||
station_oids = Enum.map(Map.keys(@af60_station_cols), &"#{@af60_base}.3.1.#{&1}")
|
||||
station_args = ["-v2c", "-c", community, "-t", "5", "-r", "1", host | station_oids]
|
||||
|
||||
with {static_out, 0} <- run_cmd("snmpget", static_args),
|
||||
{station_out, 0} <- run_cmd("snmpgetnext", station_args) do
|
||||
result = parse_af60_output(static_out, station_out)
|
||||
if map_size(result) > 0, do: {:ok, result}, else: {:error, :empty_response}
|
||||
else
|
||||
{output, _} ->
|
||||
Logger.warning("snmp failed for AF60 #{host}: #{String.trim(output)}")
|
||||
{:error, :snmp_failed}
|
||||
end
|
||||
end
|
||||
|
||||
def parse_snmpget_output(output, :af11x) do
|
||||
output
|
||||
|> parse_lines()
|
||||
|> Enum.reduce(%{}, fn {oid, value}, acc ->
|
||||
case find_af11x_field(oid) do
|
||||
nil -> acc
|
||||
field -> Map.put(acc, field, value)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def parse_af60_output(static_output, station_output) do
|
||||
static =
|
||||
static_output
|
||||
|> parse_lines()
|
||||
|> Enum.reduce(%{}, fn {oid, value}, acc ->
|
||||
case find_af60_static_field(oid) do
|
||||
nil -> acc
|
||||
field -> Map.put(acc, field, value)
|
||||
end
|
||||
end)
|
||||
|
||||
station =
|
||||
station_output
|
||||
|> parse_lines()
|
||||
|> Enum.reduce(%{}, fn {oid, value}, acc ->
|
||||
case find_af60_station_field(oid) do
|
||||
nil -> acc
|
||||
{field, conversion} -> Map.put(acc, field, convert_value(value, conversion))
|
||||
end
|
||||
end)
|
||||
|
||||
Map.merge(static, station)
|
||||
end
|
||||
|
||||
# --- Private ---
|
||||
|
||||
defp parse_lines(output) do
|
||||
output
|
||||
|> String.split("\n", trim: true)
|
||||
|> Enum.flat_map(fn line ->
|
||||
case parse_snmp_line(line) do
|
||||
{_oid, _value} = pair -> [pair]
|
||||
nil -> []
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp run_cmd(cmd, args) do
|
||||
System.cmd(cmd, args, stderr_to_stdout: true, timeout: @snmp_timeout)
|
||||
rescue
|
||||
ErlangError -> {"command not found: #{cmd}", 127}
|
||||
end
|
||||
|
||||
defp parse_snmp_line(line) do
|
||||
line = String.trim(line)
|
||||
|
||||
with [oid_part, value_part] <- String.split(line, " = ", parts: 2),
|
||||
false <- String.contains?(value_part, "No Such"),
|
||||
oid = oid_part |> String.trim() |> normalize_oid(),
|
||||
value when not is_nil(value) <- parse_snmp_value(value_part) do
|
||||
{oid, value}
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_oid(oid) do
|
||||
oid
|
||||
|> String.replace(~r/^iso\./, "1.")
|
||||
|> String.replace(~r/^\./, "")
|
||||
end
|
||||
|
||||
defp parse_snmp_value(value_str) do
|
||||
case Regex.run(~r/:\s*(.+)$/, String.trim(value_str)) do
|
||||
[_, raw] ->
|
||||
raw = String.trim(raw, "\"")
|
||||
|
||||
case Integer.parse(raw) do
|
||||
{val, _} -> val
|
||||
:error -> nil
|
||||
end
|
||||
|
||||
nil ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp find_af11x_field(oid) do
|
||||
Enum.find_value(@af11x_oids, fn {known_oid, field} ->
|
||||
if oid == known_oid, do: field
|
||||
end)
|
||||
end
|
||||
|
||||
defp find_af60_static_field(oid) do
|
||||
Enum.find_value(@af60_static_oids, fn {known_oid, field} ->
|
||||
if oid == known_oid, do: field
|
||||
end)
|
||||
end
|
||||
|
||||
defp find_af60_station_field(oid) do
|
||||
Enum.find_value(@af60_station_cols, fn {col, field_and_conv} ->
|
||||
prefix = "#{@af60_base}.3.1.#{col}."
|
||||
if String.starts_with?(oid, prefix), do: field_and_conv
|
||||
end)
|
||||
end
|
||||
|
||||
defp convert_value(value, :raw), do: value
|
||||
defp convert_value(value, :kbps_to_mbps), do: div(value, 1000)
|
||||
defp convert_value(value, :centiseconds_to_seconds), do: div(value, 100)
|
||||
end
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
defmodule Microwaveprop.Repo.Migrations.CreateCommercialLinks do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:commercial_links, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :label, :string, null: false
|
||||
add :host, :string, null: false
|
||||
add :community, :string, null: false
|
||||
add :radio_type, :string, null: false, default: "af11x"
|
||||
add :endpoint_a, :map
|
||||
add :endpoint_b, :map
|
||||
add :weather_station, :string
|
||||
add :enabled, :boolean, null: false, default: true
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:commercial_links, [:label])
|
||||
create index(:commercial_links, [:enabled])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
defmodule Microwaveprop.Repo.Migrations.SeedCommercialLinks do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_naive()
|
||||
|
||||
links = [
|
||||
%{
|
||||
label: "verona-to-climax",
|
||||
host: "10.250.1.26",
|
||||
community: "kdyyJrT0Mm",
|
||||
radio_type: "af11x",
|
||||
endpoint_a: ~s|{"lat":33.246171,"lon":-96.426516,"alt_m":205.4}|,
|
||||
endpoint_b: ~s|{"lat":33.187294,"lon":-96.448128,"alt_m":191.0}|,
|
||||
weather_station: "KTKI"
|
||||
},
|
||||
%{
|
||||
label: "new-hope-to-core",
|
||||
host: "10.250.1.58",
|
||||
community: "kdyyJrT0Mm",
|
||||
radio_type: "af11x",
|
||||
endpoint_a: ~s|{"lat":33.207279,"lon":-96.537762,"alt_m":216.4}|,
|
||||
endpoint_b: ~s|{"lat":33.174149,"lon":-96.491943,"alt_m":198.9}|,
|
||||
weather_station: "KTKI"
|
||||
},
|
||||
%{
|
||||
label: "core-new-hope",
|
||||
host: "10.250.1.61",
|
||||
community: "kdyyJrT0Mm",
|
||||
radio_type: "af11x",
|
||||
endpoint_a: ~s|{"lat":33.174149,"lon":-96.491943,"alt_m":198.9}|,
|
||||
endpoint_b: ~s|{"lat":33.207279,"lon":-96.537762,"alt_m":216.4}|,
|
||||
weather_station: "KTKI"
|
||||
},
|
||||
%{
|
||||
label: "982_380_60LR",
|
||||
host: "10.250.1.34",
|
||||
community: "kdyyJrT0Mm",
|
||||
radio_type: "af60",
|
||||
endpoint_a: ~s|{"lat":33.148921,"lon":-96.4958,"alt_m":186.5}|,
|
||||
endpoint_b: ~s|{"lat":33.174135,"lon":-96.491935,"alt_m":200.4}|,
|
||||
weather_station: "KTKI"
|
||||
},
|
||||
%{
|
||||
label: "380_982_60LR",
|
||||
host: "10.250.1.37",
|
||||
community: "kdyyJrT0Mm",
|
||||
radio_type: "af60",
|
||||
endpoint_a: ~s|{"lat":33.174135,"lon":-96.491935,"alt_m":200.4}|,
|
||||
endpoint_b: ~s|{"lat":33.148921,"lon":-96.4958,"alt_m":186.5}|,
|
||||
weather_station: "KTKI"
|
||||
},
|
||||
%{
|
||||
label: "core-to-climax",
|
||||
host: "10.250.1.93",
|
||||
community: "kdyyJrT0Mm",
|
||||
radio_type: "af11x",
|
||||
endpoint_a: ~s|{"lat":33.174149,"lon":-96.491943,"alt_m":198.9}|,
|
||||
endpoint_b: ~s|{"lat":33.187294,"lon":-96.448128,"alt_m":191.0}|,
|
||||
weather_station: "KTKI"
|
||||
},
|
||||
%{
|
||||
label: "climax-to-core",
|
||||
host: "10.250.1.90",
|
||||
community: "kdyyJrT0Mm",
|
||||
radio_type: "af11x",
|
||||
endpoint_a: ~s|{"lat":33.187294,"lon":-96.448128,"alt_m":191.0}|,
|
||||
endpoint_b: ~s|{"lat":33.174149,"lon":-96.491943,"alt_m":198.9}|,
|
||||
weather_station: "KTKI"
|
||||
}
|
||||
]
|
||||
|
||||
for link <- links do
|
||||
execute("""
|
||||
INSERT INTO commercial_links (id, label, host, community, radio_type, endpoint_a, endpoint_b, weather_station, enabled, inserted_at, updated_at)
|
||||
VALUES (gen_random_uuid(), '#{link.label}', '#{link.host}', '#{link.community}', '#{link.radio_type}', '#{link.endpoint_a}'::jsonb, '#{link.endpoint_b}'::jsonb, '#{link.weather_station}', true, '#{now}', '#{now}')
|
||||
ON CONFLICT (label) DO NOTHING
|
||||
""")
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
execute("DELETE FROM commercial_links")
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
defmodule Microwaveprop.Repo.Migrations.CreateCommercialSamples do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:commercial_samples, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :link_id, references(:commercial_links, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :sampled_at, :utc_datetime_usec, null: false
|
||||
|
||||
# Local radio
|
||||
add :rx_power_0, :float
|
||||
add :rx_power_1, :float
|
||||
add :tx_power, :float
|
||||
add :tx_freq_mhz, :integer
|
||||
add :rx_freq_mhz, :integer
|
||||
add :radio_temp_0_c, :float
|
||||
add :radio_temp_1_c, :float
|
||||
|
||||
# Modulation & capacity
|
||||
add :cur_tx_mod_rate, :integer
|
||||
add :remote_tx_mod_rate, :integer
|
||||
add :rx_capacity, :integer
|
||||
add :tx_capacity, :integer
|
||||
|
||||
# Remote radio
|
||||
add :remote_tx_power, :float
|
||||
add :remote_rx_power_0, :float
|
||||
add :remote_rx_power_1, :float
|
||||
|
||||
# Link status
|
||||
add :link_state, :integer
|
||||
add :link_uptime, :integer
|
||||
add :link_distance_m, :integer
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:commercial_samples, [:link_id, :sampled_at])
|
||||
create index(:commercial_samples, [:sampled_at])
|
||||
end
|
||||
end
|
||||
58
test/microwaveprop/commercial/link_test.exs
Normal file
58
test/microwaveprop/commercial/link_test.exs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
defmodule Microwaveprop.Commercial.LinkTest do
|
||||
use Microwaveprop.DataCase, async: true
|
||||
|
||||
alias Microwaveprop.Commercial.Link
|
||||
|
||||
@valid_attrs %{
|
||||
label: "test-link-abc",
|
||||
host: "10.250.1.26",
|
||||
community: "public",
|
||||
radio_type: "af11x",
|
||||
endpoint_a: %{lat: 33.246171, lon: -96.426516, alt_m: 205.4},
|
||||
endpoint_b: %{lat: 33.187294, lon: -96.448128, alt_m: 191.0},
|
||||
weather_station: "KTKI",
|
||||
enabled: true
|
||||
}
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid attributes" do
|
||||
changeset = Link.changeset(%Link{}, @valid_attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "requires label, host, community" do
|
||||
changeset = Link.changeset(%Link{}, %{})
|
||||
|
||||
assert %{
|
||||
label: ["can't be blank"],
|
||||
host: ["can't be blank"],
|
||||
community: ["can't be blank"]
|
||||
} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "validates radio_type inclusion" do
|
||||
changeset = Link.changeset(%Link{}, %{@valid_attrs | radio_type: "unknown"})
|
||||
assert %{radio_type: ["is invalid"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "defaults enabled to true" do
|
||||
changeset = Link.changeset(%Link{}, @valid_attrs)
|
||||
assert Ecto.Changeset.get_field(changeset, :enabled) == true
|
||||
end
|
||||
|
||||
test "persists to the database" do
|
||||
changeset = Link.changeset(%Link{}, @valid_attrs)
|
||||
assert {:ok, link} = Repo.insert(changeset)
|
||||
assert link.label == "test-link-abc"
|
||||
assert link.host == "10.250.1.26"
|
||||
assert link.radio_type == "af11x"
|
||||
assert link.endpoint_a[:lat] == 33.246171
|
||||
end
|
||||
|
||||
test "enforces unique label" do
|
||||
assert {:ok, _} = Repo.insert(Link.changeset(%Link{}, @valid_attrs))
|
||||
assert {:error, changeset} = Repo.insert(Link.changeset(%Link{}, @valid_attrs))
|
||||
assert %{label: ["has already been taken"]} = errors_on(changeset)
|
||||
end
|
||||
end
|
||||
end
|
||||
69
test/microwaveprop/commercial/poll_worker_test.exs
Normal file
69
test/microwaveprop/commercial/poll_worker_test.exs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
defmodule Microwaveprop.Commercial.PollWorkerTest do
|
||||
use Microwaveprop.DataCase, async: false
|
||||
|
||||
alias Microwaveprop.Commercial
|
||||
alias Microwaveprop.Commercial.PollWorker
|
||||
alias Microwaveprop.Commercial.Sample
|
||||
|
||||
@link_attrs %{
|
||||
label: "test-poll-link",
|
||||
host: "10.0.0.1",
|
||||
community: "public",
|
||||
radio_type: "af11x",
|
||||
weather_station: "KTKI",
|
||||
enabled: true
|
||||
}
|
||||
|
||||
describe "poll_and_record/2" do
|
||||
test "creates samples for enabled links when SNMP succeeds" do
|
||||
{:ok, link} = Commercial.create_link(@link_attrs)
|
||||
|
||||
snmp_result = %{
|
||||
rx_power_0: -55,
|
||||
tx_power: 20,
|
||||
link_state: 1,
|
||||
link_uptime: 86_400
|
||||
}
|
||||
|
||||
PollWorker.poll_and_record([link], fn _host, _community, _type -> {:ok, snmp_result} end)
|
||||
|
||||
samples = Repo.all(Sample)
|
||||
assert length(samples) == 1
|
||||
assert hd(samples).rx_power_0 == -55.0
|
||||
assert hd(samples).link_id == link.id
|
||||
end
|
||||
|
||||
test "skips links when SNMP fails" do
|
||||
{:ok, link} = Commercial.create_link(@link_attrs)
|
||||
|
||||
PollWorker.poll_and_record(
|
||||
[link],
|
||||
fn _host, _community, _type -> {:error, :snmp_failed} end
|
||||
)
|
||||
|
||||
assert Repo.all(Sample) == []
|
||||
end
|
||||
|
||||
test "continues polling remaining links after one failure" do
|
||||
{:ok, link1} = Commercial.create_link(@link_attrs)
|
||||
{:ok, link2} = Commercial.create_link(%{@link_attrs | label: "test-poll-link-2", host: "10.0.0.2"})
|
||||
|
||||
call_count = :counters.new(1, [:atomics])
|
||||
|
||||
poll_fn = fn _host, _community, _type ->
|
||||
n = :counters.get(call_count, 1)
|
||||
:counters.add(call_count, 1, 1)
|
||||
|
||||
if n == 0 do
|
||||
{:error, :snmp_failed}
|
||||
else
|
||||
{:ok, %{rx_power_0: -60, link_state: 1}}
|
||||
end
|
||||
end
|
||||
|
||||
PollWorker.poll_and_record([link1, link2], poll_fn)
|
||||
|
||||
assert length(Repo.all(Sample)) == 1
|
||||
end
|
||||
end
|
||||
end
|
||||
58
test/microwaveprop/commercial/sample_test.exs
Normal file
58
test/microwaveprop/commercial/sample_test.exs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
defmodule Microwaveprop.Commercial.SampleTest do
|
||||
use Microwaveprop.DataCase, async: true
|
||||
|
||||
alias Microwaveprop.Commercial.Link
|
||||
alias Microwaveprop.Commercial.Sample
|
||||
|
||||
defp create_link do
|
||||
%Link{}
|
||||
|> Link.changeset(%{
|
||||
label: "test-link",
|
||||
host: "10.0.0.1",
|
||||
community: "public",
|
||||
radio_type: "af11x"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
@sample_attrs %{
|
||||
sampled_at: ~U[2026-03-30 12:00:00.000000Z],
|
||||
rx_power_0: -55.0,
|
||||
tx_power: 20.0,
|
||||
link_state: 1,
|
||||
link_uptime: 86_400,
|
||||
cur_tx_mod_rate: 6,
|
||||
rx_capacity: 450,
|
||||
tx_capacity: 450
|
||||
}
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid attributes with link_id" do
|
||||
link = create_link()
|
||||
attrs = Map.put(@sample_attrs, :link_id, link.id)
|
||||
changeset = Sample.changeset(%Sample{}, attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "requires link_id and sampled_at" do
|
||||
changeset = Sample.changeset(%Sample{}, %{})
|
||||
assert %{link_id: ["can't be blank"], sampled_at: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "persists to the database" do
|
||||
link = create_link()
|
||||
attrs = Map.put(@sample_attrs, :link_id, link.id)
|
||||
|
||||
assert {:ok, sample} = Repo.insert(Sample.changeset(%Sample{}, attrs))
|
||||
assert sample.rx_power_0 == -55.0
|
||||
assert sample.link_id == link.id
|
||||
end
|
||||
|
||||
test "all signal fields are optional" do
|
||||
link = create_link()
|
||||
attrs = %{link_id: link.id, sampled_at: ~U[2026-03-30 12:00:00.000000Z]}
|
||||
changeset = Sample.changeset(%Sample{}, attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
end
|
||||
103
test/microwaveprop/commercial/snmp_client_test.exs
Normal file
103
test/microwaveprop/commercial/snmp_client_test.exs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
defmodule Microwaveprop.Commercial.SnmpClientTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Microwaveprop.Commercial.SnmpClient
|
||||
|
||||
@af11x_output """
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.11.1 = INTEGER: -55
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.14.1 = INTEGER: -56
|
||||
iso.3.6.1.4.1.41112.1.3.1.1.9.1 = INTEGER: 20
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.2.1 = INTEGER: 6
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.18.1 = INTEGER: 6
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.5.1 = INTEGER: 450
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.6.1 = INTEGER: 450
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.17.1 = INTEGER: 20
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.19.1 = INTEGER: -54
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.22.1 = INTEGER: -57
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.26.1 = INTEGER: 1
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.44.1 = INTEGER: 86400
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.8.1 = INTEGER: 45
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.10.1 = INTEGER: 47
|
||||
iso.3.6.1.4.1.41112.1.3.1.1.5.1 = INTEGER: 11245
|
||||
iso.3.6.1.4.1.41112.1.3.1.1.6.1 = INTEGER: 10720
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.4.1 = INTEGER: 7200
|
||||
"""
|
||||
|
||||
describe "parse_snmpget_output/2" do
|
||||
test "parses AF11X snmpget output into signal map" do
|
||||
result = SnmpClient.parse_snmpget_output(@af11x_output, :af11x)
|
||||
|
||||
assert result.rx_power_0 == -55
|
||||
assert result.rx_power_1 == -56
|
||||
assert result.tx_power == 20
|
||||
assert result.cur_tx_mod_rate == 6
|
||||
assert result.remote_tx_mod_rate == 6
|
||||
assert result.rx_capacity == 450
|
||||
assert result.tx_capacity == 450
|
||||
assert result.remote_tx_power == 20
|
||||
assert result.remote_rx_power_0 == -54
|
||||
assert result.remote_rx_power_1 == -57
|
||||
assert result.link_state == 1
|
||||
assert result.link_uptime == 86_400
|
||||
assert result.radio_temp_0_c == 45
|
||||
assert result.radio_temp_1_c == 47
|
||||
assert result.tx_freq_mhz == 11_245
|
||||
assert result.rx_freq_mhz == 10_720
|
||||
assert result.link_distance_m == 7200
|
||||
end
|
||||
|
||||
test "returns empty map for empty output" do
|
||||
assert SnmpClient.parse_snmpget_output("", :af11x) == %{}
|
||||
end
|
||||
|
||||
test "skips lines with No Such Instance" do
|
||||
output = """
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.11.1 = INTEGER: -55
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.14.1 = No Such Instance currently exists at this OID
|
||||
"""
|
||||
|
||||
result = SnmpClient.parse_snmpget_output(output, :af11x)
|
||||
assert result.rx_power_0 == -55
|
||||
refute Map.has_key?(result, :rx_power_1)
|
||||
end
|
||||
end
|
||||
|
||||
@af60_static_output """
|
||||
iso.3.6.1.4.1.41112.1.11.1.2.5.1 = INTEGER: 52
|
||||
iso.3.6.1.4.1.41112.1.11.1.2.6.1 = INTEGER: 1
|
||||
iso.3.6.1.4.1.41112.1.11.1.1.2.1 = INTEGER: 60480
|
||||
"""
|
||||
|
||||
@af60_station_output """
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.3.170.211.109.42.26.137 = INTEGER: -58
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.4.170.211.109.42.26.137 = INTEGER: 18
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.5.170.211.109.42.26.137 = INTEGER: 9
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.6.170.211.109.42.26.137 = INTEGER: 8
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.7.170.211.109.42.26.137 = INTEGER: 900000
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.8.170.211.109.42.26.137 = INTEGER: 850000
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.15.170.211.109.42.26.137 = INTEGER: 2800
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.17.170.211.109.42.26.137 = INTEGER: 8640000
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.18.170.211.109.42.26.137 = INTEGER: -57
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.19.170.211.109.42.26.137 = INTEGER: 19
|
||||
"""
|
||||
|
||||
describe "parse_af60_output/2" do
|
||||
test "parses AF60 static + station output" do
|
||||
result = SnmpClient.parse_af60_output(@af60_static_output, @af60_station_output)
|
||||
|
||||
assert result.radio_temp_0_c == 52
|
||||
assert result.link_state == 1
|
||||
assert result.tx_freq_mhz == 60_480
|
||||
assert result.rx_power_0 == -58
|
||||
assert result.tx_power == 18
|
||||
assert result.cur_tx_mod_rate == 9
|
||||
assert result.remote_tx_mod_rate == 8
|
||||
assert result.tx_capacity == 900
|
||||
assert result.rx_capacity == 850
|
||||
assert result.link_distance_m == 2800
|
||||
assert result.link_uptime == 86_400
|
||||
assert result.remote_rx_power_0 == -57
|
||||
assert result.remote_tx_power == 19
|
||||
end
|
||||
end
|
||||
end
|
||||
99
test/microwaveprop/commercial_test.exs
Normal file
99
test/microwaveprop/commercial_test.exs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
defmodule Microwaveprop.CommercialTest do
|
||||
use Microwaveprop.DataCase, async: true
|
||||
|
||||
alias Microwaveprop.Commercial
|
||||
alias Microwaveprop.Commercial.Link
|
||||
|
||||
setup do
|
||||
Repo.delete_all(Link)
|
||||
:ok
|
||||
end
|
||||
|
||||
@link_attrs %{
|
||||
label: "test-link",
|
||||
host: "10.0.0.1",
|
||||
community: "public",
|
||||
radio_type: "af11x",
|
||||
weather_station: "KTKI",
|
||||
enabled: true
|
||||
}
|
||||
|
||||
describe "enabled_links/0" do
|
||||
test "returns only enabled links" do
|
||||
{:ok, _enabled} = Commercial.create_link(@link_attrs)
|
||||
|
||||
{:ok, _disabled} =
|
||||
Commercial.create_link(%{@link_attrs | label: "disabled-link", enabled: false})
|
||||
|
||||
links = Commercial.enabled_links()
|
||||
assert length(links) == 1
|
||||
assert hd(links).label == "test-link"
|
||||
end
|
||||
|
||||
test "returns empty list when no enabled links" do
|
||||
assert Commercial.enabled_links() == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_link/1" do
|
||||
test "creates a link with valid attrs" do
|
||||
assert {:ok, link} = Commercial.create_link(@link_attrs)
|
||||
assert link.label == "test-link"
|
||||
assert link.enabled == true
|
||||
end
|
||||
|
||||
test "returns error for invalid attrs" do
|
||||
assert {:error, _changeset} = Commercial.create_link(%{})
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_sample/1" do
|
||||
test "creates a sample for a link" do
|
||||
{:ok, link} = Commercial.create_link(@link_attrs)
|
||||
|
||||
attrs = %{
|
||||
link_id: link.id,
|
||||
sampled_at: DateTime.utc_now(),
|
||||
rx_power_0: -55.0,
|
||||
tx_power: 20.0,
|
||||
link_state: 1
|
||||
}
|
||||
|
||||
assert {:ok, sample} = Commercial.create_sample(attrs)
|
||||
assert sample.rx_power_0 == -55.0
|
||||
assert sample.link_id == link.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "weather_stations/0" do
|
||||
test "returns unique weather stations from enabled links" do
|
||||
{:ok, _} = Commercial.create_link(@link_attrs)
|
||||
|
||||
{:ok, _} =
|
||||
Commercial.create_link(%{@link_attrs | label: "link-2", host: "10.0.0.2"})
|
||||
|
||||
{:ok, _} =
|
||||
Commercial.create_link(%{
|
||||
@link_attrs
|
||||
| label: "link-3",
|
||||
host: "10.0.0.3",
|
||||
weather_station: "KDFW"
|
||||
})
|
||||
|
||||
stations = Commercial.weather_stations()
|
||||
assert Enum.sort(stations) == ["KDFW", "KTKI"]
|
||||
end
|
||||
|
||||
test "excludes disabled links" do
|
||||
{:ok, _} = Commercial.create_link(%{@link_attrs | enabled: false})
|
||||
|
||||
assert Commercial.weather_stations() == []
|
||||
end
|
||||
|
||||
test "excludes nil weather stations" do
|
||||
{:ok, _} = Commercial.create_link(%{@link_attrs | weather_station: nil})
|
||||
|
||||
assert Commercial.weather_stations() == []
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue