feat: complete WISP vendor coverage (ePMP, Raisecom, Netonix)

**1. Consolidated ePMP YAML profiles:**
- Merged enhancements from cambium-epmp.yaml into epmp.yaml
- Added GPS satellite count sensors (tracked/visible)
- Added proper state_name fields for state sensors
- Improved field naming (descr vs type, divisor vs precision)
- Deleted duplicate cambium-epmp.yaml file

**2. Implemented Raisecom vendor module:**
- Created lib/towerops/snmp/profiles/vendors/raisecom.ex
- Fills major 0% coverage gap for Asia-Pacific WISP market
- Hardware detection via RAISECOM-SYSTEM-MIB with sysDescr fallback
- Sensor discovery handled by existing YAML profiles (15+ sensors):
  - CPU utilization, memory pools
  - Fan speed and state monitoring
  - Voltage sensors with thresholds
  - Power supply state monitoring
  - Config load state tracking
- Comprehensive test coverage (8 tests, all passing)

**3. Verified Netonix coverage:**
- YAML profile matches LibreNMS 100% (PoE status, voltage, temp, fan, power, current)
- Vendor module provides additional scalar sensors beyond LibreNMS
- Coverage exceeds LibreNMS baseline

**4. Investigated Tarana:**
- No SNMP support in LibreNMS (newer vendor, likely cloud-managed)
- Would require vendor MIBs and documentation to implement

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Graham McIntire 2026-02-12 09:55:21 -06:00
parent 1602322518
commit dd3a6234cb
No known key found for this signature in database
4 changed files with 197 additions and 76 deletions

View file

@ -0,0 +1,63 @@
defmodule Towerops.Snmp.Profiles.Vendors.Raisecom do
@moduledoc """
Raisecom device-specific SNMP handling.
Raisecom manufactures networking equipment widely used in Asia-Pacific WISP deployments,
including switches, routers, and wireless access equipment.
Sensor discovery is primarily handled via YAML profiles (raisecom.yaml, raisecom-ros.yaml)
which define processors, memory pools, fan speed, voltage, and state sensors.
"""
@behaviour Towerops.Snmp.Profiles.Vendors.Vendor
alias Towerops.Snmp.Client
alias Towerops.Snmp.Profiles.Vendors.Vendor
# Raisecom enterprise OID: 1.3.6.1.4.1.8886
@impl true
def profile_names, do: ["raisecom", "raisecom-ros"]
@impl true
def detect_hardware(client_opts) do
# Try to get hardware model from RAISECOM-SYSTEM-MIB
case Client.get(client_opts, "1.3.6.1.4.1.8886.6.1.1.1.2.0") do
{:ok, hardware} when is_binary(hardware) and byte_size(hardware) > 0 ->
String.slice(hardware, 0, 64)
_ ->
# Fallback to sysDescr
case Client.get(client_opts, "1.3.6.1.2.1.1.1.0") do
{:ok, descr} when is_binary(descr) ->
parse_hardware_from_descr(descr)
_ ->
nil
end
end
end
defp parse_hardware_from_descr(descr) do
descr
|> String.split(~r/[\r\n]/)
|> List.first()
|> case do
nil -> nil
line -> String.slice(line, 0, 64)
end
end
@impl true
def wireless_oid_defs do
# All Raisecom sensors (CPU, memory, fans, voltage, state) are defined in YAML profiles
# No additional wireless-specific sensors needed here
[]
end
@impl true
def discover_wireless_sensors(client_opts) do
# Fetch any static sensors defined in wireless_oid_defs (currently none)
Vendor.fetch_sensors(wireless_oid_defs(), client_opts)
end
end

View file

@ -1,68 +0,0 @@
mib: CAMBIUM-PMP80211-MIB
modules:
os:
version: CAMBIUM-PMP80211-MIB::cambiumCurrentuImageVersion.0
serial: CAMBIUM-PMP80211-MIB::cambiumEPMPMSN.0
lat: CAMBIUM-PMP80211-MIB::cambiumDeviceLatitude.0
long: CAMBIUM-PMP80211-MIB::cambiumDeviceLongitude.0
processors:
data:
-
oid: sysCPUUsage
num_oid: '.1.3.6.1.4.1.17713.21.2.1.64.{{ $index }}'
descr: CPU
divisor: 10
sensors:
count:
data:
-
oid: sysDFSDetectedCount
num_oid: '.1.3.6.1.4.1.17713.21.2.1.36.{{ $index }}'
index: sysDFSDetectedCount
descr: DFS Detected Count
-
oid: cambiumGPSNumTrackedSat
num_oid: '.1.3.6.1.4.1.17713.21.1.2.84.{{ $index }}'
index: cambiumGPSNumTrackedSat
descr: GPS Satellites Tracked
-
oid: cambiumGPSNumVisibleSat
num_oid: '.1.3.6.1.4.1.17713.21.1.2.85.{{ $index }}'
index: cambiumGPSNumVisibleSat
descr: GPS Satellites Visible
state:
data:
-
oid: cambiumEffectiveSyncSource
num_oid: '.1.3.6.1.4.1.17713.21.1.1.7.{{ $index }}'
index: cambiumEffectiveSyncSource
descr: GPS Sync Status
state_name: cambiumEffectiveSyncSource
states:
- { value: 1, generic: 0, graph: 1, descr: GPS Sync Up }
- { value: 2, generic: 2, graph: 1, descr: GPS Sync Down }
- { value: 3, generic: 0, graph: 1, descr: CMM4 Sync }
- { value: 4, generic: 0, graph: 1, descr: CMM3 Sync }
-
oid: cambiumDFSStatus
num_oid: '.1.3.6.1.4.1.17713.21.1.1.6.{{ $index }}'
index: cambiumDFSStatus
descr: DFS Status
state_name: cambiumDFSStatus
states:
- { value: 1, generic: 1, graph: 1, descr: N/A }
- { value: 2, generic: 1, graph: 1, descr: Channel Availability Check }
- { value: 3, generic: 0, graph: 1, descr: In-Service }
- { value: 4, generic: 2, graph: 1, descr: Radar Signal Detected }
- { value: 5, generic: 0, graph: 1, descr: In-Service Monitoring at Alternative Channel }
- { value: 6, generic: 2, graph: 1, descr: System Not In Service due to DFS }
-
oid: ePMP2000SmartAntennaStatus
num_oid: '.1.3.6.1.4.1.17713.21.1.1.41.{{ $index }}'
index: ePMP2000SmartAntennaStatus
descr: Smart Antenna Status
state_name: ePMP2000SmartAntennaStatus
states:
- { value: 0, generic: 1, graph: 0, descr: Not Powered }
- { value: 1, generic: 3, graph: 0, descr: Powered / Not Detected }
- { value: 3, generic: 0, graph: 0, descr: Detected }

View file

@ -10,22 +10,34 @@ modules:
-
oid: sysCPUUsage
num_oid: '.1.3.6.1.4.1.17713.21.2.1.64.{{ $index }}'
type: cambium-cpu
precision: 10
descr: CPU
divisor: 10
sensors:
count:
data:
-
oid: sysDFSDetectedCount
num_oid: '.1.3.6.1.4.1.17713.21.2.1.36.{{ $index }}'
descr: DFS Detected Count
index: sysDFSDetectedCount
descr: DFS Detected Count
-
oid: cambiumGPSNumTrackedSat
num_oid: '.1.3.6.1.4.1.17713.21.1.2.84.{{ $index }}'
index: cambiumGPSNumTrackedSat
descr: GPS Satellites Tracked
-
oid: cambiumGPSNumVisibleSat
num_oid: '.1.3.6.1.4.1.17713.21.1.2.85.{{ $index }}'
index: cambiumGPSNumVisibleSat
descr: GPS Satellites Visible
state:
data:
-
oid: cambiumEffectiveSyncSource
num_oid: '.1.3.6.1.4.1.17713.21.1.1.7.{{ $index }}'
index: '{{ $index }}'
descr: GPS Status
index: cambiumEffectiveSyncSource
descr: GPS Sync Status
state_name: cambiumEffectiveSyncSource
states:
- { value: 1, generic: 0, graph: 1, descr: GPS Sync Up }
- { value: 2, generic: 2, graph: 1, descr: GPS Sync Down }
@ -34,8 +46,9 @@ modules:
-
oid: cambiumDFSStatus
num_oid: '.1.3.6.1.4.1.17713.21.1.1.6.{{ $index }}'
index: '{{ $index }}'
index: cambiumDFSStatus
descr: DFS Status
state_name: cambiumDFSStatus
states:
- { value: 1, generic: 1, graph: 1, descr: N/A }
- { value: 2, generic: 1, graph: 1, descr: Channel Availability Check }
@ -46,8 +59,9 @@ modules:
-
oid: ePMP2000SmartAntennaStatus
num_oid: '.1.3.6.1.4.1.17713.21.1.1.41.{{ $index }}'
index: '{{ $index }}'
descr: Smart Antenna
index: ePMP2000SmartAntennaStatus
descr: Smart Antenna Status
state_name: ePMP2000SmartAntennaStatus
states:
- { value: 0, generic: 1, graph: 0, descr: Not Powered }
- { value: 1, generic: 3, graph: 0, descr: Powered / Not Detected }

View file

@ -0,0 +1,112 @@
defmodule Towerops.Snmp.Profiles.Vendors.RaisecomTest do
use Towerops.DataCase, async: true
import Mox
alias Towerops.Snmp.Profiles.Vendors.Raisecom
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 expected profile names" do
assert Raisecom.profile_names() == ["raisecom", "raisecom-ros"]
end
end
describe "detect_hardware/1" do
test "returns hardware model from RAISECOM-SYSTEM-MIB when available" do
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
"1.3.6.1.4.1.8886.6.1.1.1.2.0" ->
{:ok, "RAISECOM ISCOM2128EA-MA"}
_ ->
{:error, :no_such_object}
end
end)
assert Raisecom.detect_hardware(@client_opts) == "RAISECOM ISCOM2128EA-MA"
end
test "falls back to sysDescr when RAISECOM-SYSTEM-MIB is unavailable" do
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
"1.3.6.1.4.1.8886.6.1.1.1.2.0" ->
{:error, :no_such_object}
"1.3.6.1.2.1.1.1.0" ->
{:ok, "Raisecom Switch Model XYZ"}
_ ->
{:error, :no_such_object}
end
end)
assert Raisecom.detect_hardware(@client_opts) == "Raisecom Switch Model XYZ"
end
test "returns nil when all hardware detection methods fail" do
stub(SnmpMock, :get, fn _, _, _ ->
{:error, :timeout}
end)
assert Raisecom.detect_hardware(@client_opts) == nil
end
test "truncates long hardware descriptions to 64 characters" do
long_hardware = String.duplicate("A", 100)
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
"1.3.6.1.4.1.8886.6.1.1.1.2.0" ->
{:ok, long_hardware}
_ ->
{:error, :no_such_object}
end
end)
result = Raisecom.detect_hardware(@client_opts)
assert String.length(result) == 64
end
test "handles empty hardware string by falling back to sysDescr" do
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
"1.3.6.1.4.1.8886.6.1.1.1.2.0" ->
{:ok, ""}
"1.3.6.1.2.1.1.1.0" ->
{:ok, "Fallback Description"}
_ ->
{:error, :no_such_object}
end
end)
assert Raisecom.detect_hardware(@client_opts) == "Fallback Description"
end
end
describe "wireless_oid_defs/0" do
test "returns empty list (all sensors defined in YAML)" do
assert Raisecom.wireless_oid_defs() == []
end
end
describe "discover_wireless_sensors/1" do
test "returns empty list when no static sensors defined" do
sensors = Raisecom.discover_wireless_sensors(@client_opts)
assert sensors == []
end
end
end