diff --git a/lib/towerops/snmp/profiles/vendors/airos.ex b/lib/towerops/snmp/profiles/vendors/airos.ex index fccc0b1b..523c9227 100644 --- a/lib/towerops/snmp/profiles/vendors/airos.ex +++ b/lib/towerops/snmp/profiles/vendors/airos.ex @@ -2,10 +2,13 @@ defmodule Towerops.Snmp.Profiles.Vendors.Airos do @moduledoc """ Ubiquiti AirOS device-specific SNMP handling. - Mirrors LibreNMS/OS/Airos.php for wireless sensor discovery and hardware detection. - Supported devices: - AirMax (LiteBeam, PowerBeam, NanoStation, etc.) + + OIDs sourced from UBNT-AirMAX-MIB: + - Radio table: 1.3.6.1.4.1.41112.1.4.1 (ubntRadioTable) + - WlStat table: 1.3.6.1.4.1.41112.1.4.5 (ubntWlStatTable) + - AirMax table: 1.3.6.1.4.1.41112.1.4.6 (ubntAirMaxTable) """ @behaviour Towerops.Snmp.Profiles.Vendors.Vendor @@ -16,7 +19,6 @@ defmodule Towerops.Snmp.Profiles.Vendors.Airos do require Logger # OIDs for hardware detection - # Use get_next like LibreNMS to get the first available value in the IEEE802dot11-MIB tree # IEEE802dot11-MIB::dot11manufacturerProductName = 1.2.840.10036.3.1.2.1.3 # IEEE802dot11-MIB::dot11manufacturerProductVersion = 1.2.840.10036.3.1.2.1.4 @product_name_oid "1.2.840.10036.3.1.2.1.3" @@ -41,7 +43,6 @@ defmodule Towerops.Snmp.Profiles.Vendors.Airos do other -> Logger.warning("AirOS: Failed to detect hardware, got: #{inspect(other)}") - # If get_next fails, return nil (will fallback to sysDescr in Dynamic module) nil end end @@ -83,7 +84,9 @@ defmodule Towerops.Snmp.Profiles.Vendors.Airos do @impl true def wireless_oid_defs do [ - # Frequency in MHz + # === Radio table (ubntRadioTable) - 1.3.6.1.4.1.41112.1.4.1 === + + # ubntRadioFreq - entry 4 %{ oid: "1.3.6.1.4.1.41112.1.4.1.1.4.1", sensor_type: "frequency", @@ -91,47 +94,7 @@ defmodule Towerops.Snmp.Profiles.Vendors.Airos do sensor_unit: "MHz", sensor_divisor: 1 }, - # Capacity in Mbps - %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.9.1", - sensor_type: "rate", - sensor_descr: "Capacity", - sensor_unit: "Mbps", - sensor_divisor: 1 - }, - # CCQ (Client Connection Quality) % - %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.7.1", - sensor_type: "ccq", - sensor_descr: "CCQ", - sensor_unit: "%", - sensor_divisor: 1 - }, - # Connected clients - %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.15.1", - sensor_type: "clients", - sensor_descr: "Clients", - sensor_unit: "", - sensor_divisor: 1 - }, - # Distance in meters - %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.11.1", - sensor_type: "distance", - sensor_descr: "Distance", - sensor_unit: "m", - sensor_divisor: 1 - }, - # Noise floor in dBm - %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.8.1", - sensor_type: "noise-floor", - sensor_descr: "Noise Floor", - sensor_unit: "dBm", - sensor_divisor: 1 - }, - # Tx Power in dBm + # ubntRadioTxPower - entry 6 %{ oid: "1.3.6.1.4.1.41112.1.4.1.1.6.1", sensor_type: "power", @@ -139,53 +102,99 @@ defmodule Towerops.Snmp.Profiles.Vendors.Airos do sensor_unit: "dBm", sensor_divisor: 1 }, - # Signal Quality % + # ubntRadioDistance - entry 7 (meters, divide by 1000 for km) + %{ + oid: "1.3.6.1.4.1.41112.1.4.1.1.7.1", + sensor_type: "distance", + sensor_descr: "Distance", + sensor_unit: "km", + sensor_divisor: 1000 + }, + + # === Wireless statistics table (ubntWlStatTable) - 1.3.6.1.4.1.41112.1.4.5 === + + # ubntWlStatSignal - entry 5 (signal strength dBm) %{ oid: "1.3.6.1.4.1.41112.1.4.5.1.5.1", - sensor_type: "quality", - sensor_descr: "Signal Quality", - sensor_unit: "%", + sensor_type: "power", + sensor_descr: "Signal Level", + sensor_unit: "dBm", sensor_divisor: 1 }, - # Tx Rate in Mbps + # ubntWlStatRssi - entry 6 %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.2.1", - sensor_type: "rate", - sensor_descr: "Tx Rate", - sensor_unit: "Mbps", - sensor_divisor: 1 - }, - # Rx Rate in Mbps - %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.3.1", - sensor_type: "rate", - sensor_descr: "Rx Rate", - sensor_unit: "Mbps", - sensor_divisor: 1 - }, - # RSSI in dBm - %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.5.1", + oid: "1.3.6.1.4.1.41112.1.4.5.1.6.1", sensor_type: "rssi", sensor_descr: "RSSI", sensor_unit: "dBm", sensor_divisor: 1 }, - # Channel utilization Tx % + # ubntWlStatCcq - entry 7 %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.12.1", - sensor_type: "utilization", - sensor_descr: "Channel Utilization Tx", + oid: "1.3.6.1.4.1.41112.1.4.5.1.7.1", + sensor_type: "ccq", + sensor_descr: "CCQ", sensor_unit: "%", sensor_divisor: 1 }, - # Channel utilization Rx % + # ubntWlStatNoiseFloor - entry 8 %{ - oid: "1.3.6.1.4.1.41112.1.4.5.1.13.1", - sensor_type: "utilization", - sensor_descr: "Channel Utilization Rx", + oid: "1.3.6.1.4.1.41112.1.4.5.1.8.1", + sensor_type: "noise-floor", + sensor_descr: "Noise Floor", + sensor_unit: "dBm", + sensor_divisor: 1 + }, + # ubntWlStatTxRate - entry 9 + %{ + oid: "1.3.6.1.4.1.41112.1.4.5.1.9.1", + sensor_type: "rate", + sensor_descr: "Tx Rate", + sensor_unit: "Mbps", + sensor_divisor: 1 + }, + # ubntWlStatRxRate - entry 10 + %{ + oid: "1.3.6.1.4.1.41112.1.4.5.1.10.1", + sensor_type: "rate", + sensor_descr: "Rx Rate", + sensor_unit: "Mbps", + sensor_divisor: 1 + }, + # ubntWlStatStaCount - entry 15 + %{ + oid: "1.3.6.1.4.1.41112.1.4.5.1.15.1", + sensor_type: "clients", + sensor_descr: "Clients", + sensor_unit: "", + sensor_divisor: 1 + }, + + # === AirMax table (ubntAirMaxTable) - 1.3.6.1.4.1.41112.1.4.6 === + + # ubntAirMaxQuality - entry 3 (percentage) + %{ + oid: "1.3.6.1.4.1.41112.1.4.6.1.3.1", + sensor_type: "quality", + sensor_descr: "airMAX Quality", sensor_unit: "%", sensor_divisor: 1 + }, + # ubntAirMaxCapacity - entry 4 (percentage) + %{ + oid: "1.3.6.1.4.1.41112.1.4.6.1.4.1", + sensor_type: "capacity", + sensor_descr: "Capacity", + sensor_unit: "%", + sensor_divisor: 1 + }, + # ubntAirMaxAirtime - entry 7 (% multiplied by 10) + %{ + oid: "1.3.6.1.4.1.41112.1.4.6.1.7.1", + sensor_type: "utilization", + sensor_descr: "Airtime", + sensor_unit: "%", + sensor_divisor: 10 } ] end diff --git a/test/towerops/snmp/profiles/vendors/airos_test.exs b/test/towerops/snmp/profiles/vendors/airos_test.exs index d87df607..4da7cbb4 100644 --- a/test/towerops/snmp/profiles/vendors/airos_test.exs +++ b/test/towerops/snmp/profiles/vendors/airos_test.exs @@ -53,7 +53,7 @@ defmodule Towerops.Snmp.Profiles.Vendors.AirosTest do defs = Airos.wireless_oid_defs() assert is_list(defs) - assert length(defs) >= 10 + assert length(defs) == 13 end test "each definition has required fields" do @@ -68,23 +68,103 @@ defmodule Towerops.Snmp.Profiles.Vendors.AirosTest do end) end - test "includes frequency sensor" do + test "frequency uses ubntRadioFreq OID" do defs = Airos.wireless_oid_defs() freq = Enum.find(defs, &(&1.sensor_descr == "Frequency")) - assert freq + assert freq.oid == "1.3.6.1.4.1.41112.1.4.1.1.4.1" assert freq.sensor_type == "frequency" assert freq.sensor_unit == "MHz" end - test "includes CCQ sensor" do + test "tx power uses ubntRadioTxPower OID" do + defs = Airos.wireless_oid_defs() + sensor = Enum.find(defs, &(&1.sensor_descr == "Tx Power")) + + assert sensor.oid == "1.3.6.1.4.1.41112.1.4.1.1.6.1" + assert sensor.sensor_type == "power" + assert sensor.sensor_unit == "dBm" + end + + test "signal level uses ubntWlStatSignal OID" do + defs = Airos.wireless_oid_defs() + sensor = Enum.find(defs, &(&1.sensor_descr == "Signal Level")) + + assert sensor.oid == "1.3.6.1.4.1.41112.1.4.5.1.5.1" + assert sensor.sensor_type == "power" + assert sensor.sensor_unit == "dBm" + end + + test "RSSI uses ubntWlStatRssi OID (column 6, not Signal column 5)" do + defs = Airos.wireless_oid_defs() + rssi = Enum.find(defs, &(&1.sensor_type == "rssi")) + + assert rssi.oid == "1.3.6.1.4.1.41112.1.4.5.1.6.1" + assert rssi.sensor_unit == "dBm" + end + + test "CCQ uses ubntWlStatCcq OID" do defs = Airos.wireless_oid_defs() ccq = Enum.find(defs, &(&1.sensor_descr == "CCQ")) - assert ccq + assert ccq.oid == "1.3.6.1.4.1.41112.1.4.5.1.7.1" assert ccq.sensor_type == "ccq" assert ccq.sensor_unit == "%" end + + test "tx/rx rate use ubntWlStatTxRate/RxRate OIDs (columns 9/10)" do + defs = Airos.wireless_oid_defs() + tx = Enum.find(defs, &(&1.sensor_descr == "Tx Rate")) + rx = Enum.find(defs, &(&1.sensor_descr == "Rx Rate")) + + assert tx.oid == "1.3.6.1.4.1.41112.1.4.5.1.9.1" + assert rx.oid == "1.3.6.1.4.1.41112.1.4.5.1.10.1" + assert tx.sensor_type == "rate" + assert rx.sensor_type == "rate" + end + + test "quality uses ubntAirMaxQuality OID from airMax table" do + defs = Airos.wireless_oid_defs() + quality = Enum.find(defs, &(&1.sensor_type == "quality")) + + assert quality.oid == "1.3.6.1.4.1.41112.1.4.6.1.3.1" + assert quality.sensor_unit == "%" + end + + test "capacity uses ubntAirMaxCapacity OID from airMax table" do + defs = Airos.wireless_oid_defs() + capacity = Enum.find(defs, &(&1.sensor_descr == "Capacity")) + + assert capacity.oid == "1.3.6.1.4.1.41112.1.4.6.1.4.1" + assert capacity.sensor_type == "capacity" + assert capacity.sensor_unit == "%" + end + + test "utilization uses ubntAirMaxAirtime OID with divisor 10" do + defs = Airos.wireless_oid_defs() + util = Enum.find(defs, &(&1.sensor_type == "utilization")) + + assert util.oid == "1.3.6.1.4.1.41112.1.4.6.1.7.1" + assert util.sensor_divisor == 10 + assert util.sensor_unit == "%" + end + + test "distance uses ubntRadioDistance OID with divisor 1000" do + defs = Airos.wireless_oid_defs() + distance = Enum.find(defs, &(&1.sensor_type == "distance")) + + assert distance.oid == "1.3.6.1.4.1.41112.1.4.1.1.7.1" + assert distance.sensor_divisor == 1000 + assert distance.sensor_unit == "km" + end + + test "no duplicate OIDs" do + defs = Airos.wireless_oid_defs() + oids = Enum.map(defs, & &1.oid) + + assert length(oids) == length(Enum.uniq(oids)), + "Found duplicate OIDs: #{inspect(oids -- Enum.uniq(oids))}" + end end describe "discover_wireless_sensors/1" do @@ -114,5 +194,55 @@ defmodule Towerops.Snmp.Profiles.Vendors.AirosTest do assert sensors == [] end + + test "discovers sensors with correct OIDs and values" do + expect(SnmpMock, :get, 13, fn _target, oid, _opts -> + cond do + # Radio table OIDs + String.contains?(oid, "41112.1.4.1.1.4.1") -> {:ok, 5180} + String.contains?(oid, "41112.1.4.1.1.6.1") -> {:ok, 20} + String.contains?(oid, "41112.1.4.1.1.7.1") -> {:ok, 15_200} + # WlStat table OIDs + String.contains?(oid, "41112.1.4.5.1.5.1") -> {:ok, -62} + String.contains?(oid, "41112.1.4.5.1.6.1") -> {:ok, -60} + String.contains?(oid, "41112.1.4.5.1.7.1") -> {:ok, 85} + String.contains?(oid, "41112.1.4.5.1.8.1") -> {:ok, -95} + String.contains?(oid, "41112.1.4.5.1.9.1") -> {:ok, 300} + String.contains?(oid, "41112.1.4.5.1.10.1") -> {:ok, 300} + String.contains?(oid, "41112.1.4.5.1.15.1") -> {:ok, 12} + # AirMax table OIDs + String.contains?(oid, "41112.1.4.6.1.3.1") -> {:ok, 75} + String.contains?(oid, "41112.1.4.6.1.4.1") -> {:ok, 80} + String.contains?(oid, "41112.1.4.6.1.7.1") -> {:ok, 450} + true -> {:error, :no_such_object} + end + end) + + sensors = Airos.discover_wireless_sensors(@client_opts) + + assert length(sensors) == 13 + + # Verify frequency + freq = Enum.find(sensors, &(&1.sensor_type == "frequency")) + assert freq.last_value == 5180.0 + + # Verify RSSI uses correct OID (column 6) + rssi = Enum.find(sensors, &(&1.sensor_type == "rssi")) + assert rssi.last_value == -60.0 + assert rssi.sensor_oid == "1.3.6.1.4.1.41112.1.4.5.1.6.1" + + # Verify rates + rates = Enum.filter(sensors, &(&1.sensor_type == "rate")) + assert length(rates) == 2 + + # Verify distance with divisor + distance = Enum.find(sensors, &(&1.sensor_type == "distance")) + assert distance.sensor_divisor == 1000 + assert distance.last_value == 15_200.0 + + # Verify utilization with divisor + util = Enum.find(sensors, &(&1.sensor_type == "utilization")) + assert util.sensor_divisor == 10 + end end end