fix(lldp): parse the v7 neighbour table, which uses a different command

get_lldp_neighbors() existed but spoke to the older CLI generation: it ran
`show lldp remote-device all` and expected FASTPATH-style `0/1` ports in
whitespace-separated columns. v7 firmware answers `Unknown command` — as it
does for `show lldp remote-device`, a bare `show lldp`, and the plural
`show lldp neighbors`. Only the singular works, though `show ?` lists `lldp`
plainly enough:

     Port |   Device ID       |     Port ID      |      SysName      | ...
     ---- + ----------------- + ---------------- + ----------------- + ...
       g9 | 10:01:02:44:37:26 |10:01:02:44:37:28 | pve-eze.eze.local | ...

Ports are named g9, and the columns are separated by `|` with irregular
padding, so splitting on whitespace tears the values apart. The port ID is
not always a port name either — a neighbour may identify its port by MAC.

_get_lldp_table() now branches on _detect_cli_v7() into a second parser, the
same shape get_mac_address_table() already uses for this divergence.
get_lldp_neighbors() itself is untouched.

Confirmed against a GS110TPv3 on 7.1.1.17: _detect_cli_v7() returns True
there, and the switch reports two neighbours it had never surfaced. Without
them NetOrk's map had nothing to draw for the site this switch serves, so
four APs and a firewall behind it stood unconnected.
This commit is contained in:
Christian Manivong
2026-08-18 15:18:56 +07:00
parent 5317af0be1
commit efe004a295
2 changed files with 126 additions and 1 deletions
+47 -1
View File
@@ -1052,7 +1052,9 @@ class NetgearSmartDriver(ConfigLifecycleMixin, SwitchDriver):
return neighbors
def _get_lldp_table(self) -> List[Dict]:
"""Parse ``show lldp remote-device all`` into a list of row dicts."""
"""Parse the LLDP neighbour table into a list of row dicts."""
if self._detect_cli_v7():
return self._get_lldp_table_v7()
output = self._send_command("show lldp remote-device all")
rows: List[Dict] = []
in_table = False
@@ -1086,6 +1088,50 @@ class NetgearSmartDriver(ConfigLifecycleMixin, SwitchDriver):
return rows
def _get_lldp_table_v7(self) -> List[Dict]:
"""Parse ``show lldp neighbor`` on v7 firmware.
The v7 CLI has neither ``show lldp remote-device all`` nor a bare
``show lldp`` — both answer "Unknown command". Only the singular
``neighbor`` works, and it lays the table out differently::
Port | Device ID | Port ID | SysName | Capabilities | TTL
---- + ----------------- + ---------------- + ----------------- + -------------- + -----
g9 | 10:01:02:44:37:26 |10:01:02:44:37:28 | pve-eze.eze.local | Bridge | 97
Ports are named ``g9`` rather than ``0/1``, and the columns are
separated by ``|`` with irregular padding — splitting on whitespace
would tear the values apart. The port ID is not always a port name; a
neighbour may identify its port by MAC.
"""
try:
output = self._send_paged_command("show lldp neighbor")
except Exception: # pragma: no cover - transport failure
return []
rows: List[Dict] = []
for line in output.splitlines():
if "|" not in line:
continue
cols = [c.strip() for c in line.split("|")]
if len(cols) < 4:
continue
port, chassis_id, remote_port, sysname = cols[0], cols[1], cols[2], cols[3]
if not port or port.lower() == "port" or set(port) <= set("-+ "):
continue
if not sysname and not remote_port:
continue
rows.append(
{
"local_port": port,
"remote_id": "",
"remote_chassis_id": chassis_id,
"port_id": remote_port,
"system_name": sysname,
}
)
return rows
def get_lldp_neighbors_detail(self, interface: str = "") -> Dict[str, List[Dict]]:
"""Return detailed LLDP neighbor information.
+79
View File
@@ -726,3 +726,82 @@ class TestCli:
def test_raises_on_non_text_encoding(self, driver):
with pytest.raises(NotImplementedError):
driver.cli(["show sysinfo"], encoding="json")
# ---------------------------------------------------------------------------
# Tests: get_lldp_neighbors
# ---------------------------------------------------------------------------
LLDP_NEIGHBOR_OUTPUT = """\
show lldp neighbor
Port | Device ID | Port ID | SysName | Capabilities | TTL
---- + ----------------- + ---------------- + ----------------- + -------------- + -----
g9 | 10:01:02:44:37:26 |10:01:02:44:37:28 | pve-eze.eze.local | Bridge | 97
g10 | 10:01:02:44:37:26 |10:01:02:44:37:26 | pve-eze.eze.local | Bridge | 97
g1 | 28:94:01:6d:26:7d |g3 | swt-core | Bridge,Router | 93
GS110TPv3#
"""
class TestGetLldpNeighbors:
"""`show lldp neighbor` — the switch does speak LLDP, the driver did not.
Without this the map has nothing to draw for the site this switch serves:
it is the core switch, so every AP and firewall behind it hangs
unconnected. Measured on a GS110TPv3 running 7.1.1.17.
"""
def _send(self, cmd, **kw):
return LLDP_NEIGHBOR_OUTPUT if "lldp" in cmd else ""
@staticmethod
def _as_v7(driver):
"""v7 firmware — the only generation whose CLI answers `show lldp neighbor`."""
driver._cli_v7 = True
def test_returns_one_entry_per_local_port(self, driver):
self._as_v7(driver)
driver._send_paged_command = self._send
result = driver.get_lldp_neighbors()
assert set(result) == {"g9", "g10", "g1"}
def test_neighbour_hostname_is_taken_from_sysname(self, driver):
self._as_v7(driver)
driver._send_paged_command = self._send
result = driver.get_lldp_neighbors()
assert result["g9"][0]["hostname"] == "pve-eze.eze.local"
def test_remote_port_is_reported(self, driver):
self._as_v7(driver)
driver._send_paged_command = self._send
result = driver.get_lldp_neighbors()
assert result["g1"][0]["port"] == "g3"
def test_a_mac_shaped_port_id_is_still_reported_verbatim(self, driver):
"""Some neighbours identify their port by MAC rather than by name."""
self._as_v7(driver)
driver._send_paged_command = self._send
result = driver.get_lldp_neighbors()
assert result["g9"][0]["port"] == "10:01:02:44:37:28"
def test_header_and_prompt_lines_are_not_parsed_as_neighbours(self, driver):
self._as_v7(driver)
driver._send_paged_command = self._send
result = driver.get_lldp_neighbors()
assert "Port" not in result
assert not any(k.startswith("GS110") for k in result)
def test_no_neighbours_yields_an_empty_mapping(self, driver):
self._as_v7(driver)
driver._send_paged_command = lambda cmd, **kw: (
"show lldp neighbor\n\n Port | Device ID | Port ID | SysName | Capabilities | TTL\n"
" ---- + --------- + ------- + ------- + ------------ + -----\n\nGS110TPv3#\n"
)
assert driver.get_lldp_neighbors() == {}
def test_unsupported_firmware_does_not_raise(self, driver):
self._as_v7(driver)
driver._send_paged_command = lambda cmd, **kw: "Unknown command"
assert driver.get_lldp_neighbors() == {}