diff --git a/lib/towerops/snmp/profiles/vendors/epmp.ex b/lib/towerops/snmp/profiles/vendors/epmp.ex index e7936c18..4dcca8c9 100644 --- a/lib/towerops/snmp/profiles/vendors/epmp.ex +++ b/lib/towerops/snmp/profiles/vendors/epmp.ex @@ -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) diff --git a/priv/profiles/os_detection/epmp.yaml b/priv/profiles/os_detection/epmp.yaml index 87c515af..85e7171f 100644 --- a/priv/profiles/os_detection/epmp.yaml +++ b/priv/profiles/os_detection/epmp.yaml @@ -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 diff --git a/test/towerops/snmp/profiles/vendors/epmp_test.exs b/test/towerops/snmp/profiles/vendors/epmp_test.exs index 72a28087..86159048 100644 --- a/test/towerops/snmp/profiles/vendors/epmp_test.exs +++ b/test/towerops/snmp/profiles/vendors/epmp_test.exs @@ -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()