feat: add 5 new vendor modules and fix dialyzer issues

Vendor modules added:
- Aviat (WTM microwave radios)
- Aruba (wireless controllers and APs)
- CiscoWLC (Cisco wireless LAN controllers)
- Teltonika (RUTOS LTE routers)
- Sub10 (mmWave backhaul radios)

Dialyzer fixes:
- Fix unknown type Devices.t/0 in alert.ex and device.ex
- Fix unmatched_return warnings across multiple files
- Add :exq to PLT for Exq function detection
- Remove dead code in base.ex, dynamic.ex, vendor.ex
- Fix Device.t() type spec to allow nil name field

Tests: 1437 tests, 0 failures
Dialyzer: 0 errors
Credo: no issues
This commit is contained in:
Graham McIntire 2026-01-22 09:34:50 -06:00
parent 8f1752e59c
commit 0a4482f974
No known key found for this signature in database
32 changed files with 1119 additions and 111 deletions

3
.dialyzer_ignore.exs Normal file
View file

@ -0,0 +1,3 @@
# Dialyzer false positives to ignore
# Add patterns here to suppress known false positive warnings from dialyzer
[]

View file

@ -100,7 +100,7 @@ defmodule Mix.Tasks.CopyMibs do
if File.exists?(source_path) do
Logger.info("Copying #{vendor} MIBs...")
File.mkdir_p!(target_path)
File.cp_r!(source_path, target_path)
_ = File.cp_r!(source_path, target_path)
{:ok, vendor}
else
Logger.warning("Vendor MIB directory not found: #{vendor}")

View file

@ -53,7 +53,7 @@ defmodule Mix.Tasks.ImportMibs do
if clean do
Logger.info("Cleaning priv/mibs...")
File.rm_rf!(dest_path)
_ = File.rm_rf!(dest_path)
File.mkdir_p!(dest_path)
else
File.mkdir_p!(dest_path)

View file

@ -38,7 +38,7 @@ defmodule Towerops.Alerts.Alert do
email_sent_at: DateTime.t() | nil,
message: String.t() | nil,
device_id: Ecto.UUID.t(),
device: NotLoaded.t() | Devices.t(),
device: NotLoaded.t() | Device.t(),
acknowledged_by_id: Ecto.UUID.t() | nil,
acknowledged_by: NotLoaded.t() | User.t() | nil,
inserted_at: DateTime.t(),

View file

@ -195,10 +195,11 @@ defmodule Towerops.ApiTokens do
defp spawn_async_update(token, timestamp) do
parent = self()
Task.start(fn ->
maybe_allow_sandbox(parent)
update_token_timestamp(token, timestamp)
end)
_ =
Task.start(fn ->
_ = maybe_allow_sandbox(parent)
update_token_timestamp(token, timestamp)
end)
end
defp maybe_allow_sandbox(parent) do

View file

@ -246,8 +246,8 @@ defmodule Towerops.Devices do
"""
def delete_device(%DeviceSchema{} = device) do
# Stop monitoring workers before deleting
Towerops.Monitoring.Supervisor.stop_monitor(device.id)
Towerops.Monitoring.Supervisor.stop_snmp_poller(device.id)
_ = Towerops.Monitoring.Supervisor.stop_monitor(device.id)
_ = Towerops.Monitoring.Supervisor.stop_snmp_poller(device.id)
Repo.delete(device)
end

View file

@ -47,7 +47,7 @@ defmodule Towerops.Devices.Device do
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
name: String.t(),
name: String.t() | nil,
ip_address: String.t(),
description: String.t() | nil,
status: :up | :down | :unknown,

View file

@ -232,10 +232,11 @@ defmodule Towerops.Monitoring.DeviceMonitor do
defp start_check_task(device_id) do
parent = self()
Task.start(fn ->
maybe_allow_sandbox(parent)
perform_check(device_id)
end)
_ =
Task.start(fn ->
_ = maybe_allow_sandbox(parent)
perform_check(device_id)
end)
end
defp maybe_allow_sandbox(parent) do

View file

@ -73,7 +73,7 @@ defmodule Towerops.Profiles.YamlProfiles do
@impl true
def init(_opts) do
# Create ETS table
:ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
_ = :ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
# Load profiles
load_all_profiles()

View file

@ -55,7 +55,7 @@ defmodule Towerops.Snmp.Device do
raw_discovery_data: map() | nil,
last_discovery_at: DateTime.t() | nil,
device_id: Ecto.UUID.t(),
device: NotLoaded.t() | Devices.t(),
device: NotLoaded.t() | DeviceSchema.t(),
interfaces: NotLoaded.t() | [Interface.t()],
sensors: NotLoaded.t() | [Sensor.t()],
state_sensors: NotLoaded.t() | [StateSensor.t()],

View file

@ -138,7 +138,7 @@ defmodule Towerops.Snmp.Discovery do
is_rediscovery = device.last_discovery_at != nil
# Log discovery event
log_discovery_event(device, discovered_device, is_rediscovery)
_ = log_discovery_event(device, discovered_device, is_rediscovery)
# Broadcast discovery completion for real-time updates
_ =
@ -360,15 +360,9 @@ defmodule Towerops.Snmp.Discovery do
@spec discover_interfaces(Client.connection_opts(), profile()) ::
{:ok, [interface_data()]}
defp discover_interfaces(client_opts, {:yaml, profile}) do
case Dynamic.discover_interfaces(profile, client_opts) do
{:ok, interfaces} ->
Logger.debug("Discovered #{length(interfaces)} interfaces")
{:ok, interfaces}
{:error, _} ->
Logger.warning("Interface discovery failed, continuing without interfaces")
{:ok, []}
end
{:ok, interfaces} = Dynamic.discover_interfaces(profile, client_opts)
Logger.debug("Discovered #{length(interfaces)} interfaces")
{:ok, interfaces}
end
defp discover_interfaces(client_opts, profile) when is_atom(profile) do
@ -391,15 +385,9 @@ defmodule Towerops.Snmp.Discovery do
end
defp discover_sensors(client_opts, profile) when is_atom(profile) do
case profile.discover_sensors(client_opts) do
{:ok, sensors} ->
Logger.debug("Discovered #{length(sensors)} sensors")
{:ok, sensors}
{:error, _} ->
Logger.warning("Sensor discovery failed, continuing without sensors")
{:ok, []}
end
{:ok, sensors} = profile.discover_sensors(client_opts)
Logger.debug("Discovered #{length(sensors)} sensors")
{:ok, sensors}
end
@spec discover_vlans(Client.connection_opts(), profile()) :: {:ok, [map()]}
@ -648,7 +636,9 @@ defmodule Towerops.Snmp.Discovery do
{:ok, DeviceSchema.t()} | {:error, term()}
defp update_device_name_from_snmp(device, system_info) do
# Only update device name if it's currently empty and we got a sysName from SNMP
if (is_nil(device.name) or device.name == "") and Map.has_key?(system_info, :sys_name) do
name_empty? = is_nil(device.name) or device.name == ""
if name_empty? and Map.has_key?(system_info, :sys_name) do
sys_name = Map.get(system_info, :sys_name)
if sys_name && sys_name != "" do
@ -675,12 +665,18 @@ defmodule Towerops.Snmp.Discovery do
"Device discovered: #{device.name} (#{discovered_device.manufacturer} #{discovered_device.model})"
end
interface_count =
case discovered_device.interfaces do
%Ecto.Association.NotLoaded{} -> 0
interfaces when is_list(interfaces) -> length(interfaces)
end
metadata = %{
manufacturer: discovered_device.manufacturer,
model: discovered_device.model,
firmware_version: discovered_device.firmware_version,
serial_number: discovered_device.serial_number,
interface_count: length(discovered_device.interfaces || []),
interface_count: interface_count,
sensor_count:
Repo.aggregate(
from(s in Sensor, where: s.snmp_device_id == ^discovered_device.id),

View file

@ -117,9 +117,10 @@ defmodule Towerops.Snmp.PollerWorker do
:stop
device ->
if device.snmp_enabled do
poll_equipment_device(device_id, device)
end
_ =
if device.snmp_enabled do
poll_equipment_device(device_id, device)
end
:ok
end
@ -354,10 +355,10 @@ defmodule Towerops.Snmp.PollerWorker do
}
# Broadcast to device-specific topic
Phoenix.PubSub.broadcast(Towerops.PubSub, "device:#{device_id}", {:device_event, event})
_ = Phoenix.PubSub.broadcast(Towerops.PubSub, "device:#{device_id}", {:device_event, event})
# Also broadcast to global events topic
Phoenix.PubSub.broadcast(Towerops.PubSub, "device:events", {:device_event, event})
_ = Phoenix.PubSub.broadcast(Towerops.PubSub, "device:events", {:device_event, event})
Logger.info("State sensor change: #{event.message}")
end
@ -1206,10 +1207,11 @@ defmodule Towerops.Snmp.PollerWorker do
defp start_poll_task(device_id) do
parent = self()
Task.start(fn ->
maybe_allow_sandbox(parent)
perform_poll(device_id)
end)
_ =
Task.start(fn ->
_ = maybe_allow_sandbox(parent)
perform_poll(device_id)
end)
end
defp maybe_allow_sandbox(parent) do

View file

@ -1027,8 +1027,6 @@ defmodule Towerops.Snmp.Profiles.Base do
end)
end
defp build_index_value_map(_), do: %{}
defp extract_index_from_oid(oid) when is_binary(oid) do
oid
|> String.split(".")
@ -1102,8 +1100,6 @@ defmodule Towerops.Snmp.Profiles.Base do
end)
end
defp build_vlan_index_map(_), do: %{}
defp extract_vlan_id_from_oid(oid) when is_binary(oid) do
oid
|> String.split(".")
@ -1119,8 +1115,6 @@ defmodule Towerops.Snmp.Profiles.Base do
vlan_id >= 1 and vlan_id <= 4094
end
defp valid_vlan_id?(_), do: false
# Q-BRIDGE-MIB RowStatus values:
# 1=active, 2=notInService, 3=notReady, 4=createAndGo, 5=createAndWait, 6=destroy
defp vlan_row_status_to_status(1), do: "active"
@ -1138,8 +1132,6 @@ defmodule Towerops.Snmp.Profiles.Base do
end)
end
defp build_ip_mask_map(_), do: %{}
defp extract_ip_from_oid(oid) when is_binary(oid) do
# OID format: base_oid.ip1.ip2.ip3.ip4
# Extract last 4 octets as IP address

View file

@ -197,10 +197,6 @@ defmodule Towerops.Snmp.Profiles.Dynamic 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
@ -215,15 +211,6 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|> 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
%{
@ -251,10 +238,6 @@ defmodule Towerops.Snmp.Profiles.Dynamic 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
@ -269,15 +252,6 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|> 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] || %{}

View file

@ -0,0 +1,86 @@
defmodule Towerops.Snmp.Profiles.Vendors.Aruba do
@moduledoc """
Aruba Networks (HPE) device-specific SNMP handling.
Supports Aruba wireless controllers and Instant APs.
Supported devices:
- Aruba 7000 Series Controllers
- Aruba 7200 Series Controllers
- Aruba Instant APs (IAP)
- Aruba AOS-CX Switches
"""
@behaviour Towerops.Snmp.Profiles.Vendors.Vendor
alias Towerops.Snmp.Client
alias Towerops.Snmp.Profiles.Vendors.Vendor
# Aruba Enterprise OID: 1.3.6.1.4.1.14823
# wlsxSwitchModel - Controller model
@model_oid "1.3.6.1.4.1.14823.2.2.1.1.1.2.0"
@impl true
def profile_names do
["aruba-instant", "arubaos", "arubaos-cx"]
end
@impl true
def detect_hardware(client_opts) do
case Client.get(client_opts, @model_oid) do
{:ok, model} when is_binary(model) -> model
_ -> 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
[
# Total Associated Stations (Clients)
%{
oid: "1.3.6.1.4.1.14823.2.2.1.1.3.1.0",
sensor_type: "clients",
sensor_descr: "Associated Clients",
sensor_unit: "",
sensor_divisor: 1
},
# Total APs (Managed)
%{
oid: "1.3.6.1.4.1.14823.2.2.1.1.3.2.0",
sensor_type: "ap-count",
sensor_descr: "Managed APs",
sensor_unit: "",
sensor_divisor: 1
},
# Controller CPU Utilization
%{
oid: "1.3.6.1.4.1.14823.2.2.1.1.1.10.0",
sensor_type: "load",
sensor_descr: "CPU Utilization",
sensor_unit: "%",
sensor_divisor: 1
},
# Controller Memory Utilization
%{
oid: "1.3.6.1.4.1.14823.2.2.1.1.1.11.0",
sensor_type: "load",
sensor_descr: "Memory Utilization",
sensor_unit: "%",
sensor_divisor: 1
},
# Total SSIDs
%{
oid: "1.3.6.1.4.1.14823.2.2.1.1.3.3.0",
sensor_type: "count",
sensor_descr: "Active SSIDs",
sensor_unit: "",
sensor_divisor: 1
}
]
end
end

View file

@ -0,0 +1,90 @@
defmodule Towerops.Snmp.Profiles.Vendors.Aviat do
@moduledoc """
Aviat Networks (formerly Harris Stratex) device-specific SNMP handling.
Supports WTM series microwave radios.
Supported devices:
- WTM 3200
- WTM 3300
- WTM 4000
- WTM 4100
- WTM 4200
- WTM 4500
- WTM 4800
- CTR 8540
"""
@behaviour Towerops.Snmp.Profiles.Vendors.Vendor
alias Towerops.Snmp.Client
alias Towerops.Snmp.Profiles.Vendors.Vendor
# Aviat WTM MIB - System Info
# Enterprise OID: 1.3.6.1.4.1.2509
@model_oid "1.3.6.1.4.1.2509.9.6.1.1.2.1.3.1"
@impl true
def profile_names do
["aviat-wtm"]
end
@impl true
def detect_hardware(client_opts) do
case Client.get(client_opts, @model_oid) do
{:ok, model} when is_binary(model) -> model
_ -> 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
[
# Radio Temperature
%{
oid: "1.3.6.1.4.1.2509.9.6.2.3.1.1.5.1",
sensor_type: "temperature",
sensor_descr: "ODU Temperature",
sensor_unit: "C",
sensor_divisor: 1
},
# RSL (Receive Signal Level)
%{
oid: "1.3.6.1.4.1.2509.9.6.2.2.1.1.7.1",
sensor_type: "rssi",
sensor_descr: "RSL",
sensor_unit: "dBm",
sensor_divisor: 10
},
# Transmit Power
%{
oid: "1.3.6.1.4.1.2509.9.6.2.2.1.1.5.1",
sensor_type: "power",
sensor_descr: "Tx Power",
sensor_unit: "dBm",
sensor_divisor: 10
},
# Modem SNR
%{
oid: "1.3.6.1.4.1.2509.9.6.2.2.1.1.12.1",
sensor_type: "snr",
sensor_descr: "SNR",
sensor_unit: "dB",
sensor_divisor: 10
},
# Link Capacity (Mbps)
%{
oid: "1.3.6.1.4.1.2509.9.6.2.2.1.1.15.1",
sensor_type: "capacity",
sensor_descr: "Link Capacity",
sensor_unit: "Mbps",
sensor_divisor: 1
}
]
end
end

View file

@ -0,0 +1,96 @@
defmodule Towerops.Snmp.Profiles.Vendors.Ciscowlc do
@moduledoc """
Cisco Wireless LAN Controller (WLC) device-specific SNMP handling.
Supports Cisco WLC series controllers.
Supported devices:
- Cisco 2500 Series WLC
- Cisco 3500 Series WLC
- Cisco 5500 Series WLC
- Cisco 8500 Series WLC
- Cisco Virtual WLC
- Cisco Catalyst 9800 Series
"""
@behaviour Towerops.Snmp.Profiles.Vendors.Vendor
alias Towerops.Snmp.Client
alias Towerops.Snmp.Profiles.Vendors.Vendor
# Cisco AIRESPACE MIB
# Enterprise OID: 1.3.6.1.4.1.14179
@model_oid "1.3.6.1.4.1.14179.1.1.1.3.0"
@impl true
def profile_names do
["ciscowlc"]
end
@impl true
def detect_hardware(client_opts) do
case Client.get(client_opts, @model_oid) do
{:ok, model} when is_binary(model) -> model
_ -> 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
[
# Total Associated Mobile Stations (Clients)
%{
oid: "1.3.6.1.4.1.14179.2.1.1.1.38.0",
sensor_type: "clients",
sensor_descr: "Associated Clients",
sensor_unit: "",
sensor_divisor: 1
},
# Total Number of APs
%{
oid: "1.3.6.1.4.1.14179.2.1.1.1.12.0",
sensor_type: "ap-count",
sensor_descr: "Total APs",
sensor_unit: "",
sensor_divisor: 1
},
# Active 802.11a Clients
%{
oid: "1.3.6.1.4.1.14179.2.1.1.1.2.0",
sensor_type: "clients",
sensor_descr: "5GHz Clients",
sensor_unit: "",
sensor_divisor: 1
},
# Active 802.11b/g Clients
%{
oid: "1.3.6.1.4.1.14179.2.1.1.1.3.0",
sensor_type: "clients",
sensor_descr: "2.4GHz Clients",
sensor_unit: "",
sensor_divisor: 1
},
# Total Rogue APs
%{
oid: "1.3.6.1.4.1.14179.2.1.1.1.14.0",
sensor_type: "count",
sensor_descr: "Rogue APs",
sensor_unit: "",
sensor_divisor: 1
},
# Total Rogue Clients
%{
oid: "1.3.6.1.4.1.14179.2.1.1.1.15.0",
sensor_type: "count",
sensor_descr: "Rogue Clients",
sensor_unit: "",
sensor_divisor: 1
}
]
end
end

View file

@ -9,6 +9,9 @@ defmodule Towerops.Snmp.Profiles.Vendors.Registry do
alias Towerops.Snmp.Client
alias Towerops.Snmp.Profiles.Vendors.Airfiber
alias Towerops.Snmp.Profiles.Vendors.Airos
alias Towerops.Snmp.Profiles.Vendors.Aruba
alias Towerops.Snmp.Profiles.Vendors.Aviat
alias Towerops.Snmp.Profiles.Vendors.Ciscowlc
alias Towerops.Snmp.Profiles.Vendors.Cnpilot
alias Towerops.Snmp.Profiles.Vendors.Dragonwave
alias Towerops.Snmp.Profiles.Vendors.Epmp
@ -23,12 +26,17 @@ defmodule Towerops.Snmp.Profiles.Vendors.Registry do
alias Towerops.Snmp.Profiles.Vendors.Saf
alias Towerops.Snmp.Profiles.Vendors.Siae
alias Towerops.Snmp.Profiles.Vendors.Siklu
alias Towerops.Snmp.Profiles.Vendors.Sub10
alias Towerops.Snmp.Profiles.Vendors.Teltonika
alias Towerops.Snmp.Profiles.Vendors.Unifi
@vendors [
Epmp,
Airos,
Airfiber,
Aruba,
Aviat,
Ciscowlc,
Routeros,
Pmp,
Mimosa,
@ -42,7 +50,9 @@ defmodule Towerops.Snmp.Profiles.Vendors.Registry do
Dragonwave,
Racom,
Exalt,
Siae
Siae,
Sub10,
Teltonika
]
@doc """

View file

@ -0,0 +1,83 @@
defmodule Towerops.Snmp.Profiles.Vendors.Sub10 do
@moduledoc """
Sub10 Systems device-specific SNMP handling.
Supports Sub10 millimeter wave (E-band) backhaul radios.
Supported devices:
- Liberator-E Series (80 GHz)
- Liberator-V Series (60 GHz)
"""
@behaviour Towerops.Snmp.Profiles.Vendors.Vendor
alias Towerops.Snmp.Client
alias Towerops.Snmp.Profiles.Vendors.Vendor
# Sub10 Enterprise OID: 1.3.6.1.4.1.39003
@model_oid "1.3.6.1.4.1.39003.1.1.1.0"
@impl true
def profile_names do
["sub10"]
end
@impl true
def detect_hardware(client_opts) do
case Client.get(client_opts, @model_oid) do
{:ok, model} when is_binary(model) -> model
_ -> 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
[
# Radio Temperature
%{
oid: "1.3.6.1.4.1.39003.1.2.1.3.0",
sensor_type: "temperature",
sensor_descr: "Radio Temperature",
sensor_unit: "C",
sensor_divisor: 1
},
# Receive Signal Level (RSL)
%{
oid: "1.3.6.1.4.1.39003.1.3.1.2.0",
sensor_type: "rssi",
sensor_descr: "RSL",
sensor_unit: "dBm",
sensor_divisor: 10
},
# Transmit Power
%{
oid: "1.3.6.1.4.1.39003.1.3.1.1.0",
sensor_type: "power",
sensor_descr: "Tx Power",
sensor_unit: "dBm",
sensor_divisor: 10
},
# Link SNR
%{
oid: "1.3.6.1.4.1.39003.1.3.1.5.0",
sensor_type: "snr",
sensor_descr: "SNR",
sensor_unit: "dB",
sensor_divisor: 10
},
# Link Capacity (Mbps)
%{
oid: "1.3.6.1.4.1.39003.1.3.1.10.0",
sensor_type: "capacity",
sensor_descr: "Link Capacity",
sensor_unit: "Mbps",
sensor_divisor: 1
}
]
end
end

View file

@ -0,0 +1,109 @@
defmodule Towerops.Snmp.Profiles.Vendors.Teltonika do
@moduledoc """
Teltonika Networks device-specific SNMP handling.
Supports RUTX and RUT series LTE/cellular routers.
Supported devices:
- RUTX08, RUTX09, RUTX10, RUTX11, RUTX12, RUTX14, RUTX50
- RUT200, RUT240, RUT241, RUT360
- RUT950, RUT955, RUT956
- TRB140, TRB145, TRB255, TRB500
"""
@behaviour Towerops.Snmp.Profiles.Vendors.Vendor
alias Towerops.Snmp.Client
alias Towerops.Snmp.Profiles.Vendors.Vendor
# Teltonika Enterprise OID: 1.3.6.1.4.1.48690
@model_oid "1.3.6.1.4.1.48690.1.3.0"
@impl true
def profile_names do
["rutos-rutx", "rutos-rutm"]
end
@impl true
def detect_hardware(client_opts) do
case Client.get(client_opts, @model_oid) do
{:ok, model} when is_binary(model) -> model
_ -> 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
[
# Mobile Signal Strength (RSSI)
%{
oid: "1.3.6.1.4.1.48690.2.2.1.7.1",
sensor_type: "rssi",
sensor_descr: "Mobile Signal (RSSI)",
sensor_unit: "dBm",
sensor_divisor: 1
},
# Mobile RSRP (Reference Signal Received Power)
%{
oid: "1.3.6.1.4.1.48690.2.2.1.8.1",
sensor_type: "rssi",
sensor_descr: "RSRP",
sensor_unit: "dBm",
sensor_divisor: 1
},
# Mobile RSRQ (Reference Signal Received Quality)
%{
oid: "1.3.6.1.4.1.48690.2.2.1.10.1",
sensor_type: "quality",
sensor_descr: "RSRQ",
sensor_unit: "dB",
sensor_divisor: 1
},
# Mobile SINR (Signal to Interference plus Noise Ratio)
%{
oid: "1.3.6.1.4.1.48690.2.2.1.9.1",
sensor_type: "snr",
sensor_descr: "SINR",
sensor_unit: "dB",
sensor_divisor: 1
},
# Connection Type (LTE/3G/2G)
%{
oid: "1.3.6.1.4.1.48690.2.2.1.3.1",
sensor_type: "state",
sensor_descr: "Connection Type",
sensor_unit: "",
sensor_divisor: 1
},
# SIM Card Status
%{
oid: "1.3.6.1.4.1.48690.2.2.1.2.1",
sensor_type: "state",
sensor_descr: "SIM Status",
sensor_unit: "",
sensor_divisor: 1
},
# Mobile Network Operator
%{
oid: "1.3.6.1.4.1.48690.2.2.1.4.1",
sensor_type: "state",
sensor_descr: "Network Operator",
sensor_unit: "",
sensor_divisor: 1
},
# Device Temperature
%{
oid: "1.3.6.1.4.1.48690.1.7.0",
sensor_type: "temperature",
sensor_descr: "Device Temperature",
sensor_unit: "C",
sensor_divisor: 10
}
]
end
end

View file

@ -101,14 +101,6 @@ defmodule Towerops.Snmp.Profiles.Vendors.Vendor do
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

View file

@ -46,11 +46,12 @@ defmodule Towerops.Workers.MonitorWorker do
})
# Broadcast monitoring check update to device-specific topic
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device_id}",
{:monitoring_check_updated, device_id}
)
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device_id}",
{:monitoring_check_updated, device_id}
)
:ok
@ -65,11 +66,12 @@ defmodule Towerops.Workers.MonitorWorker do
})
# Broadcast monitoring check update to device-specific topic
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device_id}",
{:monitoring_check_updated, device_id}
)
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device_id}",
{:monitoring_check_updated, device_id}
)
:ok
end

View file

@ -197,7 +197,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
if device.snmp_enabled do
# Enqueue discovery job
enqueue_discovery(device.id)
_ = enqueue_discovery(device.id)
# Navigate to device show page where live updates will appear
{:noreply,
@ -280,7 +280,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
defp handle_device_creation(device) do
if device.snmp_enabled do
enqueue_discovery(device.id)
_ = enqueue_discovery(device.id)
"Device created successfully. SNMP discovery started in background."
else
"Device created successfully"
@ -289,7 +289,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
defp handle_device_update(old_device, device) do
if should_trigger_snmp_discovery?(old_device, device) do
enqueue_discovery(device.id)
_ = enqueue_discovery(device.id)
"Device updated successfully. SNMP discovery started in background."
else
"Device updated successfully"

View file

@ -49,7 +49,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
defp enqueue_discovery(device_id) do
if Application.get_env(:towerops, :env) == :test do
Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)
_ = Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)
else
{:ok, _job} = Exq.enqueue(Exq, "discovery", DiscoveryWorker, [device_id])
end

View file

@ -92,7 +92,7 @@ defmodule ToweropsWeb.SiteLive.Show do
defp enqueue_discovery(device_id) do
if Application.get_env(:towerops, :env) == :test do
# In test, run synchronously
Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)
_ = Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)
else
# In dev/prod, enqueue to Exq
{:ok, _job} = Exq.enqueue(Exq, "discovery", DiscoveryWorker, [device_id])

View file

@ -548,11 +548,12 @@ defmodule ToweropsWeb.UserAuth do
end
# If user is logged in but doesn't have a timezone set, update their profile
if user && !user.timezone && connect_timezone do
Task.start(fn ->
Accounts.update_user_profile(user, %{timezone: connect_timezone})
end)
end
_ =
if user && !user.timezone && connect_timezone do
Task.start(fn ->
Accounts.update_user_profile(user, %{timezone: connect_timezone})
end)
end
Phoenix.Component.assign(socket, :timezone, timezone)
end

View file

@ -90,8 +90,9 @@ defmodule Towerops.MixProject do
defp dialyzer do
[
plt_file: {:no_warn, "priv/plts/dialyzer.plt"},
plt_add_apps: [:mix, :ex_unit],
flags: [:unmatched_returns, :error_handling, :unknown]
plt_add_apps: [:mix, :ex_unit, :exq],
flags: [:unmatched_returns, :error_handling, :unknown],
ignore_warnings: ".dialyzer_ignore.exs"
]
end

View file

@ -0,0 +1,105 @@
defmodule Towerops.Snmp.Profiles.Vendors.ArubaTest do
use Towerops.DataCase, async: true
import Mox
alias Towerops.Snmp.Profiles.Vendors.Aruba
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
@client_opts [
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161,
timeout: 5000
]
describe "profile_names/0" do
test "returns aruba profile names" do
assert Aruba.profile_names() == ["aruba-instant", "arubaos", "arubaos-cx"]
end
end
describe "detect_hardware/1" do
test "detects hardware from model OID" do
expect(SnmpMock, :get, fn _, "1.3.6.1.4.1.14823.2.2.1.1.1.2.0", _ ->
{:ok, "Aruba7205"}
end)
assert Aruba.detect_hardware(@client_opts) == "Aruba7205"
end
test "returns nil when OID unavailable" do
expect(SnmpMock, :get, fn _, _, _ ->
{:error, :no_such_object}
end)
assert Aruba.detect_hardware(@client_opts) == nil
end
end
describe "wireless_oid_defs/0" do
test "returns list of wireless sensor definitions" do
defs = Aruba.wireless_oid_defs()
assert is_list(defs)
assert [_ | _] = defs
end
test "includes clients sensor" do
defs = Aruba.wireless_oid_defs()
clients = Enum.find(defs, &(&1.sensor_descr == "Associated Clients"))
assert clients
assert clients.sensor_type == "clients"
end
test "includes AP count sensor" do
defs = Aruba.wireless_oid_defs()
aps = Enum.find(defs, &(&1.sensor_type == "ap-count"))
assert aps
assert aps.sensor_descr == "Managed APs"
end
test "includes CPU utilization sensor" do
defs = Aruba.wireless_oid_defs()
cpu = Enum.find(defs, &(&1.sensor_descr == "CPU Utilization"))
assert cpu
assert cpu.sensor_unit == "%"
end
end
describe "discover_wireless_sensors/1" do
test "discovers sensors when SNMP responds" do
expect(SnmpMock, :get, 5, fn _, oid, _ ->
cond do
String.contains?(oid, "14823.2.2.1.1.3.1.0") -> {:ok, 250}
String.contains?(oid, "14823.2.2.1.1.3.2.0") -> {:ok, 15}
String.contains?(oid, "14823.2.2.1.1.1.10.0") -> {:ok, 45}
String.contains?(oid, "14823.2.2.1.1.1.11.0") -> {:ok, 62}
String.contains?(oid, "14823.2.2.1.1.3.3.0") -> {:ok, 8}
true -> {:error, :no_such_object}
end
end)
sensors = Aruba.discover_wireless_sensors(@client_opts)
assert is_list(sensors)
assert length(sensors) == 5
end
test "returns empty list when no sensors respond" do
expect(SnmpMock, :get, 5, fn _, _, _ ->
{:error, :no_such_object}
end)
sensors = Aruba.discover_wireless_sensors(@client_opts)
assert sensors == []
end
end
end

View file

@ -0,0 +1,114 @@
defmodule Towerops.Snmp.Profiles.Vendors.AviatTest do
use Towerops.DataCase, async: true
import Mox
alias Towerops.Snmp.Profiles.Vendors.Aviat
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
@client_opts [
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161,
timeout: 5000
]
describe "profile_names/0" do
test "returns aviat profile names" do
assert Aviat.profile_names() == ["aviat-wtm"]
end
end
describe "detect_hardware/1" do
test "detects hardware from model OID" do
expect(SnmpMock, :get, fn _, "1.3.6.1.4.1.2509.9.6.1.1.2.1.3.1", _ ->
{:ok, "WTM 4200"}
end)
assert Aviat.detect_hardware(@client_opts) == "WTM 4200"
end
test "returns nil when OID unavailable" do
expect(SnmpMock, :get, fn _, _, _ ->
{:error, :no_such_object}
end)
assert Aviat.detect_hardware(@client_opts) == nil
end
end
describe "wireless_oid_defs/0" do
test "returns list of wireless sensor definitions" do
defs = Aviat.wireless_oid_defs()
assert is_list(defs)
assert [_ | _] = defs
end
test "includes temperature sensor" do
defs = Aviat.wireless_oid_defs()
temp = Enum.find(defs, &(&1.sensor_type == "temperature"))
assert temp
assert temp.sensor_descr == "ODU Temperature"
end
test "includes RSL sensor" do
defs = Aviat.wireless_oid_defs()
rssi = Enum.find(defs, &(&1.sensor_type == "rssi"))
assert rssi
assert rssi.sensor_descr == "RSL"
assert rssi.sensor_unit == "dBm"
end
test "includes SNR sensor" do
defs = Aviat.wireless_oid_defs()
snr = Enum.find(defs, &(&1.sensor_type == "snr"))
assert snr
assert snr.sensor_unit == "dB"
end
test "includes capacity sensor" do
defs = Aviat.wireless_oid_defs()
capacity = Enum.find(defs, &(&1.sensor_type == "capacity"))
assert capacity
assert capacity.sensor_unit == "Mbps"
end
end
describe "discover_wireless_sensors/1" do
test "discovers sensors when SNMP responds" do
expect(SnmpMock, :get, 5, fn _, oid, _ ->
cond do
String.contains?(oid, "2509.9.6.2.3.1.1.5.1") -> {:ok, 45}
String.contains?(oid, "2509.9.6.2.2.1.1.7.1") -> {:ok, -550}
String.contains?(oid, "2509.9.6.2.2.1.1.5.1") -> {:ok, 200}
String.contains?(oid, "2509.9.6.2.2.1.1.12.1") -> {:ok, 280}
String.contains?(oid, "2509.9.6.2.2.1.1.15.1") -> {:ok, 400}
true -> {:error, :no_such_object}
end
end)
sensors = Aviat.discover_wireless_sensors(@client_opts)
assert is_list(sensors)
assert length(sensors) == 5
end
test "returns empty list when no sensors respond" do
expect(SnmpMock, :get, 5, fn _, _, _ ->
{:error, :no_such_object}
end)
sensors = Aviat.discover_wireless_sensors(@client_opts)
assert sensors == []
end
end
end

View file

@ -0,0 +1,114 @@
defmodule Towerops.Snmp.Profiles.Vendors.CiscowlcTest do
use Towerops.DataCase, async: true
import Mox
alias Towerops.Snmp.Profiles.Vendors.Ciscowlc
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
@client_opts [
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161,
timeout: 5000
]
describe "profile_names/0" do
test "returns cisco wlc profile names" do
assert Ciscowlc.profile_names() == ["ciscowlc"]
end
end
describe "detect_hardware/1" do
test "detects hardware from model OID" do
expect(SnmpMock, :get, fn _, "1.3.6.1.4.1.14179.1.1.1.3.0", _ ->
{:ok, "AIR-CT5508-K9"}
end)
assert Ciscowlc.detect_hardware(@client_opts) == "AIR-CT5508-K9"
end
test "returns nil when OID unavailable" do
expect(SnmpMock, :get, fn _, _, _ ->
{:error, :no_such_object}
end)
assert Ciscowlc.detect_hardware(@client_opts) == nil
end
end
describe "wireless_oid_defs/0" do
test "returns list of wireless sensor definitions" do
defs = Ciscowlc.wireless_oid_defs()
assert is_list(defs)
assert [_ | _] = defs
end
test "includes total clients sensor" do
defs = Ciscowlc.wireless_oid_defs()
clients = Enum.find(defs, &(&1.sensor_descr == "Associated Clients"))
assert clients
assert clients.sensor_type == "clients"
end
test "includes AP count sensor" do
defs = Ciscowlc.wireless_oid_defs()
aps = Enum.find(defs, &(&1.sensor_descr == "Total APs"))
assert aps
assert aps.sensor_type == "ap-count"
end
test "includes 5GHz clients sensor" do
defs = Ciscowlc.wireless_oid_defs()
clients_5g = Enum.find(defs, &(&1.sensor_descr == "5GHz Clients"))
assert clients_5g
assert clients_5g.sensor_type == "clients"
end
test "includes rogue AP sensor" do
defs = Ciscowlc.wireless_oid_defs()
rogue = Enum.find(defs, &(&1.sensor_descr == "Rogue APs"))
assert rogue
assert rogue.sensor_type == "count"
end
end
describe "discover_wireless_sensors/1" do
test "discovers sensors when SNMP responds" do
expect(SnmpMock, :get, 6, fn _, oid, _ ->
cond do
String.contains?(oid, "14179.2.1.1.1.38.0") -> {:ok, 500}
String.contains?(oid, "14179.2.1.1.1.12.0") -> {:ok, 25}
String.contains?(oid, "14179.2.1.1.1.2.0") -> {:ok, 300}
String.contains?(oid, "14179.2.1.1.1.3.0") -> {:ok, 200}
String.contains?(oid, "14179.2.1.1.1.14.0") -> {:ok, 3}
String.contains?(oid, "14179.2.1.1.1.15.0") -> {:ok, 1}
true -> {:error, :no_such_object}
end
end)
sensors = Ciscowlc.discover_wireless_sensors(@client_opts)
assert is_list(sensors)
assert length(sensors) == 6
end
test "returns empty list when no sensors respond" do
expect(SnmpMock, :get, 6, fn _, _, _ ->
{:error, :no_such_object}
end)
sensors = Ciscowlc.discover_wireless_sensors(@client_opts)
assert sensors == []
end
end
end

View file

@ -0,0 +1,114 @@
defmodule Towerops.Snmp.Profiles.Vendors.Sub10Test do
use Towerops.DataCase, async: true
import Mox
alias Towerops.Snmp.Profiles.Vendors.Sub10
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
@client_opts [
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161,
timeout: 5000
]
describe "profile_names/0" do
test "returns sub10 profile names" do
assert Sub10.profile_names() == ["sub10"]
end
end
describe "detect_hardware/1" do
test "detects hardware from model OID" do
expect(SnmpMock, :get, fn _, "1.3.6.1.4.1.39003.1.1.1.0", _ ->
{:ok, "Liberator-E"}
end)
assert Sub10.detect_hardware(@client_opts) == "Liberator-E"
end
test "returns nil when OID unavailable" do
expect(SnmpMock, :get, fn _, _, _ ->
{:error, :no_such_object}
end)
assert Sub10.detect_hardware(@client_opts) == nil
end
end
describe "wireless_oid_defs/0" do
test "returns list of wireless sensor definitions" do
defs = Sub10.wireless_oid_defs()
assert is_list(defs)
assert [_ | _] = defs
end
test "includes temperature sensor" do
defs = Sub10.wireless_oid_defs()
temp = Enum.find(defs, &(&1.sensor_type == "temperature"))
assert temp
assert temp.sensor_descr == "Radio Temperature"
end
test "includes RSL sensor" do
defs = Sub10.wireless_oid_defs()
rssi = Enum.find(defs, &(&1.sensor_type == "rssi"))
assert rssi
assert rssi.sensor_descr == "RSL"
assert rssi.sensor_unit == "dBm"
end
test "includes SNR sensor" do
defs = Sub10.wireless_oid_defs()
snr = Enum.find(defs, &(&1.sensor_type == "snr"))
assert snr
assert snr.sensor_unit == "dB"
end
test "includes capacity sensor" do
defs = Sub10.wireless_oid_defs()
capacity = Enum.find(defs, &(&1.sensor_type == "capacity"))
assert capacity
assert capacity.sensor_unit == "Mbps"
end
end
describe "discover_wireless_sensors/1" do
test "discovers sensors when SNMP responds" do
expect(SnmpMock, :get, 5, fn _, oid, _ ->
cond do
String.contains?(oid, "39003.1.2.1.3.0") -> {:ok, 42}
String.contains?(oid, "39003.1.3.1.2.0") -> {:ok, -480}
String.contains?(oid, "39003.1.3.1.1.0") -> {:ok, 150}
String.contains?(oid, "39003.1.3.1.5.0") -> {:ok, 320}
String.contains?(oid, "39003.1.3.1.10.0") -> {:ok, 1000}
true -> {:error, :no_such_object}
end
end)
sensors = Sub10.discover_wireless_sensors(@client_opts)
assert is_list(sensors)
assert length(sensors) == 5
end
test "returns empty list when no sensors respond" do
expect(SnmpMock, :get, 5, fn _, _, _ ->
{:error, :no_such_object}
end)
sensors = Sub10.discover_wireless_sensors(@client_opts)
assert sensors == []
end
end
end

View file

@ -0,0 +1,122 @@
defmodule Towerops.Snmp.Profiles.Vendors.TeltonikaTest do
use Towerops.DataCase, async: true
import Mox
alias Towerops.Snmp.Profiles.Vendors.Teltonika
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
@client_opts [
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161,
timeout: 5000
]
describe "profile_names/0" do
test "returns teltonika profile names" do
assert Teltonika.profile_names() == ["rutos-rutx", "rutos-rutm"]
end
end
describe "detect_hardware/1" do
test "detects hardware from model OID" do
expect(SnmpMock, :get, fn _, "1.3.6.1.4.1.48690.1.3.0", _ ->
{:ok, "RUTX11"}
end)
assert Teltonika.detect_hardware(@client_opts) == "RUTX11"
end
test "returns nil when OID unavailable" do
expect(SnmpMock, :get, fn _, _, _ ->
{:error, :no_such_object}
end)
assert Teltonika.detect_hardware(@client_opts) == nil
end
end
describe "wireless_oid_defs/0" do
test "returns list of wireless sensor definitions" do
defs = Teltonika.wireless_oid_defs()
assert is_list(defs)
assert [_ | _] = defs
end
test "includes RSSI sensor" do
defs = Teltonika.wireless_oid_defs()
rssi = Enum.find(defs, &(&1.sensor_descr == "Mobile Signal (RSSI)"))
assert rssi
assert rssi.sensor_type == "rssi"
assert rssi.sensor_unit == "dBm"
end
test "includes RSRP sensor" do
defs = Teltonika.wireless_oid_defs()
rsrp = Enum.find(defs, &(&1.sensor_descr == "RSRP"))
assert rsrp
assert rsrp.sensor_type == "rssi"
end
test "includes SINR sensor" do
defs = Teltonika.wireless_oid_defs()
sinr = Enum.find(defs, &(&1.sensor_descr == "SINR"))
assert sinr
assert sinr.sensor_type == "snr"
assert sinr.sensor_unit == "dB"
end
test "includes temperature sensor" do
defs = Teltonika.wireless_oid_defs()
temp = Enum.find(defs, &(&1.sensor_type == "temperature"))
assert temp
assert temp.sensor_descr == "Device Temperature"
assert temp.sensor_divisor == 10
end
end
describe "discover_wireless_sensors/1" do
test "discovers sensors when SNMP responds" do
# Note: fetch_sensor_value only accepts integer values, so state sensors
# (Connection Type, SIM Status, Network Operator) that return strings won't be collected
expect(SnmpMock, :get, 8, fn _, oid, _ ->
cond do
String.contains?(oid, "48690.2.2.1.7.1") -> {:ok, -65}
String.contains?(oid, "48690.2.2.1.8.1") -> {:ok, -85}
String.contains?(oid, "48690.2.2.1.10.1") -> {:ok, -10}
String.contains?(oid, "48690.2.2.1.9.1") -> {:ok, 15}
String.contains?(oid, "48690.2.2.1.3.1") -> {:ok, "LTE"}
String.contains?(oid, "48690.2.2.1.2.1") -> {:ok, "inserted"}
String.contains?(oid, "48690.2.2.1.4.1") -> {:ok, "T-Mobile"}
String.contains?(oid, "48690.1.7.0") -> {:ok, 450}
true -> {:error, :no_such_object}
end
end)
sensors = Teltonika.discover_wireless_sensors(@client_opts)
assert is_list(sensors)
# Only 5 sensors: RSSI, RSRP, RSRQ, SINR, and Temperature (the numeric ones)
assert length(sensors) == 5
end
test "returns empty list when no sensors respond" do
expect(SnmpMock, :get, 8, fn _, _, _ ->
{:error, :no_such_object}
end)
sensors = Teltonika.discover_wireless_sensors(@client_opts)
assert sensors == []
end
end
end