towerops/lib/towerops/snmp/profiles/dynamic.ex
Graham McIntire 888a1e0dda
refactor: remove all references to external project names
Replace all references with generic terms:
- 'external YAML files' instead of specific project names
- 'device profiles' for database-stored definitions
- 'imported OID definitions' for sensor configurations

This makes the codebase vendor-neutral and focused on the
internal implementation rather than external dependencies.
2026-01-17 18:31:41 -06:00

203 lines
6.5 KiB
Elixir

defmodule Towerops.Snmp.Profiles.Dynamic do
@moduledoc """
Dynamic SNMP profile that interprets database-stored device profiles.
This profile uses the device_profiles table to dynamically discover
sensors and processors based on imported OID definitions.
"""
alias Towerops.DeviceProfiles
alias Towerops.DeviceProfiles.DeviceProfile
alias Towerops.Snmp.Client
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Profiles.Base
require Logger
@doc """
Identifies device using the database profile information.
Returns manufacturer, model, and firmware info.
Polls SNMP OIDs from OS definitions to extract version and serial number.
"""
@spec identify_device(DeviceProfile.t(), Client.connection_opts(), Discovery.system_info()) ::
Discovery.device_info()
def identify_device(profile, client_opts, _system_info) do
# Load profile with OS definitions
profile = DeviceProfiles.get_profile_with_associations(profile.os)
# Poll OS definitions for version, serial, etc.
os_data = poll_os_definitions(profile, client_opts)
# Use profile metadata to build device info
%{
manufacturer: extract_manufacturer(profile),
model: profile.text || profile.os,
firmware_version: os_data[:version] || profile.os,
serial_number: os_data[:serial]
}
end
@doc """
Discovers network interfaces using the base profile.
Dynamic profiles inherit standard IF-MIB interface discovery.
"""
@spec discover_interfaces(DeviceProfile.t(), Client.connection_opts()) ::
{:ok, [Discovery.interface_data()]} | {:error, term()}
def discover_interfaces(_profile, client_opts) do
# Use base profile for standard interface discovery
Base.discover_interfaces(client_opts)
end
@doc """
Discovers sensors dynamically using database profile definitions.
Walks SNMP OIDs defined in the profile's sensor_definitions.
"""
@spec discover_sensors(DeviceProfile.t(), Client.connection_opts()) ::
{:ok, [Discovery.sensor_data()]} | {:error, term()}
def discover_sensors(profile, client_opts) do
# Load profile with sensor 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")
sensors =
Enum.flat_map(profile.sensor_definitions, fn sensor_def ->
discover_sensor_from_definition(client_opts, sensor_def)
end)
{:ok, sensors}
else
Logger.debug("No sensor definitions found for profile #{profile.os}")
{:ok, []}
end
end
# Private functions
defp poll_os_definitions(_profile, _client_opts) do
# Note: OS definition polling is currently disabled because device profiles
# use MIB symbolic names (e.g., CAMBIUM-PMP80211-MIB::cambiumCurrentuImageVersion.0)
# which require MIB compilation to resolve to numeric OIDs.
# The SNMP client only supports numeric OIDs.
# TODO: Add MIB resolution or import numeric OIDs during profile import
%{}
end
defp extract_manufacturer(%DeviceProfile{group: group}) when is_binary(group) do
# Capitalize manufacturer from group (e.g., "cambium" -> "Cambium")
String.capitalize(group)
end
defp extract_manufacturer(%DeviceProfile{text: text}) when is_binary(text) do
# Extract first word from text (e.g., "Ubiquiti AirFiber" -> "Ubiquiti")
text
|> String.split(" ")
|> List.first()
end
defp extract_manufacturer(_), do: "Unknown"
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
if oid do
# Remove template variables like ".{{ $index }}" from OID
# For walks, we need the base OID without the index placeholder
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)
end)
{:ok, _empty} ->
Logger.debug("No data found for sensor OID: #{walk_oid}")
[]
{:error, reason} ->
Logger.warning("Failed to walk sensor OID #{walk_oid}: #{inspect(reason)}")
[]
end
else
Logger.warning("Sensor definition missing OID: #{inspect(sensor_def)}")
[]
end
end
# Remove template variables from OID
# Examples:
# ".1.3.6.1.4.1.17713.21.2.1.36.{{ $index }}" -> ".1.3.6.1.4.1.17713.21.2.1.36"
# "sysUptime.{{ $index }}" -> "sysUptime"
defp clean_oid_template(oid) do
oid
|> String.replace(~r/\.?\{\{[^}]+\}\}/, "")
|> String.trim_trailing(".")
end
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)
# Apply divisor to value if it's numeric
last_value =
cond do
is_number(value) && sensor_def.divisor && sensor_def.divisor > 1 ->
value / sensor_def.divisor
is_number(value) ->
value * 1.0
true ->
nil
end
%{
sensor_type: sensor_def.sensor_class,
sensor_index: sensor_index,
sensor_oid: oid,
sensor_descr: sensor_def.descr || "#{sensor_def.sensor_class} #{sensor_index}",
sensor_unit: determine_unit(sensor_def.sensor_class),
sensor_divisor: sensor_def.divisor || 1,
last_value: last_value,
status: "ok"
}
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
# Extract last component of OID as index
oid
|> String.split(".")
|> List.last()
|> to_string()
else
configured_index
end
end
defp extract_index_from_oid(oid, _) do
# Extract last component of OID as index
oid
|> String.split(".")
|> List.last()
|> to_string()
end
defp determine_unit("temperature"), do: "°C"
defp determine_unit("voltage"), do: "V"
defp determine_unit("current"), do: "A"
defp determine_unit("power"), do: "W"
defp determine_unit("fanspeed"), do: "RPM"
defp determine_unit("humidity"), do: "%"
defp determine_unit("dbm"), do: "dBm"
defp determine_unit("snr"), do: "dB"
defp determine_unit("percent"), do: "%"
defp determine_unit("frequency"), do: "Hz"
defp determine_unit(_), do: ""
end