feat: ePMP firmware extraction + ePMP3000 NetSNMP-quirk detection

ePMP devices were not reporting firmware_version because the YAML's
cambiumCurrentuImageVersion.0 OID returns empty on ePMP3000 firmware.
Add detect_version/1 to the Epmp vendor module with two-OID fallback:
cambiumCurrentSWInfo.0 (verbose, ePMP3000-friendly) → cambiumCurrentuImageVersion.0
(short, ePMP1000/2000), with a regex to extract the version triplet
from whatever the device returns.

ePMP3000 also has a sysObjectID quirk where some firmware reports the
generic NetSNMP-on-Linux ID (.1.3.6.1.4.1.8072.3.2.10) instead of the
Cambium enterprise OID, causing devices to misclassify as the linux
profile. Add a second discovery block to the epmp profile that matches
NetSNMP-OID + PREEMPT_RT armv7l kernel signature + a confirming snmpget
against the Cambium ePMP MIB tree, so detection runs in pass-2
(conditional non-generic) before linux falls through in pass-3.
This commit is contained in:
Graham McIntire 2026-05-09 08:34:23 -05:00
parent 7519808e92
commit 3606ba19b3
3 changed files with 125 additions and 0 deletions

View file

@ -21,6 +21,15 @@ defmodule Towerops.Snmp.Profiles.Vendors.Epmp do
# cambiumSubModeType = { cambiumGeneralStatus 33 } = 1.3.6.1.4.1.17713.21.1.1.33
@sub_mode_oid "1.3.6.1.4.1.17713.21.1.1.33.0"
# OIDs for firmware version detection (from CAMBIUM-PMP80211-MIB)
# cambiumCurrentSWInfo = { cambiumGeneralStatus 1 } - verbose software info string (1..128 chars)
@sw_info_oid "1.3.6.1.4.1.17713.21.1.1.1.0"
# cambiumCurrentuImageVersion = { cambiumGeneralStatus 17 } - short version string (0..20 chars)
@image_version_oid "1.3.6.1.4.1.17713.21.1.1.17.0"
# Match a typical version triplet/quad like "4.7.1.1", "4.7.1-RC4", etc.
@version_regex ~r/(\d+\.\d+(?:\.\d+)*(?:[-_][A-Za-z0-9]+)?)/
@impl true
def profile_names, do: ["epmp"]
@ -42,6 +51,45 @@ defmodule Towerops.Snmp.Profiles.Vendors.Epmp do
end
end
@doc """
Detect firmware version with a two-OID fallback strategy.
Tries `cambiumCurrentSWInfo.0` first (verbose, 128-char string more
reliably populated on ePMP3000 firmware images), then falls back to
`cambiumCurrentuImageVersion.0` (short 20-char string used by ePMP1000/2000).
Extracts a version-looking substring (e.g. `4.7.1.1`) from whatever the
device returns; if no version pattern matches, returns the trimmed raw
string. Returns `nil` when both OIDs error or return empty.
"""
def detect_version(client_opts) do
fetch_version(@sw_info_oid, client_opts) ||
fetch_version(@image_version_oid, client_opts)
end
defp fetch_version(oid, client_opts) do
case Client.get(client_opts, oid) do
{:ok, value} when is_binary(value) -> normalize_version(value)
_ -> nil
end
end
defp normalize_version(value) do
trimmed = String.trim(value)
cond do
trimmed == "" ->
nil
match = Regex.run(@version_regex, trimmed) ->
[_, version] = match
version
true ->
trimmed
end
end
@impl true
def discover_wireless_sensors(client_opts) do
Vendor.fetch_sensors(wireless_oid_defs(), client_opts)

View file

@ -9,3 +9,15 @@ discovery:
-
sysObjectID:
- .1.3.6.1.4.1.17713.21
-
# ePMP3000 firmware quirk: some images report sysObjectID as the
# generic NetSNMP-on-Linux ID (.1.3.6.1.4.1.8072.3.2.10) instead of
# the Cambium enterprise OID. Detect by matching the PREEMPT_RT
# armv7l kernel signature and confirming the Cambium ePMP MIB tree
# responds. Both must pass before claiming the device as ePMP.
sysObjectID: .1.3.6.1.4.1.8072.3.2.10
sysDescr_regex: '/PREEMPT RT.+armv7l/'
snmpget:
oid: CAMBIUM-PMP80211-MIB::cambiumCurrentSWInfo.0
op: '!='
value: false

View file

@ -58,6 +58,71 @@ defmodule Towerops.Snmp.Profiles.Vendors.EpmpTest do
end
end
describe "detect_version/1" do
@sw_info_oid "1.3.6.1.4.1.17713.21.1.1.1.0"
@image_version_oid "1.3.6.1.4.1.17713.21.1.1.17.0"
test "extracts version from cambiumCurrentSWInfo verbose string" do
expect(SnmpMock, :get, fn _, @sw_info_oid, _ ->
{:ok, "ePMP-AP_Latest 4.7.1.1"}
end)
assert Epmp.detect_version(@client_opts) == "4.7.1.1"
end
test "returns plain version string from cambiumCurrentSWInfo" do
expect(SnmpMock, :get, fn _, @sw_info_oid, _ ->
{:ok, "4.7.1.1"}
end)
assert Epmp.detect_version(@client_opts) == "4.7.1.1"
end
test "preserves -RC suffix in version" do
expect(SnmpMock, :get, fn _, @sw_info_oid, _ ->
{:ok, "4.7.1-RC4"}
end)
assert Epmp.detect_version(@client_opts) == "4.7.1-RC4"
end
test "falls back to cambiumCurrentuImageVersion when SW info errors" do
expect(SnmpMock, :get, fn _, @sw_info_oid, _ -> {:error, :no_such_object} end)
expect(SnmpMock, :get, fn _, @image_version_oid, _ -> {:ok, "4.6.2"} end)
assert Epmp.detect_version(@client_opts) == "4.6.2"
end
test "falls back when SW info returns empty string" do
expect(SnmpMock, :get, fn _, @sw_info_oid, _ -> {:ok, ""} end)
expect(SnmpMock, :get, fn _, @image_version_oid, _ -> {:ok, "4.6.2"} end)
assert Epmp.detect_version(@client_opts) == "4.6.2"
end
test "returns nil when both OIDs fail" do
expect(SnmpMock, :get, fn _, @sw_info_oid, _ -> {:error, :timeout} end)
expect(SnmpMock, :get, fn _, @image_version_oid, _ -> {:error, :timeout} end)
assert Epmp.detect_version(@client_opts) == nil
end
test "returns nil when both OIDs return empty" do
expect(SnmpMock, :get, fn _, @sw_info_oid, _ -> {:ok, ""} end)
expect(SnmpMock, :get, fn _, @image_version_oid, _ -> {:ok, ""} end)
assert Epmp.detect_version(@client_opts) == nil
end
test "trims whitespace from raw value when no version pattern matches" do
expect(SnmpMock, :get, fn _, @sw_info_oid, _ ->
{:ok, " unknown-build "}
end)
assert Epmp.detect_version(@client_opts) == "unknown-build"
end
end
describe "wireless_oid_defs/0" do
test "returns list of sensor definitions" do
defs = Epmp.wireless_oid_defs()