prop/lib/microwaveprop/commercial/snmp_client.ex
Graham McIntire 316fb2fbc7
Fix low-severity bugs and re-enable Credo checks
- Bug #12: Lower rate limit to 15/min
- Bug #13: Wrap model loading in Task.start
- Bug #14: Fix classify_time_period guard gap at -3.0
- Bug #15: Not applicable (Elixir has no ?? operator)
- Bug #16: Remove fallback Repo.get for station preload
- Bug #17: Extract cache_key() in ContactMapController
- Bug #18: Add has_many :contacts and :beacons to User schema
- A&D #4: Move serve_markdown_if_requested after secure headers
- Config #1: Move signing_salt to runtime.exs env var
- Config #2: Use --check-unused instead of --unused
- Config #3: Re-enable UnsafeToAtom Credo check
- Config #5: Re-enable LeakyEnvironment Credo check
- Add @spec annotations to fix re-enabled Specs violations
- Replace String.to_atom with to_existing_atom where guarded
2026-05-29 17:29:22 -05:00

207 lines
6.3 KiB
Elixir

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}
}
@spec poll(String.t(), String.t(), String.t()) :: {:ok, map()} | {:error, term()}
def poll(host, community, "af11x"), do: poll_af11x(host, community)
def poll(host, community, "af60"), do: poll_af60(host, community)
@spec poll_af11x(String.t(), String.t()) :: {:ok, map()} | {:error, term()}
def poll_af11x(host, community) do
oids = Map.keys(@af11x_oids)
args = ["-v1", "-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
@spec poll_af60(String.t(), String.t()) :: {:ok, map()} | {:error, term()}
def poll_af60(host, community) do
static_oids = Map.keys(@af60_static_oids)
static_args = ["-v1", "-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 = ["-v1", "-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
@spec parse_snmpget_output(String.t(), :af11x) :: map()
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
@spec parse_af60_output(String.t(), String.t()) :: map()
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
runner = Application.get_env(:microwaveprop, :snmp_runner, &default_run_cmd/2)
runner.(cmd, args)
end
defp default_run_cmd(cmd, args) do
System.cmd(cmd, args, stderr_to_stdout: true)
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/^SNMPv2-SMI::enterprises\./, "1.3.6.1.4.1.")
|> 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