fix: comprehensive ePMP/dynamic profile discovery improvements

- Fix divisor calculation in importer: use precision value directly instead of 10^precision
- Fix Dynamic profile to handle map format from Client.walk (was expecting list)
- Add processor discovery to Dynamic profile (converts CPU to percent sensors)
- Fix sensor index extraction to clean template variables
- Make sensor indices unique using descriptive names for scalar sensors
- Add status determination for CPU sensors (ok/warning/critical thresholds)

This enables proper discovery and monitoring of ePMP devices with:
- DFS count sensors
- GPS/DFS/antenna state sensors
- CPU usage monitoring

All sensors now discover correctly without unique constraint violations.
This commit is contained in:
Graham McIntire 2026-01-18 09:05:03 -06:00
parent 143d9dceb3
commit 28e9be773d
No known key found for this signature in database
2 changed files with 117 additions and 14 deletions

View file

@ -333,7 +333,9 @@ defmodule Towerops.DeviceProfiles.Importer do
defp calculate_divisor(nil), do: 1
defp calculate_divisor(precision) when is_integer(precision) and precision > 0 do
10 |> :math.pow(precision) |> trunc()
# Precision in device profiles is the divisor value itself, not an exponent
# For example: precision: 10 means divide by 10, precision: 1024 means divide by 1024
precision
end
defp calculate_divisor(_), do: 1

View file

@ -51,25 +51,37 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
@doc """
Discovers sensors dynamically using database profile definitions.
Walks SNMP OIDs defined in the profile's sensor_definitions.
Walks SNMP OIDs defined in the profile's sensor_definitions and processor_definitions.
"""
@spec discover_sensors(DeviceProfile.t(), Client.connection_opts()) ::
{:ok, [Discovery.sensor_data()]}
def discover_sensors(profile, client_opts) do
# Load profile with sensor definitions
# Load profile with sensor and processor definitions
profile = DeviceProfiles.get_profile_with_associations(profile.os)
if profile && length(profile.sensor_definitions) > 0 do
Logger.debug("Discovering sensors for #{profile.os} using #{length(profile.sensor_definitions)} definitions")
sensor_count = if profile, do: length(profile.sensor_definitions), else: 0
processor_count = if profile, do: length(profile.processor_definitions), else: 0
if profile && (sensor_count > 0 || processor_count > 0) do
Logger.debug(
"Discovering sensors for #{profile.os} (#{sensor_count} sensor defs, #{processor_count} processor defs)"
)
# Discover regular sensors
sensors =
Enum.flat_map(profile.sensor_definitions, fn sensor_def ->
discover_sensor_from_definition(client_opts, sensor_def)
end)
{:ok, sensors}
# Discover processors as sensors
cpu_sensors =
Enum.flat_map(profile.processor_definitions, fn proc_def ->
discover_processor_as_sensor(client_opts, proc_def)
end)
{:ok, sensors ++ cpu_sensors}
else
Logger.debug("No sensor definitions found for profile #{profile.os}")
Logger.debug("No sensor or processor definitions found for profile #{profile.os}")
{:ok, []}
end
end
@ -99,6 +111,35 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
defp extract_manufacturer(_), do: "Unknown"
defp discover_processor_as_sensor(client_opts, proc_def) do
# Try numeric OID first, fall back to named OID
oid = proc_def.num_oid || proc_def.oid
if oid do
# Remove template variables from OID
walk_oid = clean_oid_template(oid)
case Client.walk(client_opts, walk_oid) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
# Convert walk results (map) to CPU sensor data
Enum.map(results, fn {result_oid, value} ->
build_processor_sensor_data(proc_def, %{oid: result_oid, value: value})
end)
{:ok, _empty} ->
Logger.debug("No data found for processor OID: #{walk_oid}")
[]
{:error, reason} ->
Logger.warning("Failed to walk processor OID #{walk_oid}: #{inspect(reason)}")
[]
end
else
Logger.warning("Processor definition missing OID: #{inspect(proc_def)}")
[]
end
end
defp discover_sensor_from_definition(client_opts, sensor_def) do
# Try numeric OID first, fall back to named OID
oid = sensor_def.num_oid || sensor_def.oid
@ -109,10 +150,10 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
walk_oid = clean_oid_template(oid)
case Client.walk(client_opts, walk_oid) do
{:ok, results} when is_list(results) and results != [] ->
# Convert walk results to sensor data
Enum.map(results, fn result ->
build_sensor_data(sensor_def, result)
{:ok, results} when is_map(results) and map_size(results) > 0 ->
# Convert walk results (map) to sensor data
Enum.map(results, fn {result_oid, value} ->
build_sensor_data(sensor_def, %{oid: result_oid, value: value})
end)
{:ok, _empty} ->
@ -141,7 +182,11 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
defp build_sensor_data(sensor_def, %{oid: oid, value: value}) do
# Build sensor index from OID (extract last component)
sensor_index = extract_index_from_oid(oid, sensor_def.index)
oid_index = extract_index_from_oid(oid, sensor_def.index)
# Make index unique by combining sensor class with OID index
# This prevents conflicts when multiple sensors have the same OID index (e.g., ".0")
sensor_index = build_unique_sensor_index(sensor_def.sensor_class, sensor_def.descr, oid_index)
# Apply divisor to value if it's numeric
last_value =
@ -168,15 +213,54 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
}
end
defp build_processor_sensor_data(proc_def, %{oid: oid, value: value}) do
# Build sensor index from OID (extract last component)
sensor_index = extract_index_from_oid(oid, proc_def.index)
# Apply precision (divisor) to value if it's numeric
last_value =
cond do
is_number(value) && proc_def.precision && proc_def.precision > 1 ->
value / proc_def.precision
is_number(value) ->
value * 1.0
true ->
nil
end
# Determine status based on CPU load percentage
status =
cond do
is_nil(last_value) -> "unknown"
last_value < 70 -> "ok"
last_value < 90 -> "warning"
true -> "critical"
end
%{
sensor_type: "percent",
sensor_index: "cpu#{sensor_index}",
sensor_oid: oid,
sensor_descr: proc_def.descr || "CPU #{sensor_index}",
sensor_unit: "%",
sensor_divisor: proc_def.precision || 1,
last_value: last_value,
status: status
}
end
defp extract_index_from_oid(oid, configured_index) when is_binary(configured_index) do
# If index is pre-configured, use it
if configured_index == "" do
# If index contains template variable or is empty, extract from OID
if configured_index == "" or String.contains?(configured_index, "{{") do
# Extract last component of OID as index
oid
|> String.split(".")
|> List.last()
|> to_string()
else
# Use pre-configured static index
configured_index
end
end
@ -189,6 +273,23 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|> to_string()
end
defp build_unique_sensor_index(sensor_class, descr, oid_index) do
# For scalar sensors (index 0), use a descriptive name from the description
# For table sensors, use the OID index
if oid_index == "0" && descr do
# Convert description to a safe index string
# E.g., "DFS Detected Count" -> "dfs_detected_count"
# E.g., "GPS Status" -> "gps_status"
descr
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/, "_")
|> String.trim("_")
else
# For table entries, prepend sensor class to OID index
"#{sensor_class}_#{oid_index}"
end
end
defp determine_unit("temperature"), do: "°C"
defp determine_unit("voltage"), do: "V"
defp determine_unit("current"), do: "A"