"""Render FASTPATH CLI ``show`` output from a ``VirtualSwitchState``.
The CLI analogue of ``virtual/web_gsm7252ps.py``: pure functions turning device
state into the exact fixed-width text shapes the ``protocols.cli.parse`` parsers
consume, so a ``VirtualSwitch`` answers the FASTPATH CLI like real hardware.
Only PHYSICAL ports (ifIndex <= the model's port_count) are ever printed -- the
CPU/LAG pseudo-interfaces in state (417/418 on the gsm7252ps) never appear on a
``show port all`` / ``show vlan`` page, exactly as on the real switch.
Values are rendered so the parsers reconstruct the SAME model objects the SNMP
face projects for the ops both serve (ports/pvids/vlans/poe/macs/mgmt-IP), which
is what the cross-backend test asserts. Where the two hardware interfaces
genuinely diverge (LLDP has no port-desc column in the CLI; sysInfo temperatures
vs SNMP fan RPM), that is documented at the renderer and the test compares the
shared projection only.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ..registry import get_model
if TYPE_CHECKING:
from .state import PoeSim, VirtualSwitchState
def _phys_ports(state: VirtualSwitchState) -> list[int]:
port_count = get_model(state.model_key).port_count
return sorted(p for p in state.ports if 1 <= p <= port_count)
def _iface(state: VirtualSwitchState, port: int) -> str:
"""The ifName the CLI prints for a physical ``port``.
Uses the seeded ``PortSim.name`` when present -- so the Smart-firmware
S3300-52X (gsm7228ps) renders "1/gN"/"1/xgN" exactly as the real switch does,
while the Fully Managed line keeps "1/0/N". Falls back to the "1/0/N" form for
a port with no seeded name (e.g. an LLDP local port absent from ``ports``).
"""
sim = state.ports.get(port)
return sim.name if sim is not None and sim.name else f"1/0/{port}"
[docs]
def port_for_iface(state: VirtualSwitchState, iface: str) -> int | None:
"""Inverse of ``_iface``: the physical port an ifName addresses, else None.
The mock's CLI face needs to resolve the interface name a COMMAND carries
("show interface ethernet 1/xg49", "interface 1/g5") back to a port number,
and it must accept exactly the names this renderer prints -- otherwise the
mock would answer for names the real switch does not use (or, as it used to
with a hardcoded ``\\d+/0/(\\d+)`` regex, reject the ``1/g<n>``/``1/xg<n>``
names the Smart-firmware S3300-52X really prints). Resolving through
``_iface`` keeps the two directions in one place.
"""
wanted = iface.strip()
return next((p for p in state.ports if _iface(state, p) == wanted), None)
def _is_m4300(state: VirtualSwitchState) -> bool:
"""True for the M4300 FASTPATH image, whose ``show poe``/``show environment``
column shapes differ from the gsm7252ps image (no PoE ``Temperature`` column;
the PSU sub-table is headed ``Power Modules:`` not ``Power supplies:``). Real
fixtures: tests/fixtures/cli/m4300_16x_show_{poe_port_info_all,environment}.txt.
"""
return state.model_key.startswith("m4300")
def _dotted(label: str, value: object) -> str:
fill = max(2, 46 - len(label))
return f"{label}{'.' * fill} {value}"
def _table(headers: list[str], widths: list[int], rows: list[list[object]]) -> str:
lines = [" ".join(h.ljust(w) for h, w in zip(headers, widths, strict=False))]
lines.append(" ".join("-" * w for w in widths))
for row in rows:
lines.append(
" ".join(str(c).ljust(w) for c, w in zip(row, widths, strict=False))
)
return "\n".join(lines)
def _mac_text(raw: bytes | tuple[int, ...] | str) -> str:
if isinstance(raw, str):
return ":".join(f"{ord(c):02X}" for c in raw)
return ":".join(f"{b:02X}" for b in raw)
def _speed_text(mbps: int) -> str:
if mbps >= 1000 and mbps % 1000 == 0:
return f"{mbps // 1000}G Full"
return f"{mbps} Full"
# --- show version / show network -------------------------------------------
[docs]
def render_version(state: VirtualSwitchState) -> str:
model = get_model(state.model_key)
descr = state.sys_descr or f"NETGEAR {model.display_name} Managed Switch"
return "\n".join(
[
"Switch: 1",
"",
_dotted("System Description", descr),
_dotted("Machine Model", state.model_name or model.display_name),
_dotted("Serial Number", state.serial),
_dotted("Burned In MAC Address", _mac_text(state.nsdp_mac)),
_dotted("Software Version", state.firmware),
]
)
[docs]
def render_network(state: VirtualSwitchState) -> str:
proto = "DHCP" if state.mgmt.mode == "dhcp" else "None"
return "\n".join(
[
_dotted("Interface Status", "Up"),
_dotted("IP Address", state.mgmt.address),
_dotted("Subnet Mask", state.mgmt.netmask),
_dotted("Default Gateway", state.mgmt.gateway),
_dotted("Burned In MAC Address", _mac_text(state.nsdp_mac)),
_dotted("Configured IPv4 Protocol", proto),
_dotted("Management VLAN ID", "1"),
]
)
# --- show hosts -------------------------------------------------------------
[docs]
def render_hosts(state: VirtualSwitchState) -> str:
"""``show hosts``, transcribed from real output captured 2026-08-02.
From m4300-24x (10.1.5.13), m4300-16x (10.1.5.20) and gsm7252ps
(10.1.5.22). All three label the name exactly "Host name", and the resolver
and static-mapping sections around it are reproduced because the reader has
to pick one field out of them -- a mock emitting only the wanted line would
not exercise that at all.
The trailing static-mapping tables are the empty form all three returned;
none had a host-to-address mapping configured.
"""
return "\n".join(
[
_dotted("Host name", state.hostname),
_dotted("Default domain", "Domain name is not configured"),
_dotted("Default domain list", "Domain Name List is not configured"),
_dotted("Domain Name Lookup", "Enabled"),
_dotted("Number of retries", "2"),
_dotted("Retry timeout period", "3"),
_dotted("Name servers (Preference order)", "10.1.5.1"),
"",
"Configured host name-to-address mapping:",
"",
" Host Addresses",
"------------------------ ----------------------",
"No host name is configured to IP address",
"",
" Host Total Elapsed Type Addresses",
"---------------------- ------- ------- ---- --------------",
"No hostname is mapped to an IP address",
]
)
# --- show port all ----------------------------------------------------------
[docs]
def render_ports(state: VirtualSwitchState) -> str:
headers = [
"Intf",
"Type",
"Admin",
"Physical",
"Physical",
"Link",
"Link",
"LACP",
"Flow",
]
widths = [9, 6, 9, 10, 10, 6, 7, 6, 7]
rows: list[list[object]] = []
for p in _phys_ports(state):
sim = state.ports[p]
phys_status = _speed_text(sim.speed) if (sim.link and sim.speed) else ""
rows.append(
[
_iface(state, p),
"",
"Enable" if sim.admin else "Disable",
sim.physical_mode,
phys_status,
"Up" if sim.link else "Down",
"Enable",
"Enable",
# From STATE, not hardcoded. It used to be a literal "Disable",
# which matched every capture by luck and made the CLI face
# incapable of showing a flow-control write at all.
"Enable" if sim.flow_control else "Disable",
]
)
return _table(headers, widths, rows)
# --- show vlan brief / show vlan <id> --------------------------------------
[docs]
def render_vlan_brief(state: VirtualSwitchState) -> str:
headers = ["VLAN ID", "VLAN Name", "VLAN Type"]
widths = [7, 32, 19]
rows: list[list[object]] = []
for vid in sorted(state.vlans):
vsim = state.vlans[vid]
vtype = "Default" if vid == 1 else "Static"
rows.append([vid, vsim.name, vtype])
return _table(headers, widths, rows)
[docs]
def render_vlan_detail(state: VirtualSwitchState, vid: int) -> str:
vsim = state.vlans.get(vid)
header = [
f"VLAN ID: {vid}",
f"VLAN Name: {vsim.name if vsim else ''}",
"VLAN Type: Static" if vid != 1 else "VLAN Type: Default",
"",
]
headers = ["Interface", "Current", "Configured", "Tagging"]
widths = [10, 8, 11, 8]
rows: list[list[object]] = []
member = vsim.member if vsim else set()
untagged = vsim.untagged if vsim else set()
for p in _phys_ports(state):
if p in member:
current, configured = "Include", "Include"
tagging = "Untagged" if p in untagged else "Tagged"
else:
current, configured, tagging = "Exclude", "Autodetect", "Untagged"
rows.append([_iface(state, p), current, configured, tagging])
return "\n".join(header) + "\n" + _table(headers, widths, rows)
# --- show vlan port all (PVIDs) --------------------------------------------
[docs]
def render_pvids(state: VirtualSwitchState) -> str:
headers = [
"Interface",
"Port",
"Port",
"Acceptable",
"Ingress",
"Ingress",
"GVRP",
"Default",
]
widths = [9, 10, 8, 11, 10, 9, 7, 8]
rows: list[list[object]] = []
for p in _phys_ports(state):
pvid = state.pvids.get(p, 1)
rows.append(
[
_iface(state, p),
pvid,
pvid,
"Admit All",
"Disable",
"Disable",
"Enable",
0,
]
)
return _table(headers, widths, rows)
# --- show mac-addr-table ----------------------------------------------------
[docs]
def render_mac_table(state: VirtualSwitchState) -> str:
headers = ["VLAN ID", "MAC Address", "Interface", "IfIndex", "Status"]
widths = [7, 18, 21, 7, 12]
rows: list[list[object]] = []
for msim in state.macs:
ifindex = state.bridge_ports.get(msim.bridge_port, msim.bridge_port)
sim = state.ports.get(ifindex)
iface = sim.name if sim is not None else f"1/0/{ifindex}"
rows.append([msim.vlan, _mac_text(msim.mac_bytes), iface, ifindex, "Learned"])
return _table(headers, widths, rows)
# --- show lldp remote-device all -------------------------------------------
[docs]
def render_lldp(state: VirtualSwitchState) -> str:
headers = ["Interface", "RemID", "Chassis ID", "Port ID", "System Name"]
widths = [9, 8, 20, 18, 18]
rows: list[list[object]] = []
for nb in state.lldp:
rows.append(
[
_iface(state, nb.local_port),
nb.rem_idx,
_mac_text(nb.chassis) if nb.chassis else "",
nb.port_id,
nb.sys_name,
]
)
title = ["LLDP Remote Device Summary", "", "Local"]
return "\n".join(title) + "\n" + _table(headers, widths, rows)
# --- show poe port info all -------------------------------------------------
def _poe_status_text(psim: PoeSim) -> str:
"""The ``Status`` column text a real switch prints for one PSE port.
``show poe port info all`` has NO admin column -- the reader infers admin
state from this text (see ``protocols.cli.parse.parse_poe``: anything other
than "Disabled" means admin-enabled), so an admin-OFF port MUST render as
"Disabled" or the mock would report a PoE-disabled port as still enabled and
hide a broken ``set_poe``. Fault detect codes (RFC3621 4=fault,
6=otherFault) render as "Fault", which is what lets the CLI and SNMP faces
agree about a faulted port instead of the CLI calling it "Searching".
A just-re-enabled port still reports ``Disabled`` for one read, because that
is what the hardware does (see ``PoeSim.cli_status_lag_reads``): this column
is a detection state and lags the admin write. Rendering it consumes the lag,
exactly as re-reading the table on the device eventually shows the new state.
"""
if psim.cli_status_lag_reads > 0:
psim.cli_status_lag_reads -= 1
return "Disabled"
if not psim.admin:
return "Disabled"
if psim.detect == 3:
return "Delivering Power"
if psim.detect in (4, 6):
return "Fault"
return "Searching"
[docs]
def render_poe(state: VirtualSwitchState) -> str:
# Full FASTPATH column names (the real switch wraps them over several header
# lines; a single-line header of the same names parses identically). The
# parser locates columns by NAME -- "Power (mW)" is the live draw, distinct
# from "Max Power (mW)"; "Status" is the PSE state, distinct from "Fault
# Status" -- so these exact strings matter.
# The M4300 FASTPATH image omits the "Temperature" column the gsm7252ps
# prints (real fixtures differ 9-vs-10 columns -- see parse.py:452-461). The
# parser locates columns by NAME so either shape parses, but the mock must
# emit whichever the driving model really prints.
m4300 = _is_m4300(state)
headers = [
"Intf",
"High Power",
"Max Power (mW)",
"Class",
"Power (mW)",
"Output Current (mA)",
"Output Voltage (V)",
*([] if m4300 else ["Temperature"]),
"Status",
"Fault Status",
]
widths = [7, 11, 15, 9, 11, 20, 19, *([] if m4300 else [13]), 18, 13]
rows: list[list[object]] = []
for p in sorted(state.poe):
psim = state.poe[p]
status = _poe_status_text(psim)
rows.append(
[
_iface(state, p),
"Yes" if p <= 8 else "No",
32000 if p <= 8 else 18000,
4 if psim.power_mw else "Unknown",
psim.power_mw,
0,
54 if psim.power_mw else 0,
*([] if m4300 else [30]),
status,
"No Error",
]
)
return _table(headers, widths, rows)
# --- show environment -------------------------------------------------------
[docs]
def render_environment(state: VirtualSwitchState) -> str:
temps = [s for s in state.sensors if s.kind == "temperature"]
fans = [s for s in state.sensors if s.kind == "fan"]
psus = [s for s in state.sensors if s.kind == "power"]
out = [
_dotted("Temp (C)", temps[0].raw if temps else "36"),
_dotted("Fan Speed, RPM", fans[0].raw if fans else "Not Supported"),
"",
"Temperature Sensors:",
]
out.append(
_table(
["Unit", "Sensor", "Description", "Temp (C)", "State", "Max_Temp (C)"],
[4, 6, 16, 10, 14, 14],
[[1, i + 1, s.instance, s.raw, "Normal", 55] for i, s in enumerate(temps)],
)
)
out += ["", "Fans:"]
out.append(
_table(
["Unit", "Fan", "Description", "Type", "Speed", "Duty", "State"],
[4, 3, 14, 9, 13, 13, 14],
[
[1, i + 1, s.instance, "Fixed", s.raw, "Not Supported", "Operational"]
for i, s in enumerate(fans)
],
)
)
# gsm7252ps heads the PSU sub-table "Power supplies:"; the M4300 image heads
# it "Power Modules:" (parse.py:574-577 accepts either). Emit the shape the
# driving model really prints.
out += ["", "Power Modules:" if _is_m4300(state) else "Power supplies:"]
out.append(
_table(
["Unit", "Power supply", "Description", "Type", "State"],
[4, 12, 16, 10, 14],
[
[1, i + 1, s.instance, "Fixed", "Operational"]
for i, s in enumerate(psus)
],
)
)
return "\n".join(out)
# --- show interface ethernet <intf> ----------------------------------------
[docs]
def render_interface_counters(state: VirtualSwitchState, port: int) -> str:
sim = state.ports.get(port)
rx_octets = sim.rx_octets if sim else 0
tx_octets = sim.tx_octets if sim else 0
rx_ucast = sim.rx_ucast if sim else 0
tx_ucast = sim.tx_ucast if sim else 0
rx_errors = sim.rx_errors if sim else 0
tx_errors = sim.tx_errors if sim else 0
return "\n".join(
[
_dotted("Total Packets Received (Octets)", rx_octets or 0),
_dotted("Unicast Packets Received", rx_ucast or 0),
_dotted("Total Packets Received with MAC Errors", rx_errors or 0),
_dotted("Total Packets Transmitted (Octets)", tx_octets or 0),
_dotted("Unicast Packets Transmitted", tx_ucast or 0),
_dotted("Total Transmit Errors", tx_errors or 0),
_dotted("Time Since Counters Last Cleared", "1 day 0 hr 0 min 0 sec"),
]
)
[docs]
def render_port_description(state: VirtualSwitchState, iface: str) -> str:
"""``show port description <iface>``.
Layout transcribed from live output on a GSM7252PS (10.1.5.22,
2026-08-03)::
Interface....... 1/0/8
ifIndex......... 8
Description.....
MAC address..... E0:91:F5:0C:D6:DD
Bit Offset Val.. 8
An unset description prints the label with NOTHING after it -- which is why
the parser maps an empty value to None rather than "". A port the switch
does not have answers with the same rejection any unknown argument gets.
"""
port = next(
(p for p in _phys_ports(state) if _iface(state, p) == iface),
None,
)
if port is None:
return "% Invalid input detected at '^' marker."
sim = state.ports[port]
mac = ":".join(f"{b:02X}" for b in state.nsdp_mac)
return "\n".join(
[
f"Interface....... {iface}",
f"ifIndex......... {port}",
f"Description..... {sim.description or ''}".rstrip(),
f"MAC address..... {mac}",
f"Bit Offset Val.. {port}",
]
)
# --- show logging / show logging hosts --------------------------------------
#: severity number -> the word `show logging hosts` prints. Only the canonical
#: spelling, so 6 renders "info" and never "informational" -- which is what the
#: live m4300-24x/gsm7252ps/gsm7228ps tables all print (2026-08-05).
_SEVERITY_WORDS = {
0: "emergency",
1: "alert",
2: "critical",
3: "error",
4: "warning",
5: "notice",
6: "info",
7: "debug",
}
[docs]
def render_logging(state: VirtualSwitchState) -> str:
"""``show logging``, transcribed from the real output captured 2026-08-05.
The scalar block the reader picks two fields out of. Reproduced with its
neighbours because a mock emitting only "Syslog Logging" and "Logging
Client Local Port" would not exercise ``_colon_fields`` at all -- and it is
the reason the whole block is colon-separated rather than dotted-leader,
unlike `show hosts`/`show network`.
The surrounding counters and the console/buffered rows are the shape all
four switches returned; the gsm7228ps additionally prints two Persistent
Logging rows, which is why the reader takes named fields rather than
offsets.
"""
syslog = state.syslog
return "\n".join(
[
f"Logging Client Local Port : {syslog.local_port}",
"Logging Client Source Interface : serviceport",
"CLI Command Logging : disabled",
"Console Logging : disabled",
"Console Logging Severity Filter : error",
"Buffered Logging : enabled",
"Buffered Logging Severity Filter : notice",
"",
"Syslog Logging : "
+ ("enabled" if syslog.admin_mode == 1 else "disabled"),
"",
"Log Messages Received : 9850",
"Log Messages Dropped : 0",
"Log Messages Relayed : 118",
]
)
[docs]
def render_logging_hosts(state: VirtualSwitchState) -> str:
"""``show logging hosts``, transcribed from real output captured 2026-08-05.
Header and ruler are byte-for-byte what m4300-24x, gsm7252ps and gsm7228ps
all printed. The INDEX column is 1-based and positional -- it is what
``no logging host <index>`` addresses, and the reason a removal has to look
the row up rather than name the address.
A switch with NO collectors prints the header and ruler and nothing else,
which is what makes "empty" distinguishable from "could not ask".
"""
rows = [
"Index IP Address/Hostname Severity Port Status Mode "
"Auth Cert#",
"----- ------------------------ ---------- ------ --------- ----- "
"-------- -----",
]
for c in state.syslog.collectors:
word = _SEVERITY_WORDS.get(c.severity, str(c.severity))
status = "Active" if c.status == 1 else "Inactive"
# c.index, NOT the loop position. The real table's Index column is
# SPARSE -- 1 and 3 with nothing at 2, measured on m4300-24x 10.1.5.13
# -- and a mock that renumbered densely could never expose the
# position-for-index bug that shipped.
rows.append(f"{c.index:<5} {c.host:<24} {word:<10} {c.port:<6} {status:<9} udp")
return "\n".join(rows)