feat: add wireless sensor discovery and fix YAML sensor parsing

- Add wireless sensor discovery for ePMP and AirOS devices
- Implement vendor-specific OIDs matching LibreNMS PHP discovery
- Fix table/state sensor discovery to handle map format from Client.walk
- Add discovered_sensors to debug data with values and state descriptions
- ePMP: frequency, clients, rssi, snr (rssi/snr for SM only)
- AirOS: frequency, capacity, ccq, clients, distance, noise-floor, power, quality, rate, rssi, utilization
This commit is contained in:
Graham McIntire 2026-01-21 17:15:00 -06:00
parent 12875d7f48
commit 74c9544688
No known key found for this signature in database
4 changed files with 913 additions and 43 deletions

View file

@ -36,16 +36,26 @@ defmodule Towerops.Profiles.YamlProfiles do
|> Enum.map(fn {_, profile} -> profile end)
end
# Generic OS profiles that are checked last (like LibreNMS)
# These are fallback profiles for common Linux-based devices
@generic_os ~w(airos freebsd linux)
@doc """
Matches a profile based on system info (sysObjectID or sysDescr).
Returns the best matching profile or nil.
Uses LibreNMS's two-pass detection approach:
1. First pass: Check profiles WITHOUT snmpget/snmpwalk conditions (except generic OS)
2. Second pass: Check profiles WITH snmpget/snmpwalk conditions (deferred)
- Since we can't evaluate snmpget, these will effectively be skipped
3. Third pass: Check generic OS (airos, freebsd, linux) as final fallback
"""
def match_profile(system_info) do
sys_object_id = Map.get(system_info, :sys_object_id, "")
sys_descr = Map.get(system_info, :sys_descr, "")
# First try to match by sysObjectID (more specific)
case match_by_oid(sys_object_id) do
# First try to match by sysObjectID using LibreNMS's approach
case match_by_oid_librenms_style(sys_object_id, sys_descr) do
nil -> match_by_descr(sys_descr)
profile -> profile
end
@ -132,26 +142,108 @@ defmodule Towerops.Profiles.YamlProfiles do
vendor: Map.get(detection, "text") || String.capitalize(name),
detection_oid: extract_detection_oid(detection),
detection_patterns: extract_detection_patterns(detection),
# Compound detection: sysDescr/sysDescr_regex that must match alongside sysObjectID
detection_descr_patterns: extract_detection_descr_patterns(detection),
detection_descr_regex: extract_detection_descr_regex(detection),
# Extract all detection blocks with their conditions for per-block matching
detection_blocks: extract_detection_blocks(detection),
device_oids: extract_device_oids(discovery),
sensor_oids: extract_sensor_oids(discovery)
# Scalar sensors (direct OID polling)
sensor_oids: extract_sensor_oids(discovery),
# Table-based sensors (OID walks) - all standard sensor types
table_sensor_oids: extract_table_sensor_oids(discovery),
# Additional sensor types like LibreNMS
processor_oids: extract_processor_oids(discovery),
count_sensor_oids: extract_count_sensor_oids(discovery),
state_sensor_oids: extract_state_sensor_oids(discovery)
}
end
defp extract_detection_oid(yaml) do
case get_in(yaml, ["discovery"]) do
discovery when is_list(discovery) ->
# Extract OIDs from all discovery blocks, including those with snmpget/snmpwalk conditions
# We can't evaluate snmpget conditions, but we can still match on OID + sysDescr constraints
# This allows profiles like airos (which has snmpget condition) to still match by OID
discovery
|> Enum.flat_map(fn
%{"sysObjectID" => patterns} when is_list(patterns) -> patterns
%{"sysObjectID" => pattern} when is_binary(pattern) -> [pattern]
_ -> []
end)
|> List.first()
|> normalize_oid()
_ ->
nil
end
end
# Extract all detection blocks with their OIDs, sysDescr constraints, and condition flags
# This enables per-block matching (a profile may have multiple detection blocks)
defp extract_detection_blocks(yaml) do
case get_in(yaml, ["discovery"]) do
discovery when is_list(discovery) ->
Enum.flat_map(discovery, &parse_discovery_block/1)
_ ->
[]
end
end
defp parse_discovery_block(block) do
oids = extract_oids_from_block(block)
has_condition = Map.has_key?(block, "snmpget") or Map.has_key?(block, "snmpwalk")
descr_patterns = extract_descr_from_block(block)
descr_regex = extract_descr_regex_from_block(block)
Enum.map(oids, fn oid ->
%{
oid: normalize_oid(oid),
has_condition: has_condition,
descr_patterns: descr_patterns,
descr_regex: descr_regex
}
end)
end
defp extract_oids_from_block(block) do
case Map.get(block, "sysObjectID") do
patterns when is_list(patterns) -> patterns
pattern when is_binary(pattern) -> [pattern]
_ -> []
end
end
defp extract_descr_from_block(block) do
case Map.get(block, "sysDescr") do
patterns when is_list(patterns) -> patterns
pattern when is_binary(pattern) -> [pattern]
_ -> []
end
end
defp extract_descr_regex_from_block(block) do
case Map.get(block, "sysDescr_regex") do
patterns when is_list(patterns) ->
patterns |> Enum.map(&parse_regex_pattern/1) |> Enum.reject(&is_nil/1)
pattern when is_binary(pattern) ->
case parse_regex_pattern(pattern) do
nil -> []
regex -> [regex]
end
_ ->
[]
end
end
# Strip leading dot from OID (LibreNMS uses .1.3.6... but SNMP returns 1.3.6...)
defp normalize_oid(nil), do: nil
defp normalize_oid("." <> rest), do: rest
defp normalize_oid(oid), do: oid
defp extract_detection_patterns(yaml) do
case get_in(yaml, ["discovery"]) do
discovery when is_list(discovery) ->
@ -165,6 +257,64 @@ defmodule Towerops.Profiles.YamlProfiles do
end
end
# Extract sysDescr patterns that must match alongside sysObjectID (compound detection)
defp extract_detection_descr_patterns(yaml) do
case get_in(yaml, ["discovery"]) do
discovery when is_list(discovery) ->
# Only extract from discovery blocks that have BOTH sysObjectID and sysDescr
Enum.flat_map(discovery, fn
%{"sysObjectID" => _, "sysDescr" => patterns} when is_list(patterns) -> patterns
%{"sysObjectID" => _, "sysDescr" => pattern} when is_binary(pattern) -> [pattern]
_ -> []
end)
_ ->
[]
end
end
# Extract sysDescr_regex patterns that must match alongside sysObjectID (compound detection)
defp extract_detection_descr_regex(yaml) do
case get_in(yaml, ["discovery"]) do
discovery when is_list(discovery) ->
# Only extract from discovery blocks that have BOTH sysObjectID and sysDescr_regex
discovery
|> Enum.flat_map(fn
%{"sysObjectID" => _, "sysDescr_regex" => patterns} when is_list(patterns) ->
Enum.map(patterns, &parse_regex_pattern/1)
_ ->
[]
end)
|> Enum.reject(&is_nil/1)
_ ->
[]
end
end
# Parse LibreNMS regex pattern format: '/pattern/' or '/pattern/i'
defp parse_regex_pattern(pattern) when is_binary(pattern) do
case Regex.run(~r/^\/(.+)\/([i]?)$/, pattern) do
[_, regex_str, "i"] ->
case Regex.compile(regex_str, [:caseless]) do
{:ok, regex} -> regex
_ -> nil
end
[_, regex_str, ""] ->
case Regex.compile(regex_str) do
{:ok, regex} -> regex
_ -> nil
end
_ ->
nil
end
end
defp parse_regex_pattern(_), do: nil
defp extract_device_oids(yaml) do
os_module = get_in(yaml, ["modules", "os"]) || %{}
@ -188,15 +338,19 @@ defmodule Towerops.Profiles.YamlProfiles do
|> Map.new()
end
# All sensor types supported by LibreNMS
@sensor_types ~w(temperature voltage current power fanspeed humidity frequency
dbm signal snr airflow load runtime waterflow pressure cooling charge)
# Extract scalar sensors (direct OID polling with .0 suffix)
defp extract_sensor_oids(yaml) do
sensors_module = get_in(yaml, ["modules", "sensors"]) || %{}
sensor_types = ["temperature", "voltage", "current", "power", "fanspeed"]
Enum.flat_map(sensor_types, fn sensor_type ->
Enum.flat_map(@sensor_types, fn sensor_type ->
case get_in(sensors_module, [sensor_type, "data"]) do
data when is_list(data) ->
data
|> Enum.map(&parse_sensor(&1, sensor_type))
|> Enum.map(&parse_scalar_sensor(&1, sensor_type))
|> Enum.reject(&is_nil/1)
_ ->
@ -205,26 +359,64 @@ defmodule Towerops.Profiles.YamlProfiles do
end)
end
defp parse_sensor(sensor_def, sensor_type) do
# Extract table-based sensors (OID walks with {{ $index }})
defp extract_table_sensor_oids(yaml) do
sensors_module = get_in(yaml, ["modules", "sensors"]) || %{}
Enum.flat_map(@sensor_types, fn sensor_type ->
case get_in(sensors_module, [sensor_type, "data"]) do
data when is_list(data) ->
data
|> Enum.map(&parse_table_sensor_def(&1, sensor_type))
|> Enum.reject(&is_nil/1)
_ ->
[]
end
end)
end
# Parse a scalar sensor (direct OID ending in .0)
defp parse_scalar_sensor(sensor_def, sensor_type) do
mib_name = Map.get(sensor_def, "oid") || Map.get(sensor_def, "value")
num_oid = Map.get(sensor_def, "num_oid")
# Skip table-based sensors (with {{ $index }})
cond do
is_binary(num_oid) and String.contains?(num_oid, "{{ $index }}") ->
nil
# Only include if it's a scalar sensor (no {{ $index }} and ends with .0)
is_table = is_binary(num_oid) and String.contains?(num_oid, "{{ $index }}")
is_scalar = is_binary(mib_name) and String.ends_with?(mib_name, ".0")
is_binary(mib_name) and String.ends_with?(mib_name, ".0") ->
if !is_table and is_scalar do
%{
sensor_type: sensor_type,
mib_name: mib_name,
sensor_descr: extract_descr(sensor_def, sensor_type),
sensor_unit: Map.get(sensor_def, "unit"),
sensor_divisor: Map.get(sensor_def, "divisor", 1)
}
end
end
# Parse a table-based sensor definition (has {{ $index }} placeholder)
defp parse_table_sensor_def(sensor_def, sensor_type) do
num_oid = Map.get(sensor_def, "num_oid")
oid_name = Map.get(sensor_def, "oid")
# Only include if it's a table sensor (has {{ $index }})
if is_binary(num_oid) and String.contains?(num_oid, "{{ $index }}") do
base_oid = extract_base_oid(num_oid)
if base_oid do
%{
sensor_type: sensor_type,
mib_name: mib_name,
base_oid: base_oid,
oid_name: oid_name,
sensor_descr: extract_descr(sensor_def, sensor_type),
sensor_unit: Map.get(sensor_def, "unit"),
sensor_divisor: Map.get(sensor_def, "divisor", 1)
sensor_divisor: Map.get(sensor_def, "divisor", 1),
precision: Map.get(sensor_def, "precision", 1),
table: true
}
true ->
nil
end
end
end
@ -242,16 +434,203 @@ defmodule Towerops.Profiles.YamlProfiles do
end
end
defp match_by_oid(sys_object_id) when is_binary(sys_object_id) and sys_object_id != "" do
# Try exact match first, then prefix matches
list_profiles()
|> Enum.filter(fn profile ->
profile.detection_oid && String.contains?(sys_object_id, profile.detection_oid)
end)
|> Enum.max_by(fn profile -> String.length(profile.detection_oid || "") end, fn -> nil end)
# Extract processor/CPU sensors (like LibreNMS processors module)
defp extract_processor_oids(yaml) do
case get_in(yaml, ["modules", "processors", "data"]) do
data when is_list(data) ->
data
|> Enum.map(&parse_table_sensor(&1, "processor", "%"))
|> Enum.reject(&is_nil/1)
_ ->
[]
end
end
defp match_by_oid(_), do: nil
# Extract count sensors (like LibreNMS sensors.count module)
defp extract_count_sensor_oids(yaml) do
case get_in(yaml, ["modules", "sensors", "count", "data"]) do
data when is_list(data) ->
data
|> Enum.map(&parse_table_sensor(&1, "count", ""))
|> Enum.reject(&is_nil/1)
_ ->
[]
end
end
# Extract state sensors (like LibreNMS sensors.state module)
defp extract_state_sensor_oids(yaml) do
case get_in(yaml, ["modules", "sensors", "state", "data"]) do
data when is_list(data) ->
data
|> Enum.map(&parse_state_sensor/1)
|> Enum.reject(&is_nil/1)
_ ->
[]
end
end
# Parse a table-based sensor (walks the OID table to find all instances)
defp parse_table_sensor(sensor_def, sensor_type, default_unit) do
num_oid = Map.get(sensor_def, "num_oid")
oid_name = Map.get(sensor_def, "oid")
base_oid = extract_base_oid(num_oid)
if base_oid do
%{
sensor_type: sensor_type,
base_oid: base_oid,
oid_name: oid_name,
sensor_descr: extract_descr(sensor_def, sensor_type),
sensor_unit: Map.get(sensor_def, "unit") || default_unit,
sensor_divisor: Map.get(sensor_def, "divisor", 1),
precision: Map.get(sensor_def, "precision", 1),
table: true
}
end
end
# Parse a state sensor with discrete values
defp parse_state_sensor(sensor_def) do
num_oid = Map.get(sensor_def, "num_oid")
oid_name = Map.get(sensor_def, "oid")
states = Map.get(sensor_def, "states", [])
base_oid = extract_base_oid(num_oid)
if base_oid do
# Build state mapping: value => description
state_map =
Map.new(states, fn state ->
{Map.get(state, "value"), Map.get(state, "descr", "Unknown")}
end)
%{
sensor_type: "state",
base_oid: base_oid,
oid_name: oid_name,
sensor_descr: extract_descr(sensor_def, "state"),
sensor_unit: "",
states: state_map,
table: true
}
end
end
# Extract base OID from num_oid by removing the {{ $index }} placeholder
defp extract_base_oid(nil), do: nil
defp extract_base_oid(num_oid) when is_binary(num_oid) do
# Remove {{ $index }} and trailing dot, clean up the OID
num_oid
|> String.replace(~r/\.\{\{\s*\$index\s*\}\}/, "")
|> String.replace(~r/^\.*/, "")
|> case do
"" -> nil
oid -> oid
end
end
# LibreNMS-style OS detection with proper priority ordering
# 1. First pass: Match profiles by unconditional blocks (no snmpget/snmpwalk), excluding generic_os
# 2. Second pass: Generic OS (airos, freebsd, linux) with unconditional blocks as fallback
# 3. Third pass: Generic OS with conditional blocks (last resort for profiles like airos)
#
# We check per-block, not per-profile. A profile may have both conditional and unconditional
# blocks for different OIDs (e.g., ibm-imm has snmpget block for one OID and plain block for another)
defp match_by_oid_librenms_style(sys_object_id, sys_descr) when is_binary(sys_object_id) and sys_object_id != "" do
all_profiles = list_profiles()
# Categorize profiles
{generic_profiles, other_profiles} =
Enum.split_with(all_profiles, fn p -> p.name in @generic_os end)
# First pass: non-generic profiles with unconditional matching blocks
case find_best_unconditional_match(other_profiles, sys_object_id, sys_descr) do
nil ->
# Second pass: generic OS with unconditional blocks
case find_best_unconditional_match(generic_profiles, sys_object_id, sys_descr) do
nil ->
# Third pass: generic OS with conditional blocks (for profiles like airos)
find_best_conditional_match(generic_profiles, sys_object_id, sys_descr)
profile ->
profile
end
profile ->
profile
end
end
defp match_by_oid_librenms_style(_, _), do: nil
# Find best match using only unconditional detection blocks (no snmpget/snmpwalk)
defp find_best_unconditional_match(profiles, sys_object_id, sys_descr) do
find_best_block_match(profiles, sys_object_id, sys_descr, false)
end
# Find best match using conditional detection blocks (has snmpget/snmpwalk)
defp find_best_conditional_match(profiles, sys_object_id, sys_descr) do
find_best_block_match(profiles, sys_object_id, sys_descr, true)
end
# Find the best matching profile based on detection blocks
defp find_best_block_match(profiles, sys_object_id, sys_descr, match_conditional) do
profiles
|> Enum.flat_map(&find_profile_matching_blocks(&1, sys_object_id, sys_descr, match_conditional))
|> select_best_match()
end
defp find_profile_matching_blocks(profile, sys_object_id, sys_descr, match_conditional) do
matching_blocks =
Enum.filter(profile.detection_blocks, fn block ->
block_matches?(block, sys_object_id, sys_descr, match_conditional)
end)
case matching_blocks do
[] -> []
blocks -> [{profile, Enum.max_by(blocks, &String.length(&1.oid || ""))}]
end
end
defp block_matches?(block, sys_object_id, sys_descr, match_conditional) do
block.has_condition == match_conditional &&
block.oid &&
String.contains?(sys_object_id, block.oid) &&
block_matches_descr?(block, sys_descr)
end
defp select_best_match([]), do: nil
defp select_best_match(matches) do
{profile, _block} = Enum.max_by(matches, fn {_p, block} -> String.length(block.oid || "") end)
profile
end
# Check if a detection block's sysDescr constraints are satisfied
defp block_matches_descr?(block, sys_descr) do
patterns = block.descr_patterns || []
regexes = block.descr_regex || []
cond do
# No constraints - OID match is sufficient
patterns == [] and regexes == [] ->
true
# Has pattern constraints - must match at least one
patterns != [] ->
Enum.any?(patterns, fn pattern -> String.contains?(sys_descr, pattern) end)
# Has regex constraints - must match at least one
regexes != [] ->
Enum.any?(regexes, fn regex -> Regex.match?(regex, sys_descr) end)
end
end
defp match_by_descr(sys_descr) when is_binary(sys_descr) and sys_descr != "" do
Enum.find(list_profiles(), fn profile ->

View file

@ -286,7 +286,22 @@ defmodule Towerops.Snmp.Discovery do
defp build_device_info(client_opts, system_info, {:yaml, profile}) do
# Use YAML profile to identify device
identified_info = Dynamic.identify_device(profile, client_opts, system_info)
{:ok, identified_info}
# Collect raw debug data using Base profile (works for any device)
raw_data = Base.collect_raw_debug_data(client_opts)
# Add vendor-specific debug data (wireless sensors, etc.)
vendor_data = Dynamic.collect_vendor_debug_data(profile, client_opts)
raw_data_with_vendor = Map.merge(raw_data, vendor_data)
Logger.info("Collected raw debug data for YAML profile: #{map_size(raw_data_with_vendor)} keys")
identified_info_with_debug =
identified_info
|> Map.put(:_raw_discovery_data, raw_data_with_vendor)
|> Map.put(:_last_discovery_at, DateTime.utc_now())
{:ok, identified_info_with_debug}
rescue
error ->
Logger.error("Failed to identify device with YAML profile: #{inspect(error)}")

View file

@ -28,11 +28,17 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
# Format firmware version (MikroTik has special formatting with license level)
firmware_version = format_firmware_version(profile, device_data)
# Get hardware model - use vendor-specific detection, then YAML hardware OID, then sysDescr
model =
detect_vendor_hardware(profile, client_opts) ||
Map.get(device_data, :hardware) ||
system_info[:sys_descr]
# Merge with system_info
system_info
|> Map.merge(%{
manufacturer: profile.vendor,
model: system_info[:sys_descr],
model: model,
firmware_version: firmware_version,
serial_number: Map.get(device_data, :serial_number),
latitude: Map.get(device_data, :latitude),
@ -42,28 +48,51 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
end
@doc """
Discovers sensors using profile's sensor_oids.
Translates MIB names and polls sensors.
Falls back to Base ENTITY-SENSOR-MIB discovery if profile has no sensor_oids.
Discovers sensors using profile's sensor_oids and table-based sensors.
Combines profile-specific sensors with standard ENTITY-SENSOR-MIB discovery.
Works like LibreNMS - discovers all sensor types (scalar and table-based).
"""
@spec discover_sensors(map(), Client.connection_opts()) ::
{:ok, [Discovery.sensor_data()]}
def discover_sensors(profile, client_opts) do
alias Towerops.Snmp.Profiles.Base
sensor_oids = Map.get(profile, :sensor_oids, [])
# Always discover ENTITY-SENSOR-MIB sensors (most devices support this)
{:ok, base_sensors} = Base.discover_sensors(client_opts)
if Enum.empty?(sensor_oids) do
# Fall back to ENTITY-SENSOR-MIB discovery from Base profile
Base.discover_sensors(client_opts)
else
sensors =
sensor_oids
|> Enum.map(&fetch_sensor_value(&1, client_opts))
|> Enum.reject(&is_nil/1)
# Scalar sensors from profile (direct OID polling)
scalar_sensors = Map.get(profile, :sensor_oids, [])
{:ok, sensors}
end
scalar_results =
scalar_sensors
|> Enum.map(&fetch_sensor_value(&1, client_opts))
|> Enum.reject(&is_nil/1)
# Table-based sensors from profile (OID walks for all sensor types)
table_sensor_oids = Map.get(profile, :table_sensor_oids, [])
processor_oids = Map.get(profile, :processor_oids, [])
count_sensor_oids = Map.get(profile, :count_sensor_oids, [])
state_sensor_oids = Map.get(profile, :state_sensor_oids, [])
# Walk table sensors (temperature, voltage, power, etc. with {{ $index }})
table_results = discover_table_sensors(table_sensor_oids, client_opts)
processor_results = discover_table_sensors(processor_oids, client_opts)
count_results = discover_table_sensors(count_sensor_oids, client_opts)
state_results = discover_state_sensors(state_sensor_oids, client_opts)
# Wireless sensors (vendor-specific OIDs like those in LibreNMS PHP files)
wireless_results = discover_vendor_wireless_sensors(profile, client_opts)
Logger.debug("Discovered #{length(wireless_results)} wireless sensors for profile #{profile.name}")
# Combine all sensors - profile-specific sensors first, then base sensors
all_sensors =
scalar_results ++
table_results ++
processor_results ++
count_results ++
state_results ++ wireless_results ++ base_sensors
{:ok, all_sensors}
end
@doc """
@ -77,8 +106,71 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
Base.discover_interfaces(client_opts)
end
@doc """
Discovers wireless sensors using vendor-specific OIDs.
Mirrors LibreNMS PHP-based wireless sensor discovery (e.g., Airos.php, Epmp.php).
Returns sensors for frequency, rssi, snr, clients, ccq, power, etc.
"""
@spec discover_wireless_sensors(map(), Client.connection_opts()) ::
{:ok, [Discovery.sensor_data()]}
def discover_wireless_sensors(profile, client_opts) do
sensors = discover_vendor_wireless_sensors(profile, client_opts)
{:ok, sensors}
end
# 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)
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) ->
@ -135,4 +227,386 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
nil
end
end
# Discover table-based sensors by walking the base OID
defp discover_table_sensors(sensor_defs, client_opts) do
Enum.flat_map(sensor_defs, &walk_table_sensor(&1, client_opts))
end
defp walk_table_sensor(sensor_def, client_opts) do
base_oid = sensor_def[:base_oid]
case Client.walk(client_opts, base_oid) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
parse_table_walk_results(results, sensor_def)
{:ok, results} when is_list(results) and results != [] ->
# Handle list format if Client returns that
parse_table_walk_results_list(results, sensor_def)
_ ->
[]
end
end
defp parse_table_walk_results(results, sensor_def) when is_map(results) do
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)
end
defp parse_table_walk_results_list(results, sensor_def) do
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
# Build a sensor from table walk result
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: build_sensor_descr(sensor_def, idx),
sensor_unit: sensor_def[:sensor_unit] || "",
sensor_divisor: sensor_def[:sensor_divisor] || 1,
sensor_value: value
}
end
defp build_table_sensor(_sensor_def, _oid, _value, _idx), do: nil
# Discover state sensors (sensors with discrete states like GPS Status, DFS Status)
defp discover_state_sensors(sensor_defs, client_opts) do
Enum.flat_map(sensor_defs, &walk_state_sensor(&1, client_opts))
end
defp walk_state_sensor(sensor_def, client_opts) do
base_oid = sensor_def[:base_oid]
case Client.walk(client_opts, base_oid) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
parse_state_walk_results(results, sensor_def)
{:ok, results} when is_list(results) and results != [] ->
# Handle list format if Client returns that
parse_state_walk_results_list(results, sensor_def)
_ ->
[]
end
end
defp parse_state_walk_results(results, sensor_def) when is_map(results) do
results
|> Enum.with_index(1)
|> Enum.map(fn {{oid, value}, idx} ->
build_state_sensor(sensor_def, oid, value, idx)
end)
|> Enum.reject(&is_nil/1)
end
defp parse_state_walk_results_list(results, sensor_def) do
results
|> Enum.with_index(1)
|> Enum.map(fn {%{oid: oid, value: value}, idx} ->
build_state_sensor(sensor_def, oid, value, idx)
end)
|> Enum.reject(&is_nil/1)
end
# Build a state sensor with value-to-description mapping
defp build_state_sensor(sensor_def, oid, value, idx) when is_integer(value) do
states = sensor_def[:states] || %{}
# Map numeric state to description, or use the raw value
state_descr = Map.get(states, value, "State #{value}")
%{
sensor_type: "state",
sensor_index: "#{sensor_def[:oid_name] || "state"}_#{idx}",
sensor_oid: oid,
sensor_descr: build_sensor_descr(sensor_def, idx),
sensor_unit: "",
sensor_divisor: 1,
sensor_value: value,
state_descr: state_descr
}
end
defp build_state_sensor(_sensor_def, _oid, _value, _idx), do: nil
defp build_sensor_descr(sensor_def, idx) do
base_descr = sensor_def[:sensor_descr] || sensor_def[:oid_name] || sensor_def[:sensor_type]
# If there's only one instance, don't add index
if idx == 1 do
String.capitalize(to_string(base_descr))
else
"#{String.capitalize(to_string(base_descr))} #{idx}"
end
end
@doc """
Collects all discovered sensor data for debugging purposes.
Includes YAML-defined sensors (processors, state, count, table) and wireless sensors.
"""
@spec collect_vendor_debug_data(map(), Client.connection_opts()) :: map()
def collect_vendor_debug_data(profile, client_opts) do
# Collect all sensors using the same discovery logic
{:ok, all_sensors} = discover_sensors(profile, client_opts)
# Format sensors for debug display with state descriptions
discovered_sensors =
Enum.map(all_sensors, fn sensor ->
base = %{
oid: sensor.sensor_oid,
descr: sensor.sensor_descr,
type: sensor.sensor_type,
value: sensor.sensor_value,
unit: sensor.sensor_unit || ""
}
# Add state description if present
if Map.has_key?(sensor, :state_descr) do
Map.put(base, :state_descr, sensor.state_descr)
else
base
end
end)
# Also collect raw wireless OID values (including errors for debugging)
wireless_oids = get_vendor_wireless_oids(profile)
wireless_raw =
Map.new(wireless_oids, fn sensor_def ->
value =
case Client.get(client_opts, sensor_def.oid) do
{:ok, val} -> val
{:error, reason} -> "ERROR: #{inspect(reason)}"
end
{sensor_def.sensor_type, %{oid: sensor_def.oid, descr: sensor_def.sensor_descr, value: value}}
end)
%{
discovered_sensors: discovered_sensors,
wireless_sensors: wireless_raw
}
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()
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)
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)
defp epmp_wireless_oid_defs do
[
# RSSI in dBm
%{
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
%{
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
%{
oid: "1.3.6.1.4.1.17713.21.1.2.10.0",
sensor_type: "clients",
sensor_descr: "Clients",
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) ->
%{
sensor_type: sensor_def.sensor_type,
sensor_index: "wireless_#{sensor_def.sensor_type}",
sensor_oid: sensor_def.oid,
sensor_descr: sensor_def.sensor_descr,
sensor_unit: sensor_def.sensor_unit,
sensor_divisor: sensor_def.sensor_divisor,
sensor_value: value
}
_ ->
nil
end
end
end

View file

@ -189,8 +189,10 @@ defmodule Towerops.Snmp.DiscoveryTest do
assert {:ok, device} = Discovery.discover_device(device)
assert device.manufacturer == "MikroTik"
assert device.model == "RB750"
# YAML profile "routeros" returns "Mikrotik RouterOS" as vendor
# Model comes from sysDescr when using YAML profiles
assert device.manufacturer == "Mikrotik RouterOS"
assert device.model == "RouterOS RB750"
assert device.sys_name == "router1"
# Verify interfaces were created