From cc5f615dbcf53f474d968ac6d137adffc7e4fbb7 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Tue, 18 Aug 2026 15:19:13 +0700 Subject: [PATCH] feat(lldp): report neighbours from the LLDP Remote Device Summary page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_lldp_neighbors() was never implemented, so the switch reported no neighbours and NetOrk's network map had nothing to draw for the site it serves — on the reference installation two APs, a firewall and a Proxmox host behind a 1820 8G PoE+ (J9982A) all stood unconnected. The web UI does carry the data. /htdocs/pages/switching/lldp_remote.lsp is served with the rows embedded in the page's aDataSet, so the existing _rows() helper reads it with no new plumbing: ['7', '3', '00:...:44', '00:...:46', 'enp3s0', 'host.example.com', 'bridge, WLAN access point, router, station only', 'bridge', '192.0.2.10'] Column 3 is the neighbour's port ID, which on many peers is a MAC, while column 4 is its port description — a name a person can read. The description wins, with the ID used only when it is missing. The chassis ID is passed through as `mac` when it looks like one, since netOrk resolves a neighbour by MAC before falling back to its name. Fixture captured from the live switch and anonymised in the style of the existing ones. Closes #1 --- napalm_hpe_officeconnect/officeconnect.py | 39 +++++ tests/fixtures/switching_lldp_remote.html | 188 ++++++++++++++++++++++ tests/unit/test_driver.py | 29 ++++ 3 files changed, 256 insertions(+) create mode 100644 tests/fixtures/switching_lldp_remote.html diff --git a/napalm_hpe_officeconnect/officeconnect.py b/napalm_hpe_officeconnect/officeconnect.py index a32897d..39aef12 100644 --- a/napalm_hpe_officeconnect/officeconnect.py +++ b/napalm_hpe_officeconnect/officeconnect.py @@ -30,6 +30,7 @@ from napalm_hpe_officeconnect.client import OfficeConnectClient logger = logging.getLogger("napalm_hpe_officeconnect") +_MAC_RE = re.compile(r"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}") _SPEED_RE = re.compile(r"(\d+)\s*Mbps", re.IGNORECASE) # Participation values that make an interface a member of a VLAN. @@ -76,6 +77,7 @@ class OfficeConnectDriver(SwitchDriver): PORT_STATS = "/htdocs/pages/base/port_summary_stats.lsp" MAC_TABLE = "/htdocs/pages/base/mac_address_table.lsp" VLAN_STATUS = "/htdocs/pages/switching/vlan_status.lsp" + LLDP_REMOTE = "/htdocs/pages/switching/lldp_remote.lsp" VLAN_PER_PORT = "/htdocs/pages/switching/vlan_per_port.lsp?vlan={vlan}" def __init__( @@ -222,6 +224,43 @@ class OfficeConnectDriver(SwitchDriver): """Like :meth:`get_vlans` but keeping tagged and untagged apart.""" return self._vlan_participation() + def get_lldp_neighbors(self) -> dict[str, list[dict[str, str]]]: + """Return LLDP neighbours keyed by local port. + + Rows of the "LLDP Remote Device Summary" page, verbatim from a + 1820 8G PoE+ (J9982A):: + + ['7', '3', '00:...:44', '00:...:46', 'enp3s0', 'host.example.com', + 'bridge, WLAN access point, router, station only', 'bridge', + '192.0.2.10'] + + Column 3 is the neighbour's port *ID*, which on many peers is a MAC; + column 4 is its port *description*, which is a name a person can read. + The description is preferred and the ID only used when it is absent. + + Without this the switch reports no neighbours at all, and every device + behind it — on the reference installation two APs and a firewall — + stands unconnected on the network map. + """ + neighbors: dict[str, list[dict[str, str]]] = {} + for row in self._rows(self.LLDP_REMOTE): + if len(row) < 6: + continue + local_port, chassis_id, port_id, port_desc, sysname = ( + row[0], + row[2], + row[3], + row[4], + row[5], + ) + if not local_port: + continue + entry = {"hostname": sysname, "port": port_desc or port_id} + if _MAC_RE.fullmatch(chassis_id or ""): + entry["mac"] = chassis_id.lower() + neighbors.setdefault(local_port, []).append(entry) + return neighbors + def get_mac_address_table(self) -> list[dict[str, Any]]: """Columns: [VLAN ID, MAC Address, Interface, Interface Index, Status].""" table = [] diff --git a/tests/fixtures/switching_lldp_remote.html b/tests/fixtures/switching_lldp_remote.html new file mode 100644 index 0000000..c48aa8a --- /dev/null +++ b/tests/fixtures/switching_lldp_remote.html @@ -0,0 +1,188 @@ + + + + + + + + +LLDP Remote Device Summary + + + + + + + + + + + + + + + + +
+
LLDP Remote Device Summary
  +
+ +Help + +
+
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + + diff --git a/tests/unit/test_driver.py b/tests/unit/test_driver.py index 83a5248..b7bc61c 100644 --- a/tests/unit/test_driver.py +++ b/tests/unit/test_driver.py @@ -14,6 +14,7 @@ PAGE_FOR_PATH = { OfficeConnectDriver.PORT_SUMMARY: "base_port_summary", OfficeConnectDriver.MAC_TABLE: "base_mac_address_table", OfficeConnectDriver.VLAN_STATUS: "switching_vlan_status", + OfficeConnectDriver.LLDP_REMOTE: "switching_lldp_remote", } @@ -194,3 +195,31 @@ def test_get_interfaces_counters_is_not_implemented(driver): zeros would look like real counters. See README.""" with pytest.raises(NotImplementedError): driver.get_interfaces_counters() + + +# =========================================================================== +# LLDP — the map has nothing to draw for this site without it +# =========================================================================== + + +def test_get_lldp_neighbors_keys_on_local_port(driver): + neighbors = driver.get_lldp_neighbors() + assert set(neighbors) == {"7", "8"} + + +def test_get_lldp_neighbors_reports_hostname_and_remote_port(driver): + """The remote port description beats its MAC — `enp3s0` names something.""" + entry = driver.get_lldp_neighbors()["7"][0] + assert entry["hostname"] == "host-1.example.com" + assert entry["port"] == "enp3s0" + + +def test_get_lldp_neighbors_carries_the_chassis_mac(driver): + """netOrk resolves a neighbour by MAC before falling back to its name.""" + entry = driver.get_lldp_neighbors()["7"][0] + assert entry["mac"] == "00:11:22:33:44:01" + + +def test_get_lldp_neighbors_without_neighbours_is_empty(driver): + driver._client.fetch.side_effect = lambda path: "no table" + assert driver.get_lldp_neighbors() == {}