refactor: extract vendor-specific SNMP discovery to modules

Mirrors LibreNMS architecture where vendor-specific discovery logic
lives in OS PHP classes. Creates modular vendor system with:

- Vendor behaviour defining callbacks for profile_names, detect_hardware,
  discover_wireless_sensors, wireless_oid_defs
- Epmp vendor module for Cambium ePMP devices
- Airos vendor module for Ubiquiti AirOS devices
- Routeros vendor module for MikroTik devices with table-based discovery
- Registry module for dispatching to appropriate vendor by profile name

Removes 250+ lines of inline vendor code from dynamic.ex.
This commit is contained in:
Graham McIntire 2026-01-22 08:34:35 -06:00
parent 28a9517a1f
commit 56bb855996
No known key found for this signature in database
6 changed files with 1087 additions and 313 deletions

View file

@ -7,6 +7,7 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
alias Towerops.Snmp.Client
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.MibTranslator
alias Towerops.Snmp.Profiles.Vendors.Registry, as: VendorRegistry
require Logger
@ -120,57 +121,13 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
# Private helper functions
# Vendor-specific hardware detection (like LibreNMS OS PHP files)
# ePMP: Uses wirelessInterfaceMode and cambiumSubModeType to determine AP/SM and ePTP/ePMP
defp detect_vendor_hardware(%{name: "epmp"}, client_opts) do
detect_epmp_hardware(client_opts)
end
# AirOS: Uses IEEE802dot11-MIB::dot11manufacturerProductName (mirrors Airos.php)
defp detect_vendor_hardware(%{name: name}, client_opts)
when name in ["airos", "airos-af", "airos-af60", "airos-af-ltu"] do
detect_airos_hardware(client_opts)
# Vendor-specific hardware detection using vendor modules (like LibreNMS OS PHP files)
defp detect_vendor_hardware(%{name: name}, client_opts) do
VendorRegistry.detect_hardware(name, client_opts)
end
defp detect_vendor_hardware(_profile, _client_opts), do: nil
# AirOS hardware detection - mirrors LibreNMS Airos.php discoverOS()
# Uses GETNEXT on IEEE802dot11-MIB::dot11manufacturerProductName table
defp detect_airos_hardware(client_opts) do
# IEEE802dot11-MIB::dot11manufacturerProductName OID base
product_name_oid = "1.2.840.10036.1.1.1.9"
# Use walk to get the first entry (LibreNMS uses GETNEXT)
case Client.walk(client_opts, product_name_oid) do
{:ok, [%{value: value} | _]} when is_binary(value) and value != "" ->
value
_ ->
nil
end
end
# ePMP hardware detection - mirrors LibreNMS Epmp.php discoverOS()
defp detect_epmp_hardware(client_opts) do
interface_mode_oid = "1.3.6.1.4.1.17713.21.1.1.1.0"
sub_mode_oid = "1.3.6.1.4.1.17713.21.1.1.2.0"
with {:ok, interface_mode} <- Client.get(client_opts, interface_mode_oid),
{:ok, sub_mode} <- Client.get(client_opts, sub_mode_oid) do
case {interface_mode, sub_mode} do
# AP mode (1)
{1, 5} -> "ePTP Master"
{1, _} -> "ePMP AP"
# SM mode (2)
{2, 4} -> "ePTP Slave"
{2, _} -> "ePMP SM"
_ -> nil
end
else
_ -> nil
end
end
defp format_firmware_version(%{vendor: vendor}, device_data) when vendor in ["MikroTik", "Mikrotik RouterOS"] do
case {Map.get(device_data, :firmware_version), Map.get(device_data, :license_version)} do
{fw, license} when not is_nil(fw) and not is_nil(license) ->
@ -401,277 +358,16 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
end
# Get the wireless OID definitions for a profile (for debug data collection)
defp get_vendor_wireless_oids(%{name: name}) when name in ["airos", "airos-af", "airos-af60", "airos-af-ltu"] do
airos_wireless_oid_defs()
end
defp get_vendor_wireless_oids(%{name: "epmp"}) do
epmp_wireless_oid_defs()
defp get_vendor_wireless_oids(%{name: name}) do
VendorRegistry.get_wireless_oid_defs(name)
end
defp get_vendor_wireless_oids(_profile), do: []
# Vendor-specific wireless sensor discovery
# Mirrors LibreNMS PHP-based discovery (e.g., Airos.php, Epmp.php)
defp discover_vendor_wireless_sensors(%{name: name}, client_opts)
when name in ["airos", "airos-af", "airos-af60", "airos-af-ltu"] do
discover_airos_wireless_sensors(client_opts)
end
defp discover_vendor_wireless_sensors(%{name: "epmp"}, client_opts) do
discover_epmp_wireless_sensors(client_opts)
# Vendor-specific wireless sensor discovery using vendor modules
defp discover_vendor_wireless_sensors(%{name: name}, client_opts) do
VendorRegistry.discover_wireless_sensors(name, client_opts)
end
defp discover_vendor_wireless_sensors(_profile, _client_opts), do: []
# AirOS wireless sensors - mirrors LibreNMS/OS/Airos.php
# Implements: frequency, capacity, ccq, clients, distance, noise-floor, power, quality, rate, rssi, utilization
defp discover_airos_wireless_sensors(client_opts) do
fetch_wireless_sensors(airos_wireless_oid_defs(), client_opts)
end
# AirOS wireless OID definitions (reused for discovery and debug data)
defp airos_wireless_oid_defs do
[
# Frequency in MHz
%{
oid: "1.3.6.1.4.1.41112.1.4.1.1.4.1",
sensor_type: "frequency",
sensor_descr: "Frequency",
sensor_unit: "MHz",
sensor_divisor: 1
},
# Capacity 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
},
# 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 count
%{
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 (value is in mm, divide by 1000)
%{
oid: "1.3.6.1.4.1.41112.1.4.1.1.7.1",
sensor_type: "distance",
sensor_descr: "Distance",
sensor_unit: "m",
sensor_divisor: 1000
},
# 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
%{
oid: "1.3.6.1.4.1.41112.1.4.1.1.6.1",
sensor_type: "power",
sensor_descr: "TX Power",
sensor_unit: "dBm",
sensor_divisor: 1
},
# Signal quality percentage
%{
oid: "1.3.6.1.4.1.41112.1.4.6.1.3.1",
sensor_type: "quality",
sensor_descr: "Signal Quality",
sensor_unit: "%",
sensor_divisor: 1
},
# TX Rate in Mbps
%{
oid: "1.3.6.1.4.1.41112.1.4.5.1.9.1",
sensor_type: "rate-tx",
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.10.1",
sensor_type: "rate-rx",
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.6.1",
sensor_type: "rssi",
sensor_descr: "RSSI",
sensor_unit: "dBm",
sensor_divisor: 1
},
# Signal level in dBm
%{
oid: "1.3.6.1.4.1.41112.1.4.5.1.5.1",
sensor_type: "signal",
sensor_descr: "Signal Level",
sensor_unit: "dBm",
sensor_divisor: 1
},
# Utilization percentage (divide by 10)
%{
oid: "1.3.6.1.4.1.41112.1.4.6.1.7.1",
sensor_type: "utilization",
sensor_descr: "Utilization",
sensor_unit: "%",
sensor_divisor: 10
}
]
end
# ePMP wireless sensors - mirrors LibreNMS/OS/Epmp.php
# Implements: rssi, snr, frequency, clients
defp discover_epmp_wireless_sensors(client_opts) do
fetch_wireless_sensors(epmp_wireless_oid_defs(), client_opts)
end
# ePMP wireless OID definitions (reused for discovery and debug data)
# Mirrors LibreNMS/OS/Epmp.php discoverWireless* and pollOS methods
defp epmp_wireless_oid_defs do
[
# === Wireless sensors (from discoverWireless* methods) ===
# RSSI in dBm (SM only - will be "No Such Instance" on AP)
%{
oid: "1.3.6.1.4.1.17713.21.1.2.3.0",
sensor_type: "rssi",
sensor_descr: "RSSI",
sensor_unit: "dBm",
sensor_divisor: 1
},
# SNR in dB (SM only - will be "No Such Instance" on AP)
%{
oid: "1.3.6.1.4.1.17713.21.1.2.18.0",
sensor_type: "snr",
sensor_descr: "SNR",
sensor_unit: "dB",
sensor_divisor: 1
},
# Frequency in MHz
%{
oid: "1.3.6.1.4.1.17713.21.1.2.1.0",
sensor_type: "frequency",
sensor_descr: "Frequency",
sensor_unit: "MHz",
sensor_divisor: 1
},
# Connected clients count (AP only)
%{
oid: "1.3.6.1.4.1.17713.21.1.2.10.0",
sensor_type: "clients",
sensor_descr: "Connected Clients",
sensor_unit: "",
sensor_divisor: 1
},
# === GPS sensors (from pollOS) ===
# GPS Tracked Satellites
%{
oid: "1.3.6.1.4.1.17713.21.1.2.64.0",
sensor_type: "count",
sensor_descr: "GPS Tracked Satellites",
sensor_unit: "",
sensor_divisor: 1
},
# GPS Visible Satellites
%{
oid: "1.3.6.1.4.1.17713.21.1.2.65.0",
sensor_type: "count",
sensor_descr: "GPS Visible Satellites",
sensor_unit: "",
sensor_divisor: 1
},
# === Radio settings ===
# Transmit Power in dBm
%{
oid: "1.3.6.1.4.1.17713.21.1.2.5.0",
sensor_type: "power",
sensor_descr: "Transmit Power",
sensor_unit: "dBm",
sensor_divisor: 1
},
# Channel Width in MHz
%{
oid: "1.3.6.1.4.1.17713.21.1.1.20.0",
sensor_type: "frequency",
sensor_descr: "Channel Width",
sensor_unit: "MHz",
sensor_divisor: 1
},
# === Network entry stats (from pollOS) ===
# Network Entry Attempts
%{
oid: "1.3.6.1.4.1.17713.21.2.1.33.0",
sensor_type: "count",
sensor_descr: "Network Entry Attempts",
sensor_unit: "",
sensor_divisor: 1
},
# Network Entry Success
%{
oid: "1.3.6.1.4.1.17713.21.2.1.34.0",
sensor_type: "count",
sensor_descr: "Network Entry Success",
sensor_unit: "",
sensor_divisor: 1
},
# Authentication Failures
%{
oid: "1.3.6.1.4.1.17713.21.2.1.35.0",
sensor_type: "count",
sensor_descr: "Auth Failures",
sensor_unit: "",
sensor_divisor: 1
}
]
end
# Fetch wireless sensor values from scalar OIDs
defp fetch_wireless_sensors(sensor_defs, client_opts) do
sensor_defs
|> Enum.map(&fetch_wireless_sensor_value(&1, client_opts))
|> Enum.reject(&is_nil/1)
end
defp fetch_wireless_sensor_value(sensor_def, client_opts) do
case Client.get(client_opts, sensor_def.oid) do
{:ok, value} when is_integer(value) ->
# Use OID suffix to make sensor_index unique (avoids collisions like two "frequency" sensors)
oid_suffix = sensor_def.oid |> String.split(".") |> Enum.take(-2) |> Enum.join("_")
%{
sensor_type: sensor_def.sensor_type,
sensor_index:
"wireless_#{sensor_def.sensor_descr |> String.downcase() |> String.replace(" ", "_")}_#{oid_suffix}",
sensor_oid: sensor_def.oid,
sensor_descr: sensor_def.sensor_descr,
sensor_unit: sensor_def.sensor_unit,
sensor_divisor: sensor_def.sensor_divisor,
last_value: value / 1
}
_ ->
nil
end
end
end

View file

@ -0,0 +1,150 @@
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.)
- AirFiber
- AirFiber 60GHz
- AirFiber LTU
"""
@behaviour Towerops.Snmp.Profiles.Vendors.Vendor
alias Towerops.Snmp.Client
alias Towerops.Snmp.Profiles.Vendors.Vendor
# OID for hardware detection
@product_name_oid "1.2.840.10036.1.1.1.9.5"
@impl true
def profile_names, do: ["airos", "airos-af", "airos-af60", "airos-af-ltu"]
@impl true
def detect_hardware(client_opts) do
case Client.get(client_opts, @product_name_oid) do
{:ok, product_name} when is_binary(product_name) ->
product_name
_ ->
nil
end
end
@impl true
def discover_wireless_sensors(client_opts) do
Vendor.fetch_sensors(wireless_oid_defs(), client_opts)
end
@impl true
def wireless_oid_defs do
[
# Frequency in MHz
%{
oid: "1.3.6.1.4.1.41112.1.4.1.1.4.1",
sensor_type: "frequency",
sensor_descr: "Frequency",
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
%{
oid: "1.3.6.1.4.1.41112.1.4.1.1.6.1",
sensor_type: "power",
sensor_descr: "Tx Power",
sensor_unit: "dBm",
sensor_divisor: 1
},
# Signal Quality %
%{
oid: "1.3.6.1.4.1.41112.1.4.5.1.5.1",
sensor_type: "quality",
sensor_descr: "Signal Quality",
sensor_unit: "%",
sensor_divisor: 1
},
# Tx Rate in Mbps
%{
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",
sensor_type: "rssi",
sensor_descr: "RSSI",
sensor_unit: "dBm",
sensor_divisor: 1
},
# Channel utilization Tx %
%{
oid: "1.3.6.1.4.1.41112.1.4.5.1.12.1",
sensor_type: "utilization",
sensor_descr: "Channel Utilization Tx",
sensor_unit: "%",
sensor_divisor: 1
},
# Channel utilization Rx %
%{
oid: "1.3.6.1.4.1.41112.1.4.5.1.13.1",
sensor_type: "utilization",
sensor_descr: "Channel Utilization Rx",
sensor_unit: "%",
sensor_divisor: 1
}
]
end
end

View file

@ -0,0 +1,145 @@
defmodule Towerops.Snmp.Profiles.Vendors.Epmp do
@moduledoc """
Cambium ePMP device-specific SNMP handling.
Mirrors LibreNMS/OS/Epmp.php for wireless sensor discovery and hardware detection.
Supported devices:
- ePMP AP (Access Point)
- ePMP SM (Subscriber Module)
- ePTP Master/Slave
"""
@behaviour Towerops.Snmp.Profiles.Vendors.Vendor
alias Towerops.Snmp.Client
alias Towerops.Snmp.Profiles.Vendors.Vendor
# OIDs for hardware detection
@interface_mode_oid "1.3.6.1.4.1.17713.21.1.1.1.0"
@sub_mode_oid "1.3.6.1.4.1.17713.21.1.1.2.0"
@impl true
def profile_names, do: ["epmp"]
@impl true
def detect_hardware(client_opts) do
with {:ok, interface_mode} <- Client.get(client_opts, @interface_mode_oid),
{:ok, sub_mode} <- Client.get(client_opts, @sub_mode_oid) do
case {interface_mode, sub_mode} do
# AP mode
{1, 5} -> "ePTP Master"
{1, _} -> "ePMP AP"
# SM mode
{2, 4} -> "ePTP Slave"
{2, _} -> "ePMP SM"
_ -> nil
end
else
_ -> nil
end
end
@impl true
def discover_wireless_sensors(client_opts) do
Vendor.fetch_sensors(wireless_oid_defs(), client_opts)
end
@impl true
def wireless_oid_defs do
[
# === Wireless sensors (from discoverWireless* methods) ===
# RSSI in dBm (SM only - will be "No Such Instance" on AP)
%{
oid: "1.3.6.1.4.1.17713.21.1.2.3.0",
sensor_type: "rssi",
sensor_descr: "RSSI",
sensor_unit: "dBm",
sensor_divisor: 1
},
# SNR in dB (SM only - will be "No Such Instance" on AP)
%{
oid: "1.3.6.1.4.1.17713.21.1.2.18.0",
sensor_type: "snr",
sensor_descr: "SNR",
sensor_unit: "dB",
sensor_divisor: 1
},
# Frequency in MHz
%{
oid: "1.3.6.1.4.1.17713.21.1.2.1.0",
sensor_type: "frequency",
sensor_descr: "Frequency",
sensor_unit: "MHz",
sensor_divisor: 1
},
# Connected clients count (AP only)
%{
oid: "1.3.6.1.4.1.17713.21.1.2.10.0",
sensor_type: "clients",
sensor_descr: "Connected Clients",
sensor_unit: "",
sensor_divisor: 1
},
# === GPS sensors (from pollOS) ===
# GPS Tracked Satellites
%{
oid: "1.3.6.1.4.1.17713.21.1.2.64.0",
sensor_type: "count",
sensor_descr: "GPS Tracked Satellites",
sensor_unit: "",
sensor_divisor: 1
},
# GPS Visible Satellites
%{
oid: "1.3.6.1.4.1.17713.21.1.2.65.0",
sensor_type: "count",
sensor_descr: "GPS Visible Satellites",
sensor_unit: "",
sensor_divisor: 1
},
# === Radio settings ===
# Transmit Power in dBm
%{
oid: "1.3.6.1.4.1.17713.21.1.2.5.0",
sensor_type: "power",
sensor_descr: "Transmit Power",
sensor_unit: "dBm",
sensor_divisor: 1
},
# Channel Width in MHz
%{
oid: "1.3.6.1.4.1.17713.21.1.1.20.0",
sensor_type: "frequency",
sensor_descr: "Channel Width",
sensor_unit: "MHz",
sensor_divisor: 1
},
# === Network entry stats (from pollOS) ===
# Network Entry Attempts
%{
oid: "1.3.6.1.4.1.17713.21.2.1.33.0",
sensor_type: "count",
sensor_descr: "Network Entry Attempts",
sensor_unit: "",
sensor_divisor: 1
},
# Network Entry Success
%{
oid: "1.3.6.1.4.1.17713.21.2.1.34.0",
sensor_type: "count",
sensor_descr: "Network Entry Success",
sensor_unit: "",
sensor_divisor: 1
},
# Authentication Failures
%{
oid: "1.3.6.1.4.1.17713.21.2.1.35.0",
sensor_type: "count",
sensor_descr: "Auth Failures",
sensor_unit: "",
sensor_divisor: 1
}
]
end
end

View file

@ -0,0 +1,76 @@
defmodule Towerops.Snmp.Profiles.Vendors.Registry do
@moduledoc """
Registry for vendor-specific SNMP modules.
Maps profile names to their vendor module implementations,
similar to how LibreNMS maps OS names to PHP classes.
"""
alias Towerops.Snmp.Client
alias Towerops.Snmp.Profiles.Vendors.Airos
alias Towerops.Snmp.Profiles.Vendors.Epmp
alias Towerops.Snmp.Profiles.Vendors.Routeros
@vendors [Epmp, Airos, Routeros]
@doc """
Get the vendor module for a given profile name.
Returns nil if no vendor-specific module exists.
"""
@spec get_vendor(String.t()) :: module() | nil
def get_vendor(profile_name) do
Enum.find(@vendors, fn vendor ->
profile_name in vendor.profile_names()
end)
end
@doc """
List all registered vendor modules.
"""
@spec list_vendors() :: [module()]
def list_vendors, do: @vendors
@doc """
List all profile names handled by vendor modules.
"""
@spec list_profile_names() :: [String.t()]
def list_profile_names do
Enum.flat_map(@vendors, & &1.profile_names())
end
@doc """
Detect hardware using the appropriate vendor module.
Returns nil if no vendor module matches or detection fails.
"""
@spec detect_hardware(String.t(), Client.connection_opts()) :: String.t() | nil
def detect_hardware(profile_name, client_opts) do
case get_vendor(profile_name) do
nil -> nil
vendor -> vendor.detect_hardware(client_opts)
end
end
@doc """
Discover wireless sensors using the appropriate vendor module.
Returns empty list if no vendor module matches.
"""
@spec discover_wireless_sensors(String.t(), Client.connection_opts()) :: [map()]
def discover_wireless_sensors(profile_name, client_opts) do
case get_vendor(profile_name) do
nil -> []
vendor -> vendor.discover_wireless_sensors(client_opts)
end
end
@doc """
Get wireless OID definitions for debug data collection.
Returns empty list if no vendor module matches.
"""
@spec get_wireless_oid_defs(String.t()) :: [map()]
def get_wireless_oid_defs(profile_name) do
case get_vendor(profile_name) do
nil -> []
vendor -> vendor.wireless_oid_defs()
end
end
end

View file

@ -0,0 +1,577 @@
defmodule Towerops.Snmp.Profiles.Vendors.Routeros do
@moduledoc """
MikroTik RouterOS device-specific SNMP handling.
Mirrors LibreNMS/OS/Routeros.php for wireless sensor discovery and hardware detection.
Supported features:
- Wireless AP/Station sensors (CCQ, clients, frequency, noise floor, rate)
- 60GHz wireless sensors (RSSI, quality, distance)
- LTE modem sensors (RSRQ, RSRP, SINR)
- DHCP leases and PPPoE sessions
"""
@behaviour Towerops.Snmp.Profiles.Vendors.Vendor
alias Towerops.Snmp.Client
alias Towerops.Snmp.Profiles.Vendors.Vendor
require Logger
# OID for license level (used in firmware version display)
@license_oid "1.3.6.1.4.1.14988.1.1.4.4.0"
# Table OIDs for walking
@wl_ap_table "1.3.6.1.4.1.14988.1.1.1.3.1"
@wl_stat_table "1.3.6.1.4.1.14988.1.1.1.1.1"
@wl_60g_table "1.3.6.1.4.1.14988.1.1.1.8.1"
@lte_modem_table "1.3.6.1.4.1.14988.1.1.16.1.1"
@impl true
def profile_names, do: ["routeros"]
@impl true
def detect_hardware(client_opts) do
# MikroTik hardware is typically identified by sysDescr
# We enhance the firmware version with license level
case Client.get(client_opts, @license_oid) do
{:ok, license} when is_integer(license) ->
"RouterOS Level #{license}"
_ ->
nil
end
end
@impl true
def discover_wireless_sensors(client_opts) do
# Discover from multiple tables like LibreNMS does
sensors =
discover_ap_sensors(client_opts) ++
discover_station_sensors(client_opts) ++
discover_60g_sensors(client_opts) ++
discover_lte_sensors(client_opts) ++
discover_system_sensors(client_opts)
Logger.debug("RouterOS: Discovered #{length(sensors)} wireless sensors")
sensors
end
@impl true
def wireless_oid_defs do
# Scalar OIDs for basic discovery (tables are walked separately)
[
# DHCP Leases
%{
oid: "1.3.6.1.4.1.14988.1.1.6.1.0",
sensor_type: "count",
sensor_descr: "DHCP Leases",
sensor_unit: "",
sensor_divisor: 1
},
# PPPoE Sessions (Cisco OID that RouterOS also supports)
%{
oid: "1.3.6.1.4.1.9.9.150.1.1.1.0",
sensor_type: "count",
sensor_descr: "PPPoE Sessions",
sensor_unit: "",
sensor_divisor: 1
}
]
end
# Discover sensors from mtxrWlApTable (AP mode)
defp discover_ap_sensors(client_opts) do
case Client.walk(client_opts, @wl_ap_table) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
parse_ap_table(results)
_ ->
[]
end
end
defp parse_ap_table(results) do
# Group by index (last OID component)
grouped = group_by_index(results)
Enum.flat_map(grouped, fn {idx, values} ->
ssid = get_string_value(values, 5) || "wlan#{idx}"
freq = get_int_value(values, 7)
freq_band = if freq && freq > 0, do: "#{div(freq, 1000)}G", else: ""
prefix = if freq_band == "", do: ssid, else: "#{freq_band}: #{ssid}"
build_ap_sensors(idx, prefix, values)
end)
end
defp build_ap_sensors(idx, prefix, values) do
sensors = []
# Client count (.6)
sensors =
if client_count = get_int_value(values, 6) do
[
%{
sensor_type: "clients",
sensor_index: "mikrotik_ap_clients_#{idx}",
sensor_oid: "#{@wl_ap_table}.6.#{idx}",
sensor_descr: "#{prefix} Clients",
sensor_unit: "",
sensor_divisor: 1,
last_value: client_count / 1
}
| sensors
]
else
sensors
end
# Frequency (.7)
sensors =
if freq = get_int_value(values, 7) do
[
%{
sensor_type: "frequency",
sensor_index: "mikrotik_ap_freq_#{idx}",
sensor_oid: "#{@wl_ap_table}.7.#{idx}",
sensor_descr: "#{prefix} Frequency",
sensor_unit: "MHz",
sensor_divisor: 1,
last_value: freq / 1
}
| sensors
]
else
sensors
end
# Noise floor (.9)
sensors =
if noise = get_int_value(values, 9) do
[
%{
sensor_type: "noise-floor",
sensor_index: "mikrotik_ap_noise_#{idx}",
sensor_oid: "#{@wl_ap_table}.9.#{idx}",
sensor_descr: "#{prefix} Noise Floor",
sensor_unit: "dBm",
sensor_divisor: 1,
last_value: noise / 1
}
| sensors
]
else
sensors
end
# CCQ (.10)
sensors =
if ccq = get_int_value(values, 10) do
[
%{
sensor_type: "ccq",
sensor_index: "mikrotik_ap_ccq_#{idx}",
sensor_oid: "#{@wl_ap_table}.10.#{idx}",
sensor_descr: "#{prefix} CCQ",
sensor_unit: "%",
sensor_divisor: 1,
last_value: ccq / 1
}
| sensors
]
else
sensors
end
# Tx Rate (.2)
sensors =
if tx_rate = get_int_value(values, 2) do
[
%{
sensor_type: "rate",
sensor_index: "mikrotik_ap_tx_rate_#{idx}",
sensor_oid: "#{@wl_ap_table}.2.#{idx}",
sensor_descr: "#{prefix} Tx Rate",
sensor_unit: "bps",
sensor_divisor: 1,
last_value: tx_rate / 1
}
| sensors
]
else
sensors
end
# Rx Rate (.3)
sensors =
if rx_rate = get_int_value(values, 3) do
[
%{
sensor_type: "rate",
sensor_index: "mikrotik_ap_rx_rate_#{idx}",
sensor_oid: "#{@wl_ap_table}.3.#{idx}",
sensor_descr: "#{prefix} Rx Rate",
sensor_unit: "bps",
sensor_divisor: 1,
last_value: rx_rate / 1
}
| sensors
]
else
sensors
end
sensors
end
# Discover sensors from mtxrWlStatTable (Station mode)
defp discover_station_sensors(client_opts) do
case Client.walk(client_opts, @wl_stat_table) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
parse_station_table(results)
_ ->
[]
end
end
defp parse_station_table(results) do
grouped = group_by_index(results)
Enum.flat_map(grouped, fn {idx, values} ->
ssid = get_string_value(values, 5) || "wlan#{idx}"
freq = get_int_value(values, 7)
freq_band = if freq && freq > 0, do: "#{div(freq, 1000)}G", else: ""
prefix = if freq_band == "", do: ssid, else: "#{freq_band}: #{ssid}"
build_station_sensors(idx, prefix, values)
end)
end
defp build_station_sensors(idx, prefix, values) do
sensors = []
# Frequency (.7)
sensors =
if freq = get_int_value(values, 7) do
[
%{
sensor_type: "frequency",
sensor_index: "mikrotik_sta_freq_#{idx}",
sensor_oid: "#{@wl_stat_table}.7.#{idx}",
sensor_descr: "#{prefix} Frequency",
sensor_unit: "MHz",
sensor_divisor: 1,
last_value: freq / 1
}
| sensors
]
else
sensors
end
# Tx CCQ (.9)
sensors =
if tx_ccq = get_int_value(values, 9) do
[
%{
sensor_type: "ccq",
sensor_index: "mikrotik_sta_tx_ccq_#{idx}",
sensor_oid: "#{@wl_stat_table}.9.#{idx}",
sensor_descr: "#{prefix} Tx CCQ",
sensor_unit: "%",
sensor_divisor: 1,
last_value: tx_ccq / 1
}
| sensors
]
else
sensors
end
# Rx CCQ (.10)
sensors =
if rx_ccq = get_int_value(values, 10) do
[
%{
sensor_type: "ccq",
sensor_index: "mikrotik_sta_rx_ccq_#{idx}",
sensor_oid: "#{@wl_stat_table}.10.#{idx}",
sensor_descr: "#{prefix} Rx CCQ",
sensor_unit: "%",
sensor_divisor: 1,
last_value: rx_ccq / 1
}
| sensors
]
else
sensors
end
# Tx Rate (.2)
sensors =
if tx_rate = get_int_value(values, 2) do
[
%{
sensor_type: "rate",
sensor_index: "mikrotik_sta_tx_rate_#{idx}",
sensor_oid: "#{@wl_stat_table}.2.#{idx}",
sensor_descr: "#{prefix} Tx Rate",
sensor_unit: "bps",
sensor_divisor: 1,
last_value: tx_rate / 1
}
| sensors
]
else
sensors
end
# Rx Rate (.3)
sensors =
if rx_rate = get_int_value(values, 3) do
[
%{
sensor_type: "rate",
sensor_index: "mikrotik_sta_rx_rate_#{idx}",
sensor_oid: "#{@wl_stat_table}.3.#{idx}",
sensor_descr: "#{prefix} Rx Rate",
sensor_unit: "bps",
sensor_divisor: 1,
last_value: rx_rate / 1
}
| sensors
]
else
sensors
end
sensors
end
# Discover sensors from mtxrWl60GTable (60GHz wireless)
defp discover_60g_sensors(client_opts) do
case Client.walk(client_opts, @wl_60g_table) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
parse_60g_table(results)
_ ->
[]
end
end
defp parse_60g_table(results) do
grouped = group_by_index(results)
Enum.flat_map(grouped, fn {idx, values} ->
ssid = get_string_value(values, 2) || "60G-#{idx}"
prefix = "60G: #{ssid}"
build_60g_sensors(idx, prefix, values)
end)
end
defp build_60g_sensors(idx, prefix, values) do
sensors = []
# Frequency (.6)
sensors =
if freq = get_int_value(values, 6) do
[
%{
sensor_type: "frequency",
sensor_index: "mikrotik_60g_freq_#{idx}",
sensor_oid: "#{@wl_60g_table}.6.#{idx}",
sensor_descr: "#{prefix} Frequency",
sensor_unit: "MHz",
sensor_divisor: 1,
last_value: freq / 1
}
| sensors
]
else
sensors
end
# Signal Quality (.8)
sensors =
if quality = get_int_value(values, 8) do
[
%{
sensor_type: "quality",
sensor_index: "mikrotik_60g_quality_#{idx}",
sensor_oid: "#{@wl_60g_table}.8.#{idx}",
sensor_descr: "#{prefix} Signal Quality",
sensor_unit: "%",
sensor_divisor: 1,
last_value: quality / 1
}
| sensors
]
else
sensors
end
# RSSI (.12)
sensors =
if rssi = get_int_value(values, 12) do
[
%{
sensor_type: "rssi",
sensor_index: "mikrotik_60g_rssi_#{idx}",
sensor_oid: "#{@wl_60g_table}.12.#{idx}",
sensor_descr: "#{prefix} RSSI",
sensor_unit: "dBm",
sensor_divisor: 1,
last_value: rssi / 1
}
| sensors
]
else
sensors
end
# Phy Rate (.13)
sensors =
if phy_rate = get_int_value(values, 13) do
[
%{
sensor_type: "rate",
sensor_index: "mikrotik_60g_phy_rate_#{idx}",
sensor_oid: "#{@wl_60g_table}.13.#{idx}",
sensor_descr: "#{prefix} Phy Rate",
sensor_unit: "Mbps",
sensor_divisor: 1,
last_value: phy_rate / 1
}
| sensors
]
else
sensors
end
sensors
end
# Discover sensors from mtxrLTEModemTable
defp discover_lte_sensors(client_opts) do
case Client.walk(client_opts, @lte_modem_table) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
parse_lte_table(results)
_ ->
[]
end
end
defp parse_lte_table(results) do
grouped = group_by_index(results)
Enum.flat_map(grouped, fn {idx, values} ->
prefix = "LTE #{idx}"
build_lte_sensors(idx, prefix, values)
end)
end
defp build_lte_sensors(idx, prefix, values) do
sensors = []
# RSRQ (.3)
sensors =
if rsrq = get_int_value(values, 3) do
[
%{
sensor_type: "rsrq",
sensor_index: "mikrotik_lte_rsrq_#{idx}",
sensor_oid: "#{@lte_modem_table}.3.#{idx}",
sensor_descr: "#{prefix} RSRQ",
sensor_unit: "dB",
sensor_divisor: 1,
last_value: rsrq / 1
}
| sensors
]
else
sensors
end
# RSRP (.4)
sensors =
if rsrp = get_int_value(values, 4) do
[
%{
sensor_type: "rsrp",
sensor_index: "mikrotik_lte_rsrp_#{idx}",
sensor_oid: "#{@lte_modem_table}.4.#{idx}",
sensor_descr: "#{prefix} RSRP",
sensor_unit: "dBm",
sensor_divisor: 1,
last_value: rsrp / 1
}
| sensors
]
else
sensors
end
# SINR (.7)
sensors =
if sinr = get_int_value(values, 7) do
[
%{
sensor_type: "sinr",
sensor_index: "mikrotik_lte_sinr_#{idx}",
sensor_oid: "#{@lte_modem_table}.7.#{idx}",
sensor_descr: "#{prefix} SINR",
sensor_unit: "dB",
sensor_divisor: 1,
last_value: sinr / 1
}
| sensors
]
else
sensors
end
sensors
end
# Discover system-level sensors (DHCP leases, PPPoE sessions)
defp discover_system_sensors(client_opts) do
Vendor.fetch_sensors(wireless_oid_defs(), client_opts)
end
# Helper: Group OID results by their index (last component)
defp group_by_index(results) do
Enum.reduce(results, %{}, fn {oid, value}, acc ->
parts = String.split(oid, ".")
# Get the sub-OID (second to last) and index (last)
{sub_oid, idx} =
case Enum.take(parts, -2) do
[sub, index] -> {String.to_integer(sub), index}
_ -> {0, "0"}
end
Map.update(acc, idx, %{sub_oid => value}, fn existing ->
Map.put(existing, sub_oid, value)
end)
end)
end
# Helper: Get integer value from grouped results
defp get_int_value(values, sub_oid) do
case Map.get(values, sub_oid) do
value when is_integer(value) and value != 0 -> value
_ -> nil
end
end
# Helper: Get string value from grouped results
defp get_string_value(values, sub_oid) do
case Map.get(values, sub_oid) do
value when is_binary(value) and value != "" -> value
_ -> nil
end
end
end

View file

@ -0,0 +1,130 @@
defmodule Towerops.Snmp.Profiles.Vendors.Vendor do
@moduledoc """
Behaviour for vendor-specific SNMP device handling.
Each vendor module (Epmp, Airos, Routeros, etc.) implements this behaviour
to provide device-specific discovery logic, mirroring LibreNMS OS PHP classes.
"""
alias Towerops.Snmp.Client
@type sensor :: %{
sensor_type: String.t(),
sensor_index: String.t(),
sensor_oid: String.t(),
sensor_descr: String.t(),
sensor_unit: String.t(),
sensor_divisor: integer(),
last_value: float() | nil
}
@type sensor_def :: %{
oid: String.t(),
sensor_type: String.t(),
sensor_descr: String.t(),
sensor_unit: String.t(),
sensor_divisor: integer()
}
@doc """
Returns the list of profile names this vendor module handles.
"""
@callback profile_names() :: [String.t()]
@doc """
Detect hardware type/model from SNMP data.
Returns a string describing the hardware (e.g., "ePMP AP", "MikroTik hAP ac2").
"""
@callback detect_hardware(Client.connection_opts()) :: String.t() | nil
@doc """
Discover wireless sensors specific to this vendor.
Returns a list of sensor maps ready to be saved to the database.
"""
@callback discover_wireless_sensors(Client.connection_opts()) :: [sensor()]
@doc """
Returns the list of wireless OID definitions for debug data collection.
"""
@callback wireless_oid_defs() :: [sensor_def()]
# Helper functions shared by all vendor modules
@doc """
Fetch wireless sensor values from scalar OIDs.
"""
@spec fetch_sensors([sensor_def()], Client.connection_opts()) :: [sensor()]
def fetch_sensors(sensor_defs, client_opts) do
sensor_defs
|> Enum.map(&fetch_sensor_value(&1, client_opts))
|> Enum.reject(&is_nil/1)
end
@doc """
Fetch a single sensor value from a scalar OID.
"""
@spec fetch_sensor_value(sensor_def(), Client.connection_opts()) :: sensor() | nil
def fetch_sensor_value(sensor_def, client_opts) do
case Client.get(client_opts, sensor_def.oid) do
{:ok, value} when is_integer(value) ->
# Use OID suffix to make sensor_index unique
oid_suffix = sensor_def.oid |> String.split(".") |> Enum.take(-2) |> Enum.join("_")
descr_slug = sensor_def.sensor_descr |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "_")
%{
sensor_type: sensor_def.sensor_type,
sensor_index: "wireless_#{descr_slug}_#{oid_suffix}",
sensor_oid: sensor_def.oid,
sensor_descr: sensor_def.sensor_descr,
sensor_unit: sensor_def.sensor_unit,
sensor_divisor: sensor_def.sensor_divisor,
last_value: value / 1
}
_ ->
nil
end
end
@doc """
Walk an SNMP table and create sensors from the results.
Used for per-interface or per-client wireless sensors.
"""
@spec walk_table_sensors(String.t(), sensor_def(), Client.connection_opts()) :: [sensor()]
def walk_table_sensors(base_oid, sensor_def, client_opts) do
case Client.walk(client_opts, base_oid) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
results
|> Enum.with_index(1)
|> Enum.map(fn {{oid, value}, idx} ->
build_table_sensor(sensor_def, oid, value, idx)
end)
|> Enum.reject(&is_nil/1)
{:ok, results} when is_list(results) and results != [] ->
results
|> Enum.with_index(1)
|> Enum.map(fn {%{oid: oid, value: value}, idx} ->
build_table_sensor(sensor_def, oid, value, idx)
end)
|> Enum.reject(&is_nil/1)
_ ->
[]
end
end
defp build_table_sensor(sensor_def, oid, value, idx) when is_integer(value) do
%{
sensor_type: sensor_def.sensor_type,
sensor_index: "#{sensor_def.sensor_type}_#{idx}",
sensor_oid: oid,
sensor_descr: "#{sensor_def.sensor_descr} #{idx}",
sensor_unit: sensor_def.sensor_unit,
sensor_divisor: sensor_def.sensor_divisor,
last_value: value / 1
}
end
defp build_table_sensor(_sensor_def, _oid, _value, _idx), do: nil
end