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