Source code for netgear_switch.virtual.web_gsm7252ps

"""GSM7252PS "XE FASTPATH" page renderers driven by ``VirtualSwitchState``.

Reproduces the real page encoding rather than a convenient one. Every data
cell is a hidden input whose ``NAME`` carries the ROW INSTANCE and whose
``id``/``xid`` carry the COLUMN COORDINATE, with NO field-name comment --

    <TD class="def alt0" p="1.0.520" id=1_2_10><INPUT xid=1_2_10 TYPE=hidden
         NAME=1.0.52.v_1_2_10 VALUE="Link Up">Link Up</TD>

which is exactly what ``parse.parse_xe_rows`` reads back, so the mock
exercises the same column-coordinate-addressed parsing path real hardware
does. Two details of the real encoding are deliberately reproduced because
getting them wrong is what a parser regression would look like:

- the instance prefix is ``1.<row-index>.<row-count>`` (NOT unit/slot/port),
  so a parser that tried to read a port number out of it would produce
  nonsense here too, exactly as it does on hardware;
- each page's own HEADER row is emitted with the same coordinates, so the
  fixture and the mock document the column map identically.

``sysInfo.html`` is NOT an XE page (see ``render_sysinfo``): it uses plain
bold-label/value cells and three status tables, which is what
``parse_xe_labelled_values``/``parse_xe_sensors``/``parse_xe_mgmt_ip`` read.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from . import web_fastpath_xui as xui

if TYPE_CHECKING:
    from collections.abc import Callable

    from .state import SensorSim, VirtualSwitchState


def _iface(port: int) -> str:
    return f"1/0/{port}"


def _cell(instance: str, xid: str, value: str) -> str:
    return (
        f'<TD class="def alt0" p="{instance}0" id={xid}>'
        f"<INPUT xid={xid} TYPE=hidden NAME={instance}.v_{xid} "
        f'VALUE="{value}">{value}</TD>\n'
    )


def _header(labels: dict[str, str]) -> str:
    return "".join(
        f'<TD class="def_TH alt0" id={xid}>{text}</TD>\n'
        for xid, text in labels.items()
    )


def _speed_text(mbps: int) -> str:
    """Real firmware's Physical Status text: "1000 Mbps" / "10G Full "."""
    if mbps >= 1000 and mbps % 1000 == 0:
        return f"{mbps // 1000}G Full "
    return f"{mbps} Mbps"


def _page(body: str) -> str:
    return f"<html><body><form>\n<table>\n{body}</table></form></body></html>\n"


def _physical_ports(state: VirtualSwitchState) -> list[int]:
    """Just the PHYSICAL ports, in order.

    The seeds carry SNMP ifIndex-keyed entries that also include LAG/CPU/VLAN
    interfaces. The real XE port/statistics/PVID/PoE pages list only physical
    ports, so rendering the extras would make the HTTP reader report
    interfaces the web UI never shows -- and disagree with SNMP about which
    ports exist."""
    from ..registry import get_model

    port_count = get_model(state.model_key).port_count
    return [p for p in sorted(state.ports) if p <= port_count]


# The per-row selection checkbox names, taken from the LIVE pages (they differ
# per firmware and per page, which is why the writer scrapes them):
#   gsm7252ps 10.1.5.22  portsConfiguration -> 1.<row>.52.gecb5
#                        poeInterfaceConfiguration -> 1.<row>.48.gecb234
_PORTS_CHECKBOX = "gecb5"
_POE_CHECKBOX = "gecb234"


[docs] def render_ports( state: VirtualSwitchState, *, err_msg: str = "", iface: Callable[[int], str] = _iface, checkbox: str = _PORTS_CHECKBOX, path: str = "/portsConfiguration.html", ) -> str: """``/portsConfiguration.html`` -- per-port admin/link/speed + ifindex. This is the WRITE page as well as the read page (``set_port_enabled``), so it is rendered with the real XUI scaffolding: two forms, ``<TR p=...>`` rows each carrying their own ``gecb`` checkbox, the redirection block and the CANCEL/APPLY buttons -- see ``web_fastpath_xui``. """ body = _header( { "1_2_1": "Port", "1_2_6": "Admin <br/> Mode", "1_2_9": "Physical Status", "1_2_10": "Link Status", "1_2_13": "ifindex", } ) ports = _physical_ports(state) for index, port in enumerate(ports): sim = state.ports[port] inst = xui.instance(index, len(ports)) cells = _cell(inst, "1_2_1", iface(port)) cells += _cell(inst, "1_2_6", "Enable" if sim.admin else "Disable") # A down port's Physical Status reads "Unknown" on real hardware. cells += _cell(inst, "1_2_9", _speed_text(sim.speed) if sim.link else "Unknown") cells += _cell(inst, "1_2_10", "Link Up" if sim.link else "Link Down") cells += _cell(inst, "1_2_13", str(port)) body += xui.row(inst, cells, checkbox=checkbox) return xui.page( path, f"{xui.nav_rows()}<table>\n{body}</table>\n", buttons={"2_1_1": "CANCEL", "2_1_2": "APPLY"}, err_msg=err_msg, title="NetGear - Port Configuration", )
[docs] def apply_ports( state: VirtualSwitchState, form: dict[str, str], *, checkbox: str = _PORTS_CHECKBOX, ) -> str: """Apply a portsConfiguration POST; returns the firmware ``err_msg``.""" ports = _physical_ports(state) return xui.apply_port_admin( state, form, checkbox=checkbox, ports=ports, count=len(ports) )
[docs] def render_port_statistics(state: VirtualSwitchState) -> str: """``/portStatistics.html`` -- PACKET counters (this page has no octets). The virtual state stores octet counters too, but they are deliberately NOT rendered here: the real page has no octet column, and emitting one would let a regression that reads bytes off this page pass CI. """ body = _header( { "1_1_103": "Interface", "1_1_2": "Total Packets received without Errors", "1_1_3": "Packets received with Errors", "1_1_5": "Packets transmitted without Errors", "1_1_6": "Transmit Packet Errors", } ) ports = _physical_ports(state) for row, port in enumerate(ports): sim = state.ports[port] inst = f"1.{row}.{len(ports)}" body += _cell(inst, "1_1_103", _iface(port)) body += _cell(inst, "1_1_2", str(sim.rx_ucast or 0)) body += _cell(inst, "1_1_3", str(sim.rx_errors or 0)) body += _cell(inst, "1_1_5", str(sim.tx_ucast or 0)) body += _cell(inst, "1_1_6", str(sim.tx_errors or 0)) return _page(body)
[docs] def render_pvids(state: VirtualSwitchState) -> str: """``/portPvidConfiguration.html`` -- Configured + Current PVID columns.""" body = _header( { "1_2_1": "Interface", "1_2_4": "Configured <br/> PVID", "1_2_9": "Current <br/> PVID", } ) physical = _physical_ports(state) rows = [(p, state.pvids[p]) for p in physical if p in state.pvids] for row, (port, pvid) in enumerate(rows): inst = f"1.{row}.{len(rows)}" body += _cell(inst, "1_2_1", _iface(port)) body += _cell(inst, "1_2_4", str(pvid)) body += _cell(inst, "1_2_9", str(pvid)) return _page(body)
[docs] def render_vlans(state: VirtualSwitchState) -> str: """``/vlanStatus.html`` -- VLANs with their egress port list. The egress list is rendered in the real firmware's format, including the ``lag N`` entries that must NOT be expanded into physical ports.""" from ..registry import get_model port_count = get_model(state.model_key).port_count body = _header( { "1_1_1": "VLAN <br/> ID", "1_1_2": "VLAN Name", "1_1_3": "VLAN Type", "1_1_4": "Member Ports", } ) vlans = sorted(state.vlans.items()) for row, (vid, vsim) in enumerate(vlans): inst = f"1.{row}.{len(vlans)}" # Seeds carry SNMP ifIndex-based member sets where indexes above the # physical port count are LAGs; rendering those as "1/0/<ifIndex>" # would make the reader report ports that do not exist. physical = [p for p in sorted(vsim.member) if p <= port_count] lags = [p for p in sorted(vsim.member) if p > port_count] parts = [_iface(p) for p in physical] parts += [f"lag {i}" for i, _ in enumerate(lags, start=1)] body += _cell(inst, "1_1_1", str(vid)) body += _cell(inst, "1_1_2", vsim.name or "") body += _cell(inst, "1_1_3", "Default" if vid == 1 else "Static") body += _cell(inst, "1_1_4", ", ".join(parts)) return _page(body)
[docs] def render_mac_table(state: VirtualSwitchState) -> str: """``/basicAddressTable.html`` -- the learned MAC/FDB table. The "Total MAC Addresses" scalar the real page carries is rendered too, with the true row count, so the reader's anti-truncation guard is exercised against a page that is legitimately complete.""" body = ( "<TR id=1_1 class=deftestme>\n" "<TD class=defleft id=1_1_1>Total MAC Addresses</TD>\n" f"<TD class=defright id=1_1_1><INPUT xid=1_1_1 TYPE=hidden " f'NAME=v_1_1_1 VALUE="{len(state.macs)}"></TD>\n</TR>\n' ) body += _header( { "1_2_1": "VLAN ID", "1_2_3": "MAC Address", "1_2_4": "Port", "1_2_6": "status", } ) for row, entry in enumerate(state.macs): inst = f"1.{row}.{len(state.macs)}" mac = ":".join(f"{b:02X}" for b in entry.mac_bytes) # The page names the INTERFACE an address was learned on, which is the # ifIndex-keyed port -- not the bridge-port index. Rendering the raw # bridge port would make this page disagree with SNMP's get_macs # (which joins through dot1dBasePortIfIndex) on exactly the entries # whose join is not the identity. port = state.bridge_ports.get(entry.bridge_port, entry.bridge_port) body += _cell(inst, "1_2_1", str(entry.vlan)) body += _cell(inst, "1_2_3", mac) body += _cell(inst, "1_2_4", _iface(port)) body += _cell(inst, "1_2_6", "Learned") return _page(body)
# RFC3621 pethPsePortDetectionStatus -> the real page's Status text. Only the # codes the virtual state actually uses are mapped; anything else renders as # the firmware's catch-all fault text rather than a fabricated status. _DETECT_TEXT = {1: "Disabled", 2: "Searching", 3: "Delivering power"}
[docs] def render_poe( state: VirtualSwitchState, *, watts: bool = False, err_msg: str = "", iface: Callable[[int], str] = _iface, checkbox: str = _POE_CHECKBOX, reset_label: str = "RESET", path: str = "/poeInterfaceConfiguration.html", ) -> str: """``/poeInterfaceConfiguration.html`` -- per-port PoE admin/status/power. ``watts`` selects the "Output Power" cell format to MATCH the emulated firmware: the gsm7252ps renders integer milliwatts (``watts=False``, e.g. "3500"); the M4300-16X renders watts with two decimals (``watts=True``, e.g. "4.60"). Both decode back to the same milliwatts via ``parse._poe_power_to_mw`` -- see the parity note there. Also the WRITE page (``set_poe``/``cycle_poe``/``clear_poe_fault``), so it carries the real scaffolding INCLUDING the hidden write-only "Port Reset" column ``v_1_2_20`` (``xp_1_2_20 = "write-only"``, enum ``["None","Reset"]``) that every row renders as ``Reset`` on real hardware, and the extra RESET button ``v_2_1_3`` its APPLY-sibling page does not have. """ body = _header( { "1_2_1": "Port", "1_2_2": "Admin <br/> Mode", "1_2_15": "Ouput <br/> Power <br/> (mW)", "1_2_17": "Status", } ) poe = sorted(state.poe.items()) for index, (port, sim) in enumerate(poe): inst = xui.instance(index, len(poe)) cells = _cell(inst, "1_2_1", iface(port)) cells += _cell(inst, "1_2_2", "Enable" if sim.admin else "Disable") power = sim.power_mw or 0 cell = f"{power / 1000:.2f}" if watts else str(power) cells += _cell(inst, "1_2_15", cell) cells += _cell(inst, "1_2_17", _DETECT_TEXT.get(sim.detect, "Other Fault")) cells += _cell(inst, "1_2_20", "Reset") body += xui.row(inst, cells, checkbox=checkbox) return xui.page( path, f"{xui.nav_rows()}<table>\n{body}</table>\n", buttons={ # LIVE: the M4300s label this button "Power Cycle Port(s)" while the # gsm72xx pages label it "RESET" -- so the label is a parameter, and # a writer that hard-coded either would fail against the other. "2_1_3": reset_label, "2_1_1": "CANCEL", "2_1_2": "APPLY", }, err_msg=err_msg, title="NetGear - PoE Port Configuration", )
# This page's read-write columns, in the order the firmware reports failures # for them, mapped to the ``xeleName`` it prints. Only the columns this mock # actually renders are listed -- inventing the rest would put words in the # device's mouth. Captured verbatim from 10.1.5.22 (firmware 10.0.1.6) on # 2026-07-31; the real switch's full list also carries 'Port <br/> Priority', # 'Power <br/> Limit <br/> Type', 'Power <br/> Limit <br/> (mW)', # 'Detection <br/> Type' and 'Timer <br/> Schedule' between these two. _POE_RW_COLUMNS = (("v_1_2_2", "Admin <br/> Mode"), ("v_1_2_20", "Port Reset")) def _no_list_unit_refusal(form: dict[str, str], prefix: str) -> str: """The refusal a real GSM7252PS answers when the POST omits the list unit. LIVE 2026-07-31, 10.1.5.22 port 1/0/35: an apply whose body carried the row, its checkbox and the redirection block but NEITHER ``v_1_1_1`` nor ``v_1_3_1`` came back HTTP **200** with ``err_flag=1`` and one ``Error! Failed to Set '<xeleName>' with '<value>'`` line per read-write column IN THE BODY -- even though the body changed nothing. Dropping ``v_1_2_20`` from that same body removed exactly its 'Port Reset' line, which is how the per-column shape was established. """ lines = [ f"Error! Failed to Set '{label}' with '{form[prefix + column]}'" for column, label in _POE_RW_COLUMNS if prefix + column in form ] return "\r\n".join(lines)
[docs] def apply_poe( state: VirtualSwitchState, form: dict[str, str], *, checkbox: str = _POE_CHECKBOX, unit_required: bool = True, ) -> str: """Apply a poeInterfaceConfiguration POST; returns the firmware ``err_msg``. Two distinct operations share the page, exactly as on hardware: APPLY (``v_2_1_2``) writes the Admin Mode column, RESET (``v_2_1_3``) consumes the write-only ``v_1_2_20`` column and re-runs detection -- which on a port with no PD attached lands back in ``Searching`` and on one that had faulted clears the fault. Only CHECKED rows are touched. ``unit_required`` reproduces a MEASURED per-firmware difference, not a guess. The GSM7252PS PoE rows carry no hidden Unit key column, so its firmware takes the list scope from the page's ``urlListUnit`` field and refuses the whole row without it (default ``True`` -- this is that model's renderer). The gsm7228ps and both M4300 PoE pages DO render a per-row ``v_1_2_21`` "Unit" key and accepted the same body with no page-level unit at all (live 2026-07-30 on 10.1.5.11 and 10.1.5.20:49152), so those renderers pass ``False``. Encoding the counter-example matters as much as the rule: without it the mock could not tell a writer that over-corrected. """ if not xui.is_apply(form): return "" ports = [p for p, _ in sorted(state.poe.items())] button = xui.pressed(form, ("v_2_1_3", "v_2_1_2")) for prefix in xui.checked_rows(form, checkbox): if unit_required and not xui.has_list_unit(form): return _no_list_unit_refusal(form, prefix) row0 = int(prefix.split(".")[1]) if row0 >= len(ports): continue sim = state.poe[ports[row0]] if button == "v_2_1_3": value = form.get(prefix + "v_1_2_20") if value not in ("Reset", "None", None): return f"Error! Failed to Set 'Port Reset' with '{value}'" if value == "Reset": # Re-arm detection: a faulted port leaves FAULT, an idle one # stays Searching, a powered one comes back up. sim.detect = 3 if sim.power_mw else 2 continue admin = form.get(prefix + "v_1_2_2") if admin is None: continue if admin not in ("Enable", "Disable"): return f"Error! Failed to Set 'Admin <br/> Mode' with '{admin}'" sim.admin = admin == "Enable" if not sim.admin: sim.detect = 1 elif sim.detect == 1: sim.detect = 2 return ""
[docs] def render_lldp(state: VirtualSwitchState) -> str: """``/lldpRemoteInventory.html`` -- LLDP neighbours. This page has NO remote-port-DESCRIPTION column, so ``LldpSim.port_desc`` is deliberately not rendered: the mock must not expose data the real page does not have.""" body = _header( { "1_1_1": "Port", "1_1_7": "MAC Address", "1_1_8": "System Name", "1_1_9": "Remote Port ID", } ) for row, nb in enumerate(state.lldp): inst = f"1.{row}.{len(state.lldp)}" chassis = ":".join(f"{ord(c):02X}" for c in nb.chassis) body += _cell(inst, "1_1_1", _iface(nb.local_port)) body += _cell(inst, "1_1_7", chassis) body += _cell(inst, "1_1_8", nb.sys_name) body += _cell(inst, "1_1_9", nb.port_id) return _page(body)
def _status_table(title: str, rows: list[tuple[str, str]]) -> str: """One ``sysInfo.html`` status table: a header row of unit columns plus one labelled row per sensor (only unit 1 is populated, as on a single-unit switch).""" header = "".join( f'<td class="messageTableHeaderBorder messageTableHeaderVerticalBorder">' f"{n}</td>\n" for n in ("Unit ID", "1") ) body = "".join( f'<tr>\n<td class="messageTableWhiteBorder font10Bold">{label}</td>\n' f'<td class="messageTableWhiteBorder font10">{value}</td>\n</tr>\n' for label, value in rows ) return ( f"<tr><td colspan=\"3\"><script>tbhdr('{title}','x')</script></td></tr>\n" f'<tr class="white10Bold">\n{header}</tr>\n{body}' ) def _sensor_label(sensor: SensorSim) -> str: """The page label for a seeded HTTP sysInfo sensor. For the sysInfo sensor set (``state.sysinfo_sensors``) the ``instance`` field carries the page's OWN row label -- "System"/"CPU"/"MAC-A" for temperatures, "Fan1/PWR"/"Fan2/CPU" for fans, "RPS"/"Power Module" for the device rows -- exactly as the real gsm7252ps_sysInfo.html prints them.""" return sensor.instance
[docs] def render_sysinfo(state: VirtualSwitchState) -> str: """``/base/system/management/sysInfo.html`` -- mgmt IP, base MAC and the three status tables. Renders the model's HTTP sysInfo sensor set (``state.sysinfo_sensors``), which on this device is DIFFERENT from its SNMP set: the web UI exposes a Temperature Status table (numeric degC, "N/A" for an unpopulated slot which the parser skips), a FAN Status table reporting fan HEALTH as text ("OK"/"NA", never RPM), and a Device Status table with the RPS + Power Module operational flags. Each cell is the sensor's literal captured page text, so the parser reads back exactly the real hardware's HTTP sensors. """ mac = ":".join(f"{b:02X}" for b in state.nsdp_mac) sensors = state.sysinfo_sensors temps = [ (_sensor_label(s), f"{s.raw}&degC" if s.raw.isdigit() else "N/A") for s in sensors if s.kind == "temperature" ] fans = [(_sensor_label(s), s.raw) for s in sensors if s.kind == "fan"] device = [ ("Firmware Version", state.firmware), ("Serial Number", state.serial), ] device += [(_sensor_label(s), s.raw) for s in sensors if s.kind == "power"] return ( "<html><body><form>\n<table>\n" '<tr><td class="font10Bold">Product Name</td>' f'<td class="font10">{state.model_name}</td></tr>\n' '<tr><td class="font10Bold">System Name</td>' f'<td><INPUT class="input" type="TEXT" name="sysName" ' f'VALUE="{state.hostname}"></td></tr>\n' '<tr><td class="font10Bold">IPv4 Network Interface</td>' f'<td class="font10"><a href="/ipConfiguration.html">' f"{state.mgmt.address}/{state.mgmt.netmask}</a></td></tr>\n" '<tr><td class="font10Bold">System MAC Address</td>' f'<td class="font10">{mac}</td></tr>\n' f"{_status_table('FAN Status', fans)}" f"{_status_table('Temperature Status', temps)}" f"{_status_table('Device Status', device)}" "</table></form></body></html>\n" )