From 74c954468851fb7456388b3c56f21d05cc96a33b Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 21 Jan 2026 17:15:00 -0600 Subject: [PATCH] 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 --- lib/towerops/profiles/yaml_profiles.ex | 429 +++++++++++++++++++-- lib/towerops/snmp/discovery.ex | 17 +- lib/towerops/snmp/profiles/dynamic.ex | 504 ++++++++++++++++++++++++- test/towerops/snmp/discovery_test.exs | 6 +- 4 files changed, 913 insertions(+), 43 deletions(-) diff --git a/lib/towerops/profiles/yaml_profiles.ex b/lib/towerops/profiles/yaml_profiles.ex index e43a3a17..dc5a7a07 100644 --- a/lib/towerops/profiles/yaml_profiles.ex +++ b/lib/towerops/profiles/yaml_profiles.ex @@ -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 -> diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index 8061de05..ba8380e2 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -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)}") diff --git a/lib/towerops/snmp/profiles/dynamic.ex b/lib/towerops/snmp/profiles/dynamic.ex index 3639b9cf..f75cd748 100644 --- a/lib/towerops/snmp/profiles/dynamic.ex +++ b/lib/towerops/snmp/profiles/dynamic.ex @@ -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 diff --git a/test/towerops/snmp/discovery_test.exs b/test/towerops/snmp/discovery_test.exs index 0de7b429..e6afa625 100644 --- a/test/towerops/snmp/discovery_test.exs +++ b/test/towerops/snmp/discovery_test.exs @@ -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