Virtual switch

The mock switches. Narrative documentation is in The virtual switch; this is the reference.

Server and state

Virtual switch: an in-memory device state plus protocol “faces” onto it.

VirtualSwitchState (state.py) is the one authoritative in-memory device state; seed.py hand-authors a realistic instance for a given model; the faces subpackage (Task 15+) serves that state over a real protocol (e.g. SNMP) so both transport clients can be tested against it end-to-end.

VirtualSwitch: a mock switch server binding protocol faces to a state.

Constructed from a model key, it seeds (or defaults) a VirtualSwitchState and, on start(), binds whichever protocol faces the model’s registry entry supports: SNMP for managed switches, NSDP and/or HTTP for Plus switches. Each supported backend is bound in its own independent if block, so a {NSDP, HTTP} model binds both an NSDP face (self.port) and an HTTP face (self.http_port) concurrently.

class netgear_switch.virtual.server.VirtualSwitch(
model,
community='public',
http_password='password',
*,
host='127.0.0.1',
port=0,
http_port=0,
)[source]

Bases: object

A virtual switch server: a seeded state plus its bound protocol faces.

host defaults to loopback (127.0.0.1); pass another address (e.g. 0.0.0.0 to expose the mock to other hosts) to bind elsewhere. The port/http_port arguments pin the UDP (SNMP or NSDP) and HTTP listen ports respectively; the default of 0 asks the OS for an ephemeral port, whose actual value is readable off self.port / self.http_port after start().

start()[source]

Bind every protocol face this model’s registry entry supports.

cli_session()[source]

Return an in-process mock FASTPATH CLI session over this switch’s state.

Unlike the SNMP/NSDP/HTTP faces (real sockets bound in start()), the CLI face is an in-process CliSession needing no socket – see virtual.faces.cli. Raises UnsupportedCapabilityError (via cli_spec) for a model with no CLI backend.

Return type:

VirtualCliFace

stop()[source]

Stop every bound face. Safe to call if start() failed or never ran.

property bound_endpoints: list[tuple[str, str, int]]

The faces this switch has actually bound, as (protocol, l4, port).

protocol is the upper-case backend name (SNMP/NSDP/HTTP), l4 the transport (udp/tcp), and port the actually-bound port. Empty until start() has run (and again after stop()).

netgear_switch.virtual.server.serve_forever(switches, *, out, stop=None, ready=None)[source]

Start switches, print where each is reachable, and block until stop.

Each switch is start()``ed independently; a switch that cannot bind any face (``UnsupportedCapabilityError) or otherwise fails to start is reported on out and skipped, so one bad model never takes the rest of the fleet down. For every switch that does come up, its model, host, bound port(s) (with transport), SNMP community and HTTP password are printed so an external tool knows exactly where and how to connect.

Blocks on stop.wait() (a fresh Event is created if none is passed — in that case only a KeyboardInterrupt unblocks it) until signalled, then calls stop() on every switch it started, in reverse order, even on exception. Returns the number of switches successfully served (0 means nothing bound, and the function returns immediately without blocking).

ready, if given, is set once every switch has been started and its endpoints printed — a test hook so a caller can wait for “fully up” before connecting, without racing the startup prints.

Return type:

int

The one authoritative in-memory virtual-switch device state.

VirtualSwitchState holds everything a simulated switch “knows” about itself — port link/admin/speed, counters, VLANs, PoE, sensors, the MAC/FDB table, LLDP neighbours and the management IP — as small mutable *Sim dataclasses. oid_map() projects that state onto the flat numeric OID -> (snmp_type, value) view a protocol face (Task 15) serves and the Task 5-9 parsers consume. This module is pure data + projection: no network.

exception netgear_switch.virtual.state.CommitFailedError[source]

Bases: Exception

The agent accepted the varbind’s type but refused to apply it.

Models a real SNMP commitFailed. VERIFIED on an M4300-24X (FASTPATH 12.0.13.8): a SET of dot1qVlanStaticEgressPorts commitFails even when writing back byte-identical octets, because per-port switchport mode – not the Q-BRIDGE PortList – owns VLAN membership on that firmware.

exception netgear_switch.virtual.state.NotWritableError[source]

Bases: Exception

The object exists but is read-only (real agents answer notWritable).

exception netgear_switch.virtual.state.InconsistentValueError[source]

Bases: Exception

The agent refuses this value in the device’s current state.

Models a real SNMP inconsistentValue. VERIFIED on the GS728TPP (sw-netgear-gs728tpp.monarto.mithis.com / 10.2.5.10, firmware 6.0.1.30, 2026-08-03): every documented way of CREATING a dot1qVlanStaticTable row is refused with exactly this error – createAndGo(4) alone, createAndGo with dot1qVlanStaticName in the same PDU, createAndWait(5) then name then active(1), setting the name column alone, and createAndGo carrying an empty 126-byte egress PortList. The same firmware happily writes an EXISTING row’s membership and accepts destroy(6), so this is specifically row creation, not a read-only table.

netgear_switch.virtual.state.encode_port_bitmap(ports, width_bytes=8)[source]

Inverse of parse.decode_port_bitmap: a port set -> a latin-1 bitmap.

Delegates to the canonical bytes encoder in protocols/snmp/write.encode_port_bitmap (single source of truth for the MSB-first bit-packing) and decodes to the latin-1 str this module’s callers expect.

Return type:

str

class netgear_switch.virtual.state.PortSim(
name,
admin,
link,
speed,
if_type=6,
rx_octets=None,
tx_octets=None,
rx_ucast=None,
tx_ucast=None,
rx_errors=None,
tx_errors=None,
switchport_mode='general',
description=None,
flow_control=True,
serves_etherlike=False,
physical_mode='Auto',
autoneg_admin='1',
speed_admin='1000',
duplex_admin_mode='3',
)[source]

Bases: object

One switch port’s link/admin/speed/name plus optional HC counters.

Counters are int | None: None means “this port does not expose this counter” and must round-trip to an absent row in oid_map() (no fabricated zero), so parse_port_stats yields None there too.

name: str
admin: bool
speed: int
if_type: int = 6
rx_octets: int | None = None
tx_octets: int | None = None
rx_ucast: int | None = None
tx_ucast: int | None = None
rx_errors: int | None = None
tx_errors: int | None = None
switchport_mode: str = 'general'
description: str | None = None
flow_control: bool = True
serves_etherlike: bool = False
physical_mode: str = 'Auto'
autoneg_admin: str = '1'
speed_admin: str = '1000'
duplex_admin_mode: str = '3'
class netgear_switch.virtual.state.UserSim(name, http_access_mode)[source]

Bases: object

One local login account, as the switch’s own pages word it.

http_access_mode is stored VERBATIM rather than derived from a privilege flag, because the same account is worded DIFFERENTLY depending on which face is asked – measured on 10.1.5.22 and 10.1.5.13, where admin reads “Super User” on userManagement.html but “Read/Write” and “Privilege-15” respectively through each switch’s own show users. A mock that stored one level and rendered it per face would be inventing the wording; storing what each page really emits means the reader’s own word-to-privilege mapping is what gets exercised.

The CLI face has no show users yet. When it gains one it needs its own field here, NOT this one: the two faces genuinely disagree.

name: str
http_access_mode: str
class netgear_switch.virtual.state.ServiceSim(enabled, port=None)[source]

Bases: object

One management service’s admin state, as its own config page reports it.

port is None where the page carries NO port field – which is a real per-page difference, not a gap in the mock: the m4300 SSH page publishes v_1_10_1='22' while the gsm7252ps SSH page has no such coordinate at all (measured 2026-08-03). Seeding 22 there would make the fake claim a field the device does not print.

enabled: bool
port: int | None = None
class netgear_switch.virtual.state.SyslogCollectorSim(host, port, severity, status=1, index=1)[source]

Bases: object

One remote syslog collector row, as the vendor host table reports it.

Field values are SEEDED from a live switch, never computed: the severity is the standard syslog number the device actually returns (6 for “info” on m4300-24x, cross-checked against its own show logging hosts), and status 1 is what that command prints as “Active”.

host: str
port: int
severity: int
status: int = 1
index: int = 1

The row’s index in the switch’s own host table. SPARSE on real hardware – m4300-24x 10.1.5.13 held Index 1 and Index 3 with nothing at 2 (2026-08-05) – so the mock stores it rather than deriving it from list position. A fake that renumbered densely could never catch a position-for-index bug, which is exactly the bug that got shipped.

class netgear_switch.virtual.state.SyslogSim(admin_mode=2, local_port=514, collectors=<factory>)[source]

Bases: object

The switch’s remote-logging state, as the vendor .14 subtree reports it.

MEASURED 2026-08-02 – see docs/superpowers/specs/. admin_mode is the device’s own enum (1 = enabled, 2 = disabled), kept as the raw integer rather than a bool so the mock emits exactly what a real agent emits and the reader’s own decoding is what gets exercised.

admin_mode: int = 2
local_port: int = 514
collectors: list[SyslogCollectorSim]
class netgear_switch.virtual.state.VlanSim(
name,
member=<factory>,
untagged=<factory>,
configured_only=<factory>,
static_row=True,
)[source]

Bases: object

One dot1q VLAN: display name plus egress-member and untagged port sets.

member/untagged are the CURRENT (operational) egress sets – what show vlan <id> prints under Current: Include, what the FASTPATH vlanStatus.html Member Ports cell lists, and what the VLAN Membership page’s hiddenTagged/hiddenUnTagged ifName lists carry.

name: str
member: set[int]
untagged: set[int]
configured_only: set[int]
static_row: bool = True
property configured: set[int]

current members plus configured_only.

Type:

The CONFIGURED egress set

class netgear_switch.virtual.state.VlanMembershipPageSim(
slots,
lag_slot,
grid,
trailing_comma=False,
csrf=False,
escape=False,
)[source]

Bases: object

MEASURED shape of one model’s FASTPATH “VLAN Membership” page.

Every field below is a number/flag read off a real capture of switching/dot1q/vlan_port_cfg.html (2026-07-30), NOT derived from the port count – deliberately, for the same reason vlan_portlist_width is seeded rather than computed: a mock that re-derives a device constant using the code’s own formula can only ever agree with the code (principle 5).

Live measurements:

model

slots

lag_slot

grid

trailing_comma

csrf

escape

gsm7252ps

116

3

gif

no

no

no

gsm7228ps

78

3

png

no

no

yes

m4300-24x

152

13

png

yes

no

yes

m4300-16x

144

13

png

yes

yes

yes

slots = physical ports first, then the LAG pseudo-interfaces (so slots - port_count LAGs: 64 / 26 / 128 / 128). lag_slot is the middle component of a LAG ifName (0/3/N on the gsm72xx, 0/13/N on the M4300). grid selects which of the two firmware generations’ port grids the page renders: "gif" = the older toggleImageFirst + grey_[btu].gif cells (0-BASED hiddenMem index), "png" = the jQuery togImg + switch_<state>_inactive.png cells (1-BASED index). trailing_comma reproduces the M4300 firmware appending an empty field to hiddenMem/hiddenTagged. csrf renders the per-page CSRFToken the M4300-16X requires back on every POST. escape HTML-entity-escapes the ifName lists (1&#x2F;0&#x2F;49) as every firmware but the gsm7252ps does.

slots: int
lag_slot: int
grid: str
trailing_comma: bool = False
csrf: bool = False
escape: bool = False
class netgear_switch.virtual.state.PoeSim(admin, detect, power_mw=0, cli_status_lag_reads=0)[source]

Bases: object

One PoE port: RFC3621 admin/detect state plus vendor delivered power.

admin: bool
detect: int
power_mw: int = 0
cli_status_lag_reads: int = 0
class netgear_switch.virtual.state.SensorSim(kind, instance, raw)[source]

Bases: object

One box sensor reading (fan RPM / PSU watts / temperature).

raw is the literal wire text: either a decimal integer string or Netgear’s "Not Supported" placeholder for an unpopulated slot.

kind: str
instance: str
raw: str
class netgear_switch.virtual.state.EntitySim(index, phys_class, name, descr)[source]

Bases: object

One ENTITY-MIB entPhysicalTable component (index + class/name/descr).

Used only by models whose SNMP agent exposes the fan/PSU sensor INVENTORY via the standard ENTITY-MIB rather than a Netgear vendor sensor column (verified: the GS728TPP). phys_class is the entPhysicalClass int enum (6=powerSupply, 7=fan). No live value/status exists on the wire – this is inventory only (see protocols/snmp/parse.parse_entity_sensors).

index: int
phys_class: int
name: str
descr: str
class netgear_switch.virtual.state.MacSim(vlan, mac_bytes, bridge_port)[source]

Bases: object

One learned MAC/FDB entry: VLAN, 6-byte MAC, bridge-port index.

vlan: int
mac_bytes: tuple[int, int, int, int, int, int]
bridge_port: int
class netgear_switch.virtual.state.LldpSim(time_mark, local_port, rem_idx, chassis, port_id, port_desc, sys_name)[source]

Bases: object

One lldpRemTable neighbour row group.

time_mark: int
local_port: int
rem_idx: int
chassis: str
port_id: str
port_desc: str
sys_name: str
class netgear_switch.virtual.state.MgmtSim(address, netmask, gateway, mode)[source]

Bases: object

The switch’s own management-IP configuration.

address: str
netmask: str
gateway: str
mode: str
class netgear_switch.virtual.state.ScpCertDeploy(
commands=<factory>,
copies=<factory>,
https_disabled=False,
https_enabled=False,
saved=False,
)[source]

Bases: object

Record of a FASTPATH copy scp:// SSL-cert deploy the mock CLI face received (see virtual.faces.cli.VirtualCliFace.run_scp_copy).

Purely a record of the EXEC sequence the library ISSUED – the copy commands and their nvram: destinations, plus the HTTPS toggle + save-config steps – so a test can assert the deploy driver drove the switch correctly. It does NOT carry cert bytes: the SCP deploy pulls the PEM from a staging server the caller set up (the library only sends the copy commands), so there is no PEM to observe here. Not part of any SNMP/NSDP/HTTP projection.

commands: list[str]
copies: list[tuple[str, str]]
https_disabled: bool = False
https_enabled: bool = False
saved: bool = False
class netgear_switch.virtual.state.VirtualSwitchState(
model_key,
ports=<factory>,
vlans=<factory>,
pvids=<factory>,
poe=<factory>,
sensors=<factory>,
http_sensors=None,
entity_components=<factory>,
macs=<factory>,
bridge_ports=<factory>,
lldp=<factory>,
mgmt=<factory>,
model_name='',
serial='',
firmware='',
hostname='',
syslog=<factory>,
users=<factory>,
services=<factory>,
nsdp_password='password',
nsdp_auth_version=1,
nsdp_last_salt=None,
nsdp_auth_failures=0,
nsdp_qos_engine=None,
nsdp_port_mirroring_dest=None,
nsdp_port_mirroring_sources=<factory>,
nsdp_igmp_snooping_enabled=None,
nsdp_igmp_snooping_vlan=None,
nsdp_broadcast_filtering=None,
nsdp_loop_detection=None,
nsdp_mac=b'(\xc6\x8e\x00\x00\x01',
sys_descr='',
sys_object_id='',
uploaded_cert=None,
scp_cert_deploy=None,
reboots=0,
dot1d_base_mac_ascii=False,
vlan_portlist_width=None,
switchport_mode=<factory>,
switchport_access_vlan=<factory>,
switchport_native_vlan=<factory>,
switchport_allowed_vlans=<factory>,
switchport_general_untagged=<factory>,
switchport_general_tagged=<factory>,
pdu_egress_writes=<factory>,
vlan_membership_page=None,
vlan_membership_locked_ports=frozenset({}),
)[source]

Bases: object

The one authoritative virtual-switch device state.

A mutable holder (later slices mutate it to simulate writes); pure data plus the oid_map() SNMP projection, no network here.

model_key: str
ports: dict[int, PortSim]
vlans: dict[int, VlanSim]
pvids: dict[int, int]
poe: dict[int, PoeSim]
sensors: list[SensorSim]
http_sensors: list[SensorSim] | None = None
entity_components: list[EntitySim]
macs: list[MacSim]
bridge_ports: dict[int, int]
lldp: list[LldpSim]
mgmt: MgmtSim
model_name: str = ''
serial: str = ''
firmware: str = ''
hostname: str = ''
syslog: SyslogSim

Remote-logging state. Only meaningful for a model with a vendor subtree; oid_map() projects it under <vendor base>.14 for those models only, which is what makes gs728tpp (no vendor OIDs) correctly unable to answer.

users: list[UserSim]

Local login accounts, as userManagement.html lists them. Empty for a model whose UI has no such page located, so its HTTP face 404s that URL exactly as the real switch does.

services: dict[str, ServiceSim]

Management-service admin state, keyed “http”/”https”/”ssh”/”telnet”, as each service’s own config page reports it. Empty for a model whose pages have not been located.

nsdp_password: str = 'password'
nsdp_auth_version: int = 1
nsdp_last_salt: bytes | None = None
nsdp_auth_failures: int = 0
nsdp_qos_engine: int | None = None
nsdp_port_mirroring_dest: int | None = None
nsdp_port_mirroring_sources: frozenset[int]
nsdp_igmp_snooping_enabled: bool | None = None
nsdp_igmp_snooping_vlan: int | None = None
nsdp_broadcast_filtering: bool | None = None
nsdp_loop_detection: bool | None = None
nsdp_mac: bytes = b'(\xc6\x8e\x00\x00\x01'
sys_descr: str = ''
sys_object_id: str = ''
uploaded_cert: str | None = None
scp_cert_deploy: ScpCertDeploy | None = None
reboots: int = 0
dot1d_base_mac_ascii: bool = False
vlan_portlist_width: int | None = None
switchport_mode: dict[int, int]
switchport_access_vlan: dict[int, int]
switchport_native_vlan: dict[int, int]
switchport_allowed_vlans: dict[int, bytes]
switchport_general_untagged: dict[int, set[int]]
switchport_general_tagged: dict[int, set[int]]
pdu_egress_writes: set[int]
vlan_membership_page: VlanMembershipPageSim | None = None
vlan_membership_locked_ports: frozenset[int] = frozenset({})
property sysinfo_sensors: list[SensorSim]

The sensor set the HTTP sysInfo page renders.

Returns http_sensors when a model’s web UI exposes a different sensor set than SNMP (e.g. the gsm7252ps), else falls back to sensors so a model whose two faces agree (the M4300) is unchanged.

oid_map()[source]

Project this state onto the full numeric OID -> (type, value) view.

Built directly from the exact OID layouts in protocols.snmp.oids so a protocol face can serve it and the Task 5-9 parsers reconstruct the seeded state from what the face returns.

Return type:

dict[str, tuple[str, str]]

snapshot()[source]

Deep-copy this state, for atomic multi-varbind SET rollback.

A single SNMP SET PDU can carry several varbinds (e.g. set_vlan_membership writing both the egress and untagged bitmaps in one set_many call) and a real agent guarantees they apply all-or-nothing. faces/snmp.py’s write_variables snapshots the state before applying a PDU’s varbinds and calls restore on this snapshot if any of them fails, so a partial mutation is never observable. See restore.

Also marks a PDU boundary: pdu_egress_writes (which tracks same-PDU egress writes for the S3300’s auto-untag ordering quirk) is cleared here, because faces/snmp.py snapshots exactly once per PDU.

Return type:

VirtualSwitchState

restore(snapshot)[source]

Restore this state in place from a prior snapshot() result.

Copies every dataclass field from snapshot onto self rather than replacing self itself, so existing references to this exact object (e.g. VirtualSwitch.state, StateMibView._state) keep seeing the restored data.

apply_poe_admin(port, *, on)[source]

Switch a PSE port’s admin state, with the coherence a real PoE switch shows: admin off -> detect=1 (unused) and the data link drops; admin on -> detect=3 (delivering).

ONE rule shared by every protocol face – the SNMP SET path (apply_write) and the CLI poe/no poe commands both come through here, so the mock cannot behave differently depending on which backend a test drove (which would make cross-backend write parity meaningless). Unknown port: deliberate no-op, exactly as before.

apply_poe_reset(port)[source]

Re-arm PSE detection on a port (the CLI’s poe reset).

Models what the hardware does: the port is powered down and detection starts again, so it ends up DELIVERING only if a powered device is actually drawing power (power_mw), else back to SEARCHING (2) – a reset does NOT conjure a PD onto an empty port. This is what makes cycle_poe legitimately time out on an empty port, exactly as it would on real hardware, while clear_poe_fault (which only needs the port to LEAVE the fault state) succeeds.

apply_write(oid, value)[source]

Mutate this state from one SNMP SET varbind, with device coherence.

Dispatches on the OID’s column prefix. Applies the same coherence a real PoE switch shows so cycle_poe terminates against the mock: admin off -> detect=1 (unused) + data-port link down; admin on -> detect=3 (delivering). Unhandled writable OIDs are a deliberate no-op (the write “succeeds” but reads back unchanged), which is exactly what a verify-after-write must catch. (The SNMP face layer additionally rejects a SET on an OID is_writable_oid doesn’t recognize at all with a proper SNMP error, before it ever reaches here — see faces/snmp.py.)

nsdp_tlvs(tags)[source]

Project this state onto NSDP read TLVs for the requested tags.

MODEL / MAC / PORT_COUNT identity is always included (a real Plus switch echoes it, and parse_device needs the model + a port count to size VLAN bitmaps). Only tags this mock knows are emitted; unknown requested tags are silently skipped, exactly as real hardware does.

Return type:

list[TLVEntry]

apply_nsdp_write(tag, value)[source]

Mutate this state from one NSDP write TLV (verify-after-write reads it back). Unknown/read-only tags are a deliberate no-op.

is_writable_oid(oid)[source]

True if oid is one this mock recognizes as SNMP-writable.

Mirrors apply_write’s dispatch prefixes on purpose (single set of column constants from protocols.snmp.oids, kept in sync deliberately) so the SNMP face (faces/snmp.py) can reject a SET on a genuinely unknown/read-only OID with a proper SNMP error (notWritable) instead of the always-succeeding no-op apply_write itself deliberately allows for a recognized-but-absent instance (e.g. creating a not-yet-existing VLAN row).

Return type:

bool

is_oid_implemented(oid)[source]

True unless oid falls under a MIB subtree this model’s real SNMP agent never registers at all (e.g. the RFC3621 PoE MIB on a non-PoE model) – see protocols.snmp.oids.is_oid_implemented. Used by StateMibView/faces/snmp.py to answer noSuchObject for such a request instead of silently walking into an unrelated subtree.

Return type:

bool

Hand-authored VirtualSwitchState seeds, one builder per model.

seed_gsm7252ps is the original, most exhaustively-documented seed: every read op (Task 5-9) has at least one non-empty, non-vacuous example – ports with link/admin/speed, RX/TX counters on >=2 ports, >=1 VLAN with egress/ untagged bitmaps and PVIDs, PoE with a delivering port, fan/temperature/PSU sensors (including a “Not Supported” fan slot), >=2 MAC/FDB entries with their bridge-port->ifIndex mappings, >=1 LLDP neighbour, and a static management IP. This makes the Task 16 SNMP<->SNMP equivalence test (and the round-trip tests here) exercise real data on every parser, not an empty table. Port 1 also carries an ifAlias description (ports 2+ deliberately leave it unset) so the ifAlias column exercises both the present and absent-instance paths. A sysDescr containing “GSM7252PS” plus a placeholder sysObjectID (Task 2 model detection) round out the identity signals – see VirtualSwitchState.sys_descr/sys_object_id.

seed_m4300_24x/seed_m4300_16x are transcribed directly from the committed real-hardware captures (tests/fixtures/captures/m4300-*.json) rather than hand-invented, so the M4300 pair’s headline capability contrast (24X has NO PoE, 16X has PoE on all 16 ports) is grounded, not guessed – see each function’s docstring for exactly what is captured-real vs illustrative.

netgear_switch.virtual.seed.seed_gsm7252ps()[source]

Build a GSM7252PS (52-port, 48-PoE) state from the REAL capture.

TRANSCRIBED, not invented, for everything the device’s own captures show: per-port admin/link/speed/ifAlias, per-port counters, every PVID, all 14 VLANs with their exact member/untagged ifIndex sets (physical ports AND the per-VLAN lag 1..lag 64 ifIndexes, including the genuine hardware quirk that untagged is not a subset of member), all 48 PoE ports, the box sensors, the management IP (10.1.5.22), base MAC, serial and firmware – from tests/fixtures/captures/gsm7252ps.json (SNMP, host 10.1.5.22) plus that same switch’s HTTP captures (tests/fixtures/http/gsm7252ps_*.html, the source of the serial/firmware/hostname and the http_sensors set). This is a strict transcription and is guarded as one – see tests/virtual/test_state_seed.py::test_seed_gsm7252ps_matches_capture_strictly, which runs it through the same capture_parity.assert_seed_matches_capture helper the M4300 seeds use. It replaced an earlier HAND-INVENTED seed that contradicted the captures throughout (mgmt 10.1.5.20, 2 VLANs vs the real 14, one PoE port delivering vs 30).

TWO real sensor sets, one per interface (see sensors and http_sensors below): the SNMP walk returns fan RPM + PSU watts and NO temperature; the HTTP sysInfo page returns temperatures + fan/PSU HEALTH text. Both are transcribed from their respective captures, neither is forced onto the other face.

Genuinely ILLUSTRATIVE (regression traps the capture cannot express, each documented where defined): the MAC/FDB entries and their bridge-port -> ifIndex join, the single LLDP neighbour, and mgmt.mode/gateway (the real capture reports mode “unknown” and no gateway route; the mock needs a definite DHCP-mode OID to serve and to flip on write).

Non-physical interfaces are represented by two of the capture’s 65 (ifIndex 417 “CPU Interface” and 418 “lag 1”) plus the per-VLAN LAG ifIndexes in VLAN membership, so a renderer/parser that forgets that the web UI lists ONLY physical ports is caught.

Return type:

VirtualSwitchState

netgear_switch.virtual.seed.seed_gsm7228ps()[source]

Build a GSM7228PS / S3300-52X-PoE+ (52-port, 48-PoE Smart Managed Pro) state from the REAL capture.

TRANSCRIBED, not invented, from this model’s OWN first live-hardware capture (tests/fixtures/captures/gsm7228ps.json, SNMP host 10.1.5.11 = sw-netgear-s3300-1, sysObjectID 1.3.6.1.4.1.4526.100.10.19, captured 2026-07-30): every physical port’s name/admin/link/speed, all counters, every PVID, all 5 VLANs with their exact member/untagged ifIndex sets (physical ports plus the lag 1..lag 26 ifIndexes 314-339 that VLAN 1 carries), all 48 PoE ports (2 delivering, 1 fault, the rest searching), the box sensors (3 fan RPM + PSU watts + temperature, under vendor 4526.11.43), the management IP (10.1.5.11) and base MAC. Guarded as a strict transcription by test_gsm7228ps_seed.py via the same capture_parity.assert_seed_matches_capture helper the other seeds use.

This replaced an earlier HAND-INVENTED illustrative seed (mgmt 10.1.5.21, 2 VLANs, a guessed 4526.11.100.28 sysObjectID, “1/0/N” FASTPATH port names) written before any S3300 was ever powered on – the model is now verified=True with real ground truth behind it.

Genuinely ILLUSTRATIVE (regression traps the capture cannot express): mgmt.mode/gateway (the real capture reports mode “unknown” and no gateway route, but the mock needs a definite writable DHCP-mode OID, so “static” + the subnet router are structural stand-ins). MAC/FDB and LLDP rows ARE transcribed from the capture but are inherently volatile, so they are not pinned by the parity harness.

Return type:

VirtualSwitchState

netgear_switch.virtual.seed.seed_gs110emx()[source]

Build a GS110EMX (10-port Plus, NSDP+HTTP) state from the REAL capture.

Identity, mgmt-IP and per-port link/speed/description are transcribed from this model’s OWN committed captures (tests/fixtures/http/ gs110emx_{sysinfo,port_settings,interface_stats}.html, host 10.1.5.25): ports 6/8/9/10 up at 100M/1G/10G/10G with port 8 described “rumpus”, the rest down; static 10.1.5.25/24 via 10.1.5.1; MAC bc:a5:11:b8:ec:f1.

Previously these were hand-invented values (hostname “plus-sw”, 10.1.5.20, the default MAC, 1M/2M counters on idle ports, all-untagged VLAN 1, PVIDs 90) that CONTRADICTED this model’s own captures while tests pinned them as if true. Now transcribed: identity, mgmt-IP, port link/speed/description, per-port counters, VLAN 1 membership and every PVID.

STILL ILLUSTRATIVE (no capture exists, and this says so rather than implying otherwise): VLAN 90’s member/untagged sets – only VLAN 1’s membership page was captured – and the QoS/mirroring/IGMP/broadcast/ loop-detection tag values further down, which are test fixtures chosen so nsdp_device() has something non-vacuous to decode on every parsed tag.

FIRMWARE CAVEAT. This state is transcribed from captures taken while 10.1.5.25 ran firmware 1.0.1.4; that unit has since been upgraded to 1.0.2.8, which is what seed_gs110emx_fw1028 transcribes. Whether 1.0.1.4 advertised the v2 write auth was never measured – this seed carries the SKU’s measured nsdp_auth_version=0x10 so the v2 write path is exercisable, but anything asserting real GS110EMX behaviour AT A GIVEN FIRMWARE must use seed_gs110emx_fw1028.

Return type:

VirtualSwitchState

netgear_switch.virtual.seed.seed_gs110emx_fw1028()[source]

The SAME GS110EMX, as measured LIVE on 2026-07-30 at firmware 1.0.2.8.

Every value below came off the wire that day rather than out of the older committed HTML fixtures seed_gs110emx transcribes, and each is here because a claim in this library turned out to be wrong about it:

  • nsdp_auth_version=0x10 – AUTH_V2_ENCPASS (0x0014) answers 0x00000010 and this firmware accepts ONLY the v2 salted challenge-response. An NSDP WRITE_REQUEST bearing the v1 repeating-XOR PASSWORD TLV is answered error=13 (then 14 on the next try) with the header error-attribute set to 0x000A/ATTR_PASSWORD; a plaintext password fares identically. v2 writes DO work here – the token is an 8-byte XOR fold of password+salt+MAC sent in a leading 0x001A TLV, cracked and live-verified on this very unit (see protocols/nsdp/auth.py).

  • ports 9 and 10 at 10000 Mbps – their PORT_STATUS speed byte is 0x06 (09 06 01 / 0a 06 01), which the decoder used to treat as an unknown code and report as LINK DOWN.

  • port descriptions “Nicole’s Room” (6) and “TV Room” (8), read from tag 0xB000 and matching that switch’s own Port Status page exactly.

  • flow_control=True on every port, matching the page’s Flow Control column (its sibling 10.1.5.27, with the column set to Disable throughout, answers 0x00 on all ten ports).

  • the twelve VLAN ids the unit really carries, from tag 0x2800.

Return type:

VirtualSwitchState

netgear_switch.virtual.seed.seed_gs305ep()[source]

Build an ILLUSTRATIVE GS305EP (5-port, PoE ports 1-4) virtual state.

HAND-INVENTED: no capture of any kind exists for gs305ep. The port speeds, the 12800 mW PoE reading, VLAN 90 and the PVIDs are all structural test data, NOT observed values – same convention as seed_gsm7228ps, which says so explicitly. Only the shape is grounded: the Plus family genuinely has no MAC/FDB, no box sensors and no LLDP over its web UI.

Return type:

VirtualSwitchState

netgear_switch.virtual.seed.seed_gs105pe()[source]

Build a GS105PE (5-port Plus, NSDP+HTTP) virtual state from a REAL live capture (host 10.1.5.30 / poe-micro3, 2026-07-21 – see netgear-m4300-http-cheetah / the gs105pe live findings). Every value below is transcribed from the captured NsdpDevice: ports 3 (100M) and 5 (1G) up, the rest down; VLANs 1/41/90 with their real member/untagged sets; real PVIDs; DHCP mgmt-IP; and the QoS/mirroring/IGMP engine tags. Port mirroring is OFF on this unit (dest 0, no sources) – the 3-byte PORT_MIRRORING TLV that exposed the fixed-width parser bug (see parse_port_mirroring).

Return type:

VirtualSwitchState

netgear_switch.virtual.seed.seed_m4300_24x()[source]

Build a realistic M4300-24X (24-port, non-PoE) virtual switch state.

Return type:

VirtualSwitchState

netgear_switch.virtual.seed.seed_m4300_16x()[source]

Build a realistic M4300-16X (16-port, all-16 PoE) virtual switch state.

Return type:

VirtualSwitchState

netgear_switch.virtual.seed.seed_gs728tpp()[source]

Build a GS728TPP (28-port Smart Managed Pro, SNMP+HTTP) virtual state from REAL captures of the live switch 10.2.5.10 (2026-07-29 – tmp/gs728tpp_ground_truth.json). The HTTP GoAhead wcd face renders these values back through the same parse_goahead_* parsers the real captures exercise. Ports g1-g28 (7 up: g2/g5/g12/g23/g24/g26/g28), the real 12 VLANs with their member/untagged sets, real PVIDs, 24 PoE+ ports (all Searching, 0 mW on this idle unit), a subset of the real dynamic FDB, 4 LLDP neighbours, the box DiagnosticsUnitList sensors (fan1/2 OK, fan3-5 absent, both PSU rows OK, temp unreported) and the static mgmt-IP.

SNMP is now grounded in a real live walk (10.2.5.10, 2026-07-29 – tmp/gs728tpp_snmp_full.json): this agent implements ZERO Netgear vendor OIDs and serves everything via standard MIBs (registry snmp_vendor_base=None). So state.sensors (the vendor SNMP box-sensor set) stays empty; instead the fan/PSU sensor INVENTORY is exposed via the standard ENTITY-MIB entity_components (Main/Redundant PowerSupply + Fan1/Fan2, the real entPhysicalIndex/Class/Name/Descr rows from the capture) – inventory ONLY, no live value over SNMP. The HTTP sysInfo sensors (with live health status) live in http_sensors; that status is the real HTTP-only difference. PoE per-port mW is likewise a vendor column this agent lacks, so SNMP get_poe reports power_mw=None (vs HTTP’s live 0).

Return type:

VirtualSwitchState

Protocol faces

Protocol faces onto a VirtualSwitchState (e.g. an SNMP agent, Task 15).

A real pysnmp v2c command-responder agent serving a StateMibView.

This wires the pure StateMibView (Task 14) into an actual pysnmp v7 agent bound to an ephemeral UDP port on 127.0.0.1, so both transport clients (NetsnmpCliClient and PysnmpClient) can be exercised end-to-end against a mock switch.

pysnmp is imported lazily (only when VirtualSnmpFace.start() runs, inside the background thread), so this module — and the rest of the virtual package — stays importable without the [testing]/[async] extra installed. pysnmp ships no type stubs; every reference is resolved through importlib.import_module (returning Any), the same single-seam pattern used by transport/aio/snmp_pysnmp.py, so mypy –strict needs no ignore_missing_imports override for pysnmp at all.

Adaptations from the Task 15 brief’s sample code (the brief explicitly warned its snippets might be stale — they were):

  • The brief’s _StateInstrum sketch used read_vars/read_next_vars. The actual installed pysnmp v7 MIB-instrumentation-controller callback names are read_variables/read_next_variables (confirmed by reading pysnmp.smi.instrum.AbstractMibInstrumController and how pysnmp.entity.rfc3413.cmdrsp.{Get,Next,Bulk}CommandResponder invoke them: self.snmpContext.get_mib_instrum(contextName).read_variables. readVars/readNextVars exist only as deprecated old-camelCase aliases for those, never as read_vars/read_next_vars).

  • Rather than raising NoSuchInstanceError/EndOfMibViewError (which GetCommandResponder/NextCommandResponder would turn into a whole-PDU genErr — not spec-conformant SNMPv2c behaviour, and not what PysnmpClient/net-snmp expect), this controller embeds the real pysnmp.proto.rfc1905 exception values (noSuchInstance / endOfMibView) directly into the response var-bind, exactly as a real SNMPv2c agent does and exactly what both transport clients already treat as an absent-OID / walk-terminator marker.

  • The custom controller is a plain class (no pysnmp base class to inherit from without a static pysnmp import, which would defeat the lazy-import seam) — it only needs to duck-type read_variables/read_next_variables, which is all cmdrsp ever calls on it.

  • The engine/transport/VACM setup follows pysnmp.entity.config’s add_transport/add_v1_system/add_vacm_user (the real, current function names — no addTransport/addV1System camelCase, those are deprecated aliases too).

Task 17 (write path) additions, verified the same way against the installed pysnmp v7 rather than trusted from a brief:

  • SetCommandResponder.handle_management_operation (read via inspect.getsource) calls self.snmpContext.get_mib_instrum(contextName).write_variables — so the controller callback is named write_variables (matching the read_variables/read_next_variables naming above), not write_vars.

  • That same source shows CommandResponderBase.process_pdu catches any pysnmp.smi.error.SmiError raised out of handle_management_operation and maps its exact class through SMI_ERROR_MAP to an SNMP error-status (WrongValueError -> wrongValue, NotWritableError -> notWritable) — confirming both errors below travel cleanly to the client instead of the whole-PDU genErr/timeout a bare exception would cause.

  • That handler then does errorIndex = errorIndication["idx"] + 1. Passing idx=None (as sketched in the brief) would make this None + 1 -> an unhandled TypeError inside pysnmp’s own error path — a worse failure than the one being guarded against. write_variables below passes the real 0-based position of the failing var-bind instead.

  • add_vacm_user already existed for reads; granting SET access is just passing writeSubTree=(1, 3, 6, 1) alongside the existing readSubTree — same function, no new API.

class netgear_switch.virtual.faces.snmp.VirtualSnmpFace(view, *, community='public', host='127.0.0.1', port=0)[source]

Bases: object

A pysnmp v2c command-responder agent serving a StateMibView.

Runs the pysnmp asyncio dispatcher on a dedicated background thread with its own event loop, bound to an ephemeral UDP port on host.

start()[source]

Bind the UDP socket, start the agent thread, and return the port.

Return type:

int

stop()[source]

Close the dispatcher, join the background thread, and close the agent’s UDP socket deterministically.

pysnmp’s AsyncioDispatcher.close_dispatcher closes the asyncio transport by scheduling its real close (loop.call_soon(...)) for the next loop iteration, then immediately calls loop.stop() in that same callback — which breaks run_forever() before that next iteration ever runs. _run already compensates for this (it pumps the loop once more after run_forever() returns, so pysnmp’s own deferred close normally does run before the loop is closed). Closing self._sock here too, after the thread has fully stopped, is a deliberate belt-and-braces backstop: it guarantees the fd is closed deterministically even if that compensation ever fails to run (e.g. a future pysnmp change altering the callback ordering), rather than depending on GC to eventually close it and emit a ResourceWarning.

Pure OID responder over VirtualSwitchState.oid_map().

No pysnmp, no network: a sorted (oid_tuple, snmp_type, value) list answering exact-match GET and lexicographic GETNEXT with bisect. Task 15 wires this into the real pysnmp command responder.

class netgear_switch.virtual.faces.mibview.StateMibView(state)[source]

Bases: object

Sorted view of a switch’s OID map supporting GET and GETNEXT.

get(oid)[source]
Return type:

tuple[tuple[int, …], str, str] | None

get_next(oid)[source]
Return type:

tuple[tuple[int, …], str, str] | None

rebuild()[source]

Recompute the sorted view from current state (call after a write).

apply_write(oid, value)[source]

Mutate the underlying state then rebuild so reads reflect the write.

apply_write_uncommitted(oid, value)[source]

Mutate the underlying state WITHOUT rebuilding the sorted view.

For an atomic multi-varbind SET (faces/snmp.py’s write_variables): the (relatively expensive) rebuild() is deferred until the whole PDU has committed successfully, once, rather than once per varbind. Callers MUST call rebuild() themselves once every varbind in the PDU has applied without error.

snapshot_state()[source]

Snapshot the underlying state, for atomic multi-varbind SET rollback.

See VirtualSwitchState.snapshot/restore_state.

Return type:

VirtualSwitchState

restore_state(snapshot)[source]

Restore the underlying state in place from a prior snapshot_state() result, discarding any writes applied since.

The sorted view itself needs no rebuild after a restore: if the caller took the snapshot before making any changes and only ever reaches this on a failed atomic SET, the state (and thus the view) is back to exactly what it was before that SET began.

is_writable_oid(oid)[source]

Passthrough to VirtualSwitchState.is_writable_oid (see there).

Return type:

bool

is_implemented(oid)[source]

False if oid falls under a subtree root this model’s SNMP agent has no registration for at all (e.g. the RFC3621 PoE MIB on a non-PoE model) – see VirtualSwitchState.is_oid_implemented.

faces/snmp.py checks this BEFORE calling get/get_next: a real agent answers noSuchObject for such a request rather than this view’s flat bisect silently finding whatever unrelated OID happens to sort next.

Return type:

bool

A real UDP NSDP responder serving a VirtualSwitchState.

Mirrors the pysnmp face pattern (Task 15’s VirtualSnmpFace) but for the far simpler NSDP wire protocol: a single background thread with one UDP socket bound to an ephemeral port on loopback (so no root, no privileged 63321/63322 bind, no SO_BINDTODEVICE). It answers READ_REQUEST from state.nsdp_tlvs and applies WRITE_REQUEST after validating auth.

Two write-auth schemes are modelled, selected by state.nsdp_auth_version (advertised to clients via the AUTH_V2_ENCPASS read):

  • v1 — validate the XOR PASSWORD (0x000A) TLV; a mismatch returns error byte 7 (result 0x0700).

  • v2 — validate the 8-byte AUTH_V2_PASSWORD (0x001A) token against auth_v2_password(password, mac, last-issued salt). This reproduces the GS110EMX (fw 1.0.2.8) behaviour LIVE-VERIFIED here: a WRITE that LEADS with the 0x001A token (then the config TLVs) and carries the right token applies the change (error 0); a wrong token returns error 13 and, after a few rapid failures, escalates to error 14 and then goes SILENT (no reply) for a cooldown; a READ naming write-only 0x001A returns error 3. A client that offers the v1 PASSWORD TLV to a v2 state has no 0x001A token at all, so it lands in the same error-13 branch blaming 0x000A/ATTR_PASSWORD – which is exactly how the real firmware refuses v1 auth (and what check_result keys its “use the v2 scheme” guidance off).

stop() closes the socket deterministically so no ResourceWarning is emitted under -W error::ResourceWarning.

class netgear_switch.virtual.faces.nsdp.VirtualNsdpFace(state, *, host='127.0.0.1', port=0)[source]

Bases: object

A UDP NSDP command responder serving a VirtualSwitchState.

start()[source]
Return type:

int

stop()[source]

Stop the serve thread and close the socket deterministically.

A real http.server web-UI face serving a VirtualSwitchState.

Binds a ThreadingHTTPServer to an ephemeral TCP port on 127.0.0.1 and serves the login CGI + read/write CGI pages from device state via virtual.web. Both httpx transport clients (sync + async) are exercised end-to-end against it with no hardware.

A real switch never fabricates a 200 for a capability it doesn’t have, so this face 404s any request whose path is not one of this model’s populated HttpModelSpec fields, before ever calling into virtual.web — that module’s render_page has a deliberately permissive catch-all (see its docstring) that is only safe to reach for a path this spec actually advertises.

Teardown is deterministic: stop() calls shutdown() (unblocks serve_forever), joins the server thread, then server_close() closes the listening socket — so nothing leaks under -W error::ResourceWarning.

class netgear_switch.virtual.faces.http.VirtualHttpFace(
state,
spec,
*,
host='127.0.0.1',
password='password',
rand='1234',
port=0,
)[source]

Bases: object

A ThreadingHTTPServer web-UI face serving a VirtualSwitchState.

start()[source]
Return type:

int

stop()[source]

Stop the serve thread and close the listening socket deterministically.

In-process mock FASTPATH CLI face (implements CliSession).

Unlike the HTTP face (which binds a real ThreadingHTTPServer so httpx clients hit a socket), the CLI face is an IN-PROCESS transport: it implements the same CliSession seam CliReader/CliWriter depend on and dispatches each command string straight to the cli_fastpath renderer (reads) or to a state mutation (writes) – no SSH server, no socket, no host keys. This is deliberate and honest: live SSH cannot be exercised from CI (no network) and the real byte transports are documented as transport-only, so the mock proves the command-dispatch + parser round trip (the part that CAN be tested) rather than standing up a paramiko server whose value would be untestable here anyway.

A VirtualSwitch exposes one via cli_session(); the session setup commands (enable / terminal length 0) are accepted as no-op success, matching a real shell.

CONFIGURATION commands (the vlan database / configure trees that cli_write.CliWriter drives) mutate VirtualSwitchState itself, so the change is immediately visible through EVERY face of the same virtual switch – this CLI face’s own show output, the SNMP oid_map() projection, the NSDP TLVs and the web pages – exactly as a write on real hardware is visible over every protocol.

Two behaviours are modelled on purpose because the library’s correctness depends on them:

  • An accepted configuration command returns EMPTY output; anything the switch would reject returns text. (The empty/non-empty CONTRACT is live-proven on an M4300-24X; the exact wording of the rejection strings below is NOT a transcription of any capture, and nothing in the library parses them.)

  • vlan participation / vlan tagging / vlan pvid are accepted but completely INERT while the port is in switchport mode access – the live finding (see cli_write.CliWriter) that makes switchport mode general a mandatory step of every per-port CLI VLAN write. A mock that silently applied them in access mode would hide exactly the bug that finding exists to prevent.

class netgear_switch.virtual.faces.cli.VirtualCliFace(state, spec)[source]

Bases: object

An in-process CLI session serving a VirtualSwitchState.

run_scp_copy(command, scp_password)[source]

In-process stand-in for the interactive copy scp://... step.

The real ShellDriver.run_scp_copy drives a byte-level prompt handshake (TOFU/password/(y/n)) – exercised end-to-end by the byte-level fake-shell test. This in-process face has no byte stream, so it records the copy (source URL + nvram: destination) into ScpCertDeploy and reports success, letting a facade-level test assert the deploy driver issued the right commands + destinations against a seeded VirtualSwitch.

Return type:

str

run_write_memory(command='write memory', *, prestuff)[source]

In-process stand-in for a command with a (y/n) confirm.

Two commands use this transport path: write memory (save config) and reload (reboot). They are NOT interchangeable, so the mock keeps them apart – a reload must never look like a config save. A real reload also tears the session down; the mock cannot restart itself, so it records the request (state.reboots) and returns, which is what lets a test prove the right command was issued.

Return type:

str

run(command)[source]
Return type:

str

close()[source]

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.

netgear_switch.virtual.cli_fastpath.port_for_iface(state, iface)[source]

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.

Return type:

int | None

netgear_switch.virtual.cli_fastpath.render_version(state)[source]
Return type:

str

netgear_switch.virtual.cli_fastpath.render_network(state)[source]
Return type:

str

netgear_switch.virtual.cli_fastpath.render_hosts(state)[source]

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 type:

str

netgear_switch.virtual.cli_fastpath.render_ports(state)[source]
Return type:

str

netgear_switch.virtual.cli_fastpath.render_vlan_brief(state)[source]
Return type:

str

netgear_switch.virtual.cli_fastpath.render_vlan_detail(state, vid)[source]
Return type:

str

netgear_switch.virtual.cli_fastpath.render_pvids(state)[source]
Return type:

str

netgear_switch.virtual.cli_fastpath.render_mac_table(state)[source]
Return type:

str

netgear_switch.virtual.cli_fastpath.render_lldp(state)[source]
Return type:

str

netgear_switch.virtual.cli_fastpath.render_poe(state)[source]
Return type:

str

netgear_switch.virtual.cli_fastpath.render_environment(state)[source]
Return type:

str

netgear_switch.virtual.cli_fastpath.render_interface_counters(state, port)[source]
Return type:

str

netgear_switch.virtual.cli_fastpath.render_port_description(state, iface)[source]

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.

Return type:

str

netgear_switch.virtual.cli_fastpath.render_logging(state)[source]

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.

Return type:

str

netgear_switch.virtual.cli_fastpath.render_logging_hosts(state)[source]

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”.

Return type:

str

Web-UI renderers

One renderer per HTML dialect, producing each model’s real pages from state.

Pure web-UI projection of VirtualSwitchState (render + apply).

The exact inverse of the Task-3 parsers and Task-4 form encoders: render_page turns device state into the documented HTML shape the parsers consume, and apply_form mutates the state from a POSTed form body. No network here — the VirtualHttpFace (Task 11) wraps these in an http.server handler.

The rendered HTML is deliberately minimal but carries every field the parsers read, including a constant CSRF hash token on each writable page. Routing (deciding whether a requested path is one a given model’s http_spec actually advertises, and returning a 404-equivalent for anything else) is Task 11’s job at the I/O boundary; this module renders/applies whatever page its caller already resolved against spec.

netgear_switch.virtual.web.render_login(rand)[source]
Return type:

str

netgear_switch.virtual.web.render_page(state, spec, path, form)[source]
Return type:

str

netgear_switch.virtual.web.apply_form(state, spec, path, form)[source]

The FASTPATH “XUI” write-form scaffolding shared by every managed model.

Every managed page (portsConfiguration.html, poeInterfaceConfiguration.html, ipConfiguration.html, mgmtVlanIpv4Configuration.html) is wrapped in the SAME structure on real firmware, and the mock reproduces it exactly because each piece is load-bearing for the writer:

  • TWO <FORM>``s. The first, ``<page>.html/a0, is the applet/redirect form and carries no data; the SECOND, <page>.html/a1, is the read+write form. A parser that grabbed the first form would find nothing.

  • Repeating rows are <TR p="<unit>.<row0>.<count>0"> and their fields are named <unit>.<row0>.<count>.v_1_2_<column> – the row index is 0-based and the count is the RENDERED row count, not the port count (a 52-port switch’s PoE page has 48 rows). Each row also carries its own gecb* checkbox, and the firmware applies ONLY the rows whose checkbox is submitted.

  • A trailing “redirection elements” block – submit_flag/submit_target/ err_flag/err_msg/clazz_information – and a xuiButtonsDiv holding the page’s buttons as DISABLED hidden inputs.

  • An apply is submit_flag=8 (the firmware’s own xui_operation_submit = 8, from /scripts/_xeobj_jsvars.js); a refusal comes back as HTTP 200 with err_flag=1 and a human err_msg.

All of it live-captured 2026-07-30 from gsm7252ps 10.1.5.22, gsm7228ps 10.1.5.11, m4300-24x 10.1.5.13 and m4300-16x 10.1.5.20:49152.

netgear_switch.virtual.web_fastpath_xui.instance(row0, count, unit=1)[source]

The row field-name prefix, WITHOUT its trailing dot.

Return type:

str

netgear_switch.virtual.web_fastpath_xui.row(inst, cells, *, checkbox='gecb5')[source]

Wrap cells in the real <TR p="..."> row, with its own checkbox.

checkbox differs per firmware on real hardware (gecb5 on gsm7252ps’s ports page, gecb10 on gsm7228ps’s, gecb_1_2 on the M4300s), so the caller passes the one that model renders – the writer scrapes it rather than constructing it, and a mock that always used one spelling would hide a scrape that had hard-coded another.

Return type:

str

netgear_switch.virtual.web_fastpath_xui.nav_rows(unit='1', *, type_filter='^Physical$')[source]

The page’s two class=deftestme list-navigation rows.

Return type:

str

netgear_switch.virtual.web_fastpath_xui.has_list_unit(form)[source]

Whether this POST carries one of the page’s urlListUnit aliases.

Return type:

bool

netgear_switch.virtual.web_fastpath_xui.page(path, body, *, buttons, err_msg='', title='NETGEAR')[source]

One complete XUI page: both forms, the body, the redirection block and the buttons. err_msg non-empty renders the refusal the way the firmware does – HTTP 200 with err_flag=1.

Return type:

str

netgear_switch.virtual.web_fastpath_xui.checked_rows(form, checkbox)[source]

The row prefixes whose gecb checkbox the submitted form carries.

This is the whole selection rule on real hardware: fields for an unchecked row are ignored even when present. Reproducing it is what makes the mock able to FAIL a writer that forgot the checkbox – which is exactly how a write silently does nothing on the real switch.

Return type:

list[str]

netgear_switch.virtual.web_fastpath_xui.is_apply(form)[source]

Whether this POST is an APPLY (submit_flag=8) rather than a reload.

Return type:

bool

netgear_switch.virtual.web_fastpath_xui.pressed(form, candidates)[source]

Which of candidates (button field names) this POST carries.

Return type:

str | None

netgear_switch.virtual.web_fastpath_xui.render_mgmt_ip(state, spec, *, err_msg='')[source]

The model’s management-IP page, rendered from state.mgmt.

Return type:

str

netgear_switch.virtual.web_fastpath_xui.render_service_xui(state, path, service)[source]

One service’s config page in the XUI labelled-scalar shape.

Return type:

str

netgear_switch.virtual.web_fastpath_xui.render_service_form(state, path, service)[source]

One service’s config page in the PLAIN NAMED FORM shape.

Reproduces the firmware’s double-checked radio group verbatim: BOTH radios carry a checked attribute, spelled checked="checked" on the first and a bare uppercase CHECKED on the second, and a browser takes the LAST. A mock that marked only the true one would let a first-match parser pass here and then misreport every real switch.

Return type:

str

netgear_switch.virtual.web_fastpath_xui.render_users(state, path)[source]

userManagement.html, rendered from state.users.

Return type:

str

netgear_switch.virtual.web_fastpath_xui.render_syslog(state, path, *, err_msg='')[source]

syslogConfiguration.html, rendered from state.syslog.

Counters (Messages Received/Relayed/Ignored) are rendered as the live pages do but are NOT part of SyslogConfig; they exist so the page the mock serves has the same field set as the real one.

Return type:

str

netgear_switch.virtual.web_fastpath_xui.apply_mgmt_ip(state, spec, form)[source]

Apply a management-IP form, returning the firmware’s err_msg (”” = ok).

Reproduces the real page’s validator rather than accepting anything: the firmware answers a malformed address with HTTP 200 + err_flag=1 + “Error: Unable to set ‘<name>’ with ‘<value>’. IP address should be in x.x.x.x form …” (the page publishes that exact string as xeValData.xv_1_1_1_635).

Return type:

str

netgear_switch.virtual.web_fastpath_xui.apply_port_admin(state, form, *, checkbox, ports, count, admin_column='v_1_2_6')[source]

Apply portsConfiguration.html’s Admin Mode column, honouring the per-row checkboxes. Returns the firmware err_msg (”” = accepted).

Return type:

str

netgear_switch.virtual.web_fastpath_xui.apply_syslog_rows(state, form)[source]

Apply a syslog-page row ADD or DELETE, returning err_msg (”” = ok).

Reproduces what the live M4300 page does, both halves:

  • ADD – the v_g_2_1_* template row with its write-only row-status (v_g_2_1_5) set to “Active”. A new row takes the next FREE index and leaves existing rows where they are, which is what makes the table sparse.

  • DELETE – a data row whose own v_2_1_5 is set to “Delete”.

A template row submitted WITHOUT the row-status is ignored, exactly as the firmware ignores a blank global row that was never activated – that is the case a writer which forgot to set it would otherwise appear to pass.

Return type:

str

The managed FASTPATH “VLAN Membership” page, rendered from + applied to state.

Reproduces switching/dot1q/vlan_port_cfg.html (GET) and its ..._rw.html form target (POST, used for BOTH a VLAN-select re-render and an apply) as the real firmware serves them. Grounded in live captures taken 2026-07-30 from all four managed switches – gsm7252ps 10.1.5.22, gsm7228ps (S3300-52X) 10.1.5.11, m4300-24x 10.1.5.13 and m4300-16x 10.1.5.20:49152 – which are checked in as tests/fixtures/http/*vlan[Pp]ort[Cc]fg*.html.

The behaviours below are reproduced BECAUSE hardware does them, and each one is something a lenient mock would have hidden:

  • Two different views of the same VLAN. hiddenTagged/hiddenUnTagged are the CURRENT (operational) egress lists; hiddenMem and the port grid are the CONFIGURED participation. They genuinely differ – see state.VlanSim.configured_only, seeded from the real GSM7252PS.

  • ``submt`` is the apply flag. submt=0 (what the VLAN <select>’s screen_refresh() posts) re-renders WITHOUT applying; only submt=16 (0x10, what submitform() sets) writes. A mock that applied on every POST would let a broken reader silently corrupt VLANs and still pass.

  • The page shows whichever VLAN ``vlanId`` selected, and an unknown VLAN falls back to the lowest – so a reader that forgets to check which VLAN came back gets caught here instead of on hardware.

  • Two grid encodings, two index bases. Older firmware (gsm7252ps) emits toggleImageFirst(this,<0-based slot>,...) + grey_[btu].gif; newer (S3300/M4300) emits togImg(this,<1-based slot>,...) + switch_*.png. Both are rendered, per model, from the seeded VlanMembershipPageSim.grid.

  • LAG pseudo-interfaces occupy hiddenMem slots after the physical ports (64 on the gsm7252ps, 26 on the S3300, 128 on the M4300s) and are rendered in their own grid table. A writer that assumed slot == port - 1 and truncated the string would drop them; here it cannot.

netgear_switch.virtual.web_fastpath_vlan.refusal(state, form)[source]

The err_msg this apply would be refused with, or None if allowed.

Reproduces the M4300 firmware’s precondition: a port whose switchport mode is access or trunk cannot be given explicit VLAN membership, and the web UI reports that as err_flag=1 + err_msg on an otherwise-200 page rather than an HTTP error. Quoted verbatim from 10.1.5.13 – see VirtualSwitchState.vlan_membership_locked_ports.

Return type:

str | None

netgear_switch.virtual.web_fastpath_vlan.render_membership(state, spec, form, *, err_msg='')[source]

Render the VLAN Membership page for the VLAN form selected.

Return type:

str

netgear_switch.virtual.web_fastpath_vlan.apply_membership(state, form)[source]

Apply a membership POST – but ONLY when submt is the apply flag.

submt=0 is the VLAN <select>’s own re-render POST and must not mutate anything: the reader relies on that to page through VLANs, and a mock that wrote on every POST would let a reader silently rewrite every VLAN it read. Confirmed on hardware, by reading a VLAN twice and diffing the two responses (byte-identical) on all four switches.

A port set Excluded loses its CURRENT membership too; a port set tagged/untagged becomes a current member. configured_only is cleared for a port the caller explicitly set, because the caller has now stated that port’s participation outright.

M4300 “Cheetah /v1” page renderers driven by VirtualSwitchState.

Reproduces the real page encoding rather than a convenient one: every value is a hidden input whose NAME carries the ROW INSTANCE, followed by an HTML comment naming the field –

<TD id=1_2_10><INPUT xid=1_2_10 TYPE=hidden NAME=1.0.24.v_1_2_10

VALUE=”Link Up”>Link Up</TD><!– baseport_LinkStatus2 –>

which is exactly what parse.parse_cheetah_rows reads back, so the mock exercises the same field-name-addressed parsing path real hardware does. Interface names are HTML-escaped (1&#x2F;0&#x2F;1) because the real firmware escapes them – that escaping once collapsed every parsed port number to 1, so reproducing it keeps the regression visible in CI.

netgear_switch.virtual.web_m4300.render_ports(state, *, err_msg='')[source]

/v1/portsConfiguration.html – per-port admin/link/speed.

COLUMN COORDINATES CORRECTED against the real page (10.1.5.13, 2026-07-30): Admin Mode is v_1_2_6 and ifIndex is v_1_2_13 (a display:none column), and the row prefix is 1.<0-based row>.<row count>. The mock used to emit v_1_2_3/v_1_2_2 and 1.<port>.24, which the comment-keyed read parsers tolerated but which does not exist on any switch – so a writer addressing the real Admin Mode column would have found nothing here while working on hardware.

Return type:

str

netgear_switch.virtual.web_m4300.apply_ports(state, form)[source]

Apply a /v1/portsConfiguration POST; returns the firmware err_msg.

Return type:

str

netgear_switch.virtual.web_m4300.render_poe(state, *, err_msg='')[source]

/v1/poeInterfaceConfiguration.html (M4300-16X only; the 24X has no PoE and its spec leaves the path None).

The cell grid is byte-identical to the gsm7252ps XE page, so that renderer is reused – but with THREE M4300-specific differences, each live-measured: the power column is decimal watts, the row checkbox is gecb_1_2, and the reset button reads Power Cycle Port(s) rather than RESET.

Return type:

str

netgear_switch.virtual.web_m4300.apply_poe(state, form)[source]
Return type:

str

netgear_switch.virtual.web_m4300.render_port_statistics(state)[source]

/v1/portStatistics.html – FRAME counters (this UI has no octets).

The virtual state stores octet counters, so the frame columns are seeded from the packet counters when present and 0 otherwise – never from the octet values, which would imply this page reports bytes when it does not.

Return type:

str

netgear_switch.virtual.web_m4300.render_pvids(state)[source]

/v1/portPvidConfiguration.html – per-port PVID.

Return type:

str

netgear_switch.virtual.web_m4300.render_vlans(state)[source]

/v1/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.

Return type:

str

netgear_switch.virtual.web_m4300.render_mac_table(state)[source]

/v1/basicAddressTable.html – the learned MAC/FDB table.

Return type:

str

netgear_switch.virtual.web_m4300.render_sysinfo(state)[source]

/v1/base/system/management/sysInfo.html – mgmt IP, base MAC and the temperature block. Plain labelled cells (this page has no xid cells).

Return type:

str

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.

netgear_switch.virtual.web_gsm7252ps.render_ports(
state,
*,
err_msg='',
iface=_iface,
checkbox=_PORTS_CHECKBOX,
path='/portsConfiguration.html',
)[source]

/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.

Return type:

str

netgear_switch.virtual.web_gsm7252ps.apply_ports(state, form, *, checkbox=_PORTS_CHECKBOX)[source]

Apply a portsConfiguration POST; returns the firmware err_msg.

Return type:

str

netgear_switch.virtual.web_gsm7252ps.render_port_statistics(state)[source]

/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.

Return type:

str

netgear_switch.virtual.web_gsm7252ps.render_pvids(state)[source]

/portPvidConfiguration.html – Configured + Current PVID columns.

Return type:

str

netgear_switch.virtual.web_gsm7252ps.render_vlans(state)[source]

/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.

Return type:

str

netgear_switch.virtual.web_gsm7252ps.render_mac_table(state)[source]

/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.

Return type:

str

netgear_switch.virtual.web_gsm7252ps.render_poe(
state,
*,
watts=False,
err_msg='',
iface=_iface,
checkbox=_POE_CHECKBOX,
reset_label='RESET',
path='/poeInterfaceConfiguration.html',
)[source]

/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.

Return type:

str

netgear_switch.virtual.web_gsm7252ps.apply_poe(state, form, *, checkbox=_POE_CHECKBOX, unit_required=True)[source]

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.

Return type:

str

netgear_switch.virtual.web_gsm7252ps.render_lldp(state)[source]

/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.

Return type:

str

netgear_switch.virtual.web_gsm7252ps.render_sysinfo(state)[source]

/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.

Return type:

str

S3300-52X-PoE+ (gsm7228ps) “XE FASTPATH” page renderers from state.

The S3300 Smart-Managed-Pro web UI shares the sibling gsm7252ps Cheetah XE cell grid for ports/stats/PVIDs/VLANs/PoE/LLDP, so those pages are rendered by the exact gsm7252ps renderers (see web_gsm7252ps) – the reader keys off the ifindex/port columns, which are identical. Only three pages differ and are rendered here, matching the real captures in tests/fixtures/http/gsm7228ps_*.html and the S3300-specific parsers:

  • basicAddressTable.html – the MAC/FDB columns are SHIFTED (VLAN in v_1_2_2, not v_1_2_1) and the port ifName is HTML-entity-escaped in the Smart firmware’s 1/gN/1/xgN form (&#x2F; = /); the switch’s own base MAC is learned on the CPU interface, rendered c1 / status “Management”, which parse_s3300_macs skips as non-physical (SNMP reports that same base MAC on the CPU ifIndex).

  • sysInfo.html – exposes only the Base MAC Address (no IPv4 mgmt address on the statically-reachable page), which is all parse_s3300_mgmt reads.

Sensors are NOT served as a live table: the S3300 sysInfo has no fan/temp readings, so get_sensors over HTTP is unsupported (SNMP only) – see HtmlDialect.S3300 and http_read._supports_sensors.

netgear_switch.virtual.web_gsm7228ps.render_ports(state, *, err_msg='')[source]

/portsConfiguration.html in the Smart firmware’s spelling.

Same XE grid as gsm7252ps, but the Port cell is 1/g12/1/xg49, not 1/0/12 – live-confirmed on 10.1.5.11. It used to be aliased straight to the gsm7252ps renderer, which made the mock print 1/0/N here and hid the fact that a writer locating its row by ifName has to handle BOTH spellings.

Return type:

str

netgear_switch.virtual.web_gsm7228ps.apply_ports(state, form)[source]
Return type:

str

netgear_switch.virtual.web_gsm7228ps.render_poe(state, *, err_msg='')[source]

/poeInterfaceConfiguration.html in the Smart firmware’s spelling.

Return type:

str

netgear_switch.virtual.web_gsm7228ps.apply_poe(state, form)[source]
Return type:

str

netgear_switch.virtual.web_gsm7228ps.render_vlans(state)[source]

/vlanStatus.html – VLANs with their egress port list (S3300 ifNames).

The egress cell uses the Smart firmware’s 1/gN/1/xgN names (which parse_s3300_vlans reads, unlike the 1/0/N-only XE expander), with LAG ifIndexes rendered lag N – not expanded into physical ports.

Return type:

str

netgear_switch.virtual.web_gsm7228ps.render_mac_table(state)[source]

/basicAddressTable.html – the learned MAC/FDB table (S3300 columns).

VLAN in v_1_2_2, MAC in v_1_2_3, escaped port ifName in v_1_2_4, status in v_1_2_5 – the shifted layout parse_s3300_macs reads. The “Total MAC Addresses” scalar (v_1_1_1) carries the true row count so the reader’s anti-truncation guard sees a legitimately complete page.

Return type:

str

netgear_switch.virtual.web_gsm7228ps.render_sysinfo(state)[source]

/base/system/management/sysInfo.html – Base MAC Address only.

The S3300 Smart UI’s statically-reachable sysInfo exposes the switch’s base MAC (labelled cell, aid="1_16_1_right") but NOT the IPv4 management address (that page is behind a JS-only menu), and carries no live fan/temp sensor table. parse_s3300_mgmt reads back the base MAC; get_sensors is unsupported over HTTP for this model.

Return type:

str

Byte-faithful GS110EMX web-UI page templates (Gambit token session).

The literal HTML in web_gs110emx_templates.py is the REAL captured content from a physical GS110EMX (tests/fixtures/http/gs110emx_*.html) with only the dynamic values swapped for marker placeholders – everything else (JS boilerplate, attribute names, and critically the malformed never-closed <tr class="portID"> rows on interface_stats.html – see protocols/http/parse.py’s _OPEN_ROW_RE) is copied byte-for-byte from the capture, so the mock is byte-equivalent to real hardware whenever seeded with the same values. Substitution is plain str.replace (not .format()/f-strings) because the captured pages’ inline JavaScript is full of literal {/} characters that would need escaping.

One known, deliberate byte-level deviation: the real capture’s LAST portID row (port 10) is followed by 2 fewer whitespace characters before </table> than every other row – a capture idiosyncrasy of the real device, not a parsing-relevant difference. render_interface_stats renders every row (including the last) with the same trailing whitespace for simplicity, so a byte-diff against the original 10-port capture differs by exactly those 2 bytes.

netgear_switch.virtual.web_gs110emx.render_login(rand)[source]

GET / login page – byte-identical to gs110emx_login.html but for the rand nonce.

Return type:

str

netgear_switch.virtual.web_gs110emx.render_redirect(token)[source]

POST /redirect.html login response – byte-identical to gs110emx_redirect.html but for the Gambit token. token="" (a rejected login) renders a Gambit field with an empty value, which parse.parse_gambit_token reads back as falsy.

Return type:

str

netgear_switch.virtual.web_gs110emx.render_sysinfo(state, token)[source]

GET /iss/specific/sysInfo.html?Gambit=<token> – byte-identical to gs110emx_sysinfo.html but for the state-driven device identity / mgmt-IP fields. dhcp_select mirrors the captured page’s data-select-value attribute (0=static/Disable, 1=DHCP/Enable) – see protocols/http/parse.py’s parse_sysinfo/HttpSysInfo for the read-side grounding of this convention.

Return type:

str

netgear_switch.virtual.web_gs110emx.render_port_settings(state, token)[source]

GET port_settings.html – port link/speed/description from state, so the HTTP port-status read matches the NSDP PORT_STATUS read on this switch.

Return type:

str

netgear_switch.virtual.web_gs110emx.apply_port_settings(state, form)[source]

Apply a port_settings.html POST; returns the page’s reply BODY.

The real page answers an AJAX apply with a bare SUCCESS (its own JS checks resText != "SUCCESS"), not a re-rendered page. PORT_NO is the SEMICOLON-TERMINATED selected-port list its saveSelectedPorts() builds ("3;"), and a body that sends a bare number selects nothing and applies nothing – reproduced here, because that is exactly the mistake that a lenient mock would have hidden (it was caught on real hardware instead).

Return type:

str

netgear_switch.virtual.web_gs110emx.render_pvid(state, token)[source]

GET vlan_pvidsetting.html – per-port PVID from state.

Return type:

str

netgear_switch.virtual.web_gs110emx.render_cf8021q(state, token)[source]

GET Cf8021q.html – the VLAN list (with member ports) from state. The reader only scrapes the VID column (parse_gs110emx_vlan_ids); the member list is rendered for fidelity.

Return type:

str

netgear_switch.virtual.web_gs110emx.render_vlan_membership(state, token, selected_vid)[source]

POST vlanMembership.html (VLAN_ID=<selected_vid>) – the per-port hiddenMem wire codes (1=untagged, 2=tagged, 3=excluded) for the selected VLAN, plus the full VLAN <option> list. The wire codes are the SAME scheme gs305ep’s 8021qMembe.cgi uses, so parse.parse_membership reads it back and the resulting VLANInfo matches the NSDP VLAN_MEMBERS read.

Return type:

str

netgear_switch.virtual.web_gs110emx.render_interface_stats(state, token)[source]

GET /iss/specific/interface_stats.html?Gambit=<token> – byte-identical to gs110emx_interface_stats.html but for the per-port counters. The real device NEVER closes a <tr class="portID"> with </tr> (rows run on until the next <tr> or the table close); this reproduces that exact malformed-but-real shape row-for-row, which is why parse.parse_interface_stats (not gs305ep’s parse_port_stats) is needed to read it back. Missing counters (None) render as 0, matching the real device’s own zeroed idle-port rows.

Return type:

str

Raw byte-faithful GS110EMX web-UI HTML fragments (data only, no logic).

Split out of web_gs110emx.py so the long single-line string literals captured verbatim from tests/fixtures/http/gs110emx_*.html (see that module’s docstring) don’t drag E501/line-length linting onto the actual render logic; this module is pure data, excluded from ruff in pyproject.toml exactly like _version.py.

GS105PE web-UI page renderers driven by VirtualSwitchState.

Structurally faithful to the REAL captured pages (see tests/fixtures/http/gs105pe_*.html) – same row markers, same column order, and critically the same two quirks real firmware has, so the mock exercises exactly the code paths real hardware does:

  • portStatistics.cgi leaves the first counter’s <td> EMPTY and carries every counter as a hidden (hi, lo) 32-bit pair (see protocols/http/parse.py’s parse_gs105pe_stats), and writes its rows as <tr class="portID" name="portID"> – an extra attribute the other pages lack.

  • 8021qMembe.cgi carries a per-page CSRF hash and marks the currently selected VLAN with <option ... selected>; the reader reuses that page for the selected VLAN rather than re-POSTing it (which makes real hardware drop the connection).

Byte-for-byte fidelity to the capture is deliberately NOT attempted here (that is what the fixture-driven parser tests in tests/test_http_read.py prove); this face’s job is to serve the SAME STATE the NSDP face serves, so the HTTP<->NSDP cross-verification is meaningful.

netgear_switch.virtual.web_gs105pe.render_status(state)[source]

GET /status.cgi – per-port link + speed, the columns parse_gs105pe_port_status reads ([1]=port, [2]=link, [4]=speed).

Return type:

str

netgear_switch.virtual.web_gs105pe.render_port_statistics(state)[source]

GET /portStatistics.cgi – reproduces the real page’s quirks: rows are <tr class="portID" name="portID">, the Bytes-Received cell is rendered EMPTY, and each counter is a hidden (hi, lo) 32-bit pair.

Return type:

str

netgear_switch.virtual.web_gs105pe.render_pvid(state)[source]

GET /portPVID.cgi – [1]=port, [2]=PVID.

Return type:

str

netgear_switch.virtual.web_gs105pe.render_vlan_config(state)[source]

GET /8021qCf.cgi – the VLAN list as vlanckN checkboxes (the shape gs305ep’s parse_vlan_ids reads, which gs105pe shares).

Return type:

str

netgear_switch.virtual.web_gs105pe.render_vlan_membership(state, selected_vid)[source]

GET/POST /8021qMembe.cgi – the per-port hiddenMem wire codes (1=untagged, 2=tagged, 3=excluded) for selected_vid, plus the CSRF hash and a <option ... selected> marking which VLAN is shown.

Return type:

str

netgear_switch.virtual.web_gs105pe.render_switch_info(state)[source]

GET /switch_info.cgi – device identity + mgmt-IP, in the labelled-cell and lowercase-input shape parse_gs105pe_sysinfo reads.

Return type:

str

GS728TPP GoAhead wcd XML-API renderers for the virtual HTTP face.

Each function renders one wcd response from a VirtualSwitchState in the SAME shape the real switch 10.2.5.10 returns (a trailing <DeviceConfiguration> data block of <Object type="section"> elements), so the SAME parse.parse_goahead_* parsers that read the real captures read the mock back – proving seed<->render<->parse round-trips with no hardware.

Only the data block matters to the parsers (they slice it out and ignore the surrounding template), so this emits a minimal-but-faithful <ResponseData><DeviceConfiguration>.. envelope.

netgear_switch.virtual.web_gs728tpp.render_ports(state)[source]
Return type:

str

netgear_switch.virtual.web_gs728tpp.render_pvids_membership(state)[source]
Return type:

str

netgear_switch.virtual.web_gs728tpp.render_vlans(state)[source]
Return type:

str

netgear_switch.virtual.web_gs728tpp.render_poe(state)[source]
Return type:

str

netgear_switch.virtual.web_gs728tpp.render_macs(state)[source]
Return type:

str

netgear_switch.virtual.web_gs728tpp.render_lldp(state)[source]
Return type:

str

netgear_switch.virtual.web_gs728tpp.render_mgmt_ip(state)[source]
Return type:

str

netgear_switch.virtual.web_gs728tpp.render_device_info_and_sensors(state)[source]

DeviceBasicInfo (cosmetic identity) + DiagnosticsUnitList (the sensors the library actually reads back via parse_goahead_sensors).

The DiagnosticsUnitList fields come from state.sysinfo_sensors (each SensorSim carries the XML tag in instance and the wire code in raw), so the seed is the single source of truth for both faces.

Return type:

str

netgear_switch.virtual.web_gs728tpp.apply_cert_import(state, xml_body)[source]

Accept an SSLCryptoCertificateImportList XML upload, validate it, and record the received certificate on state.uploaded_cert.

Returns the wcd status response. Mirrors real firmware: a well-formed body carrying a non-empty <certificate> and <privateKey> yields <statusCode>0</statusCode> and records the cert; a malformed or empty upload yields a NON-zero statusCode (so a transport/writer regression that dropped the body would be caught here rather than passing silently). DTD/ entity declarations are rejected outright (XXE hardening, matching parse._goahead_data_block).

Return type:

str

netgear_switch.virtual.web_gs728tpp.unauthenticated_response()[source]

What the switch answers a wcd request with no valid session.

CAPTURED from the live GS728TPP (10.2.5.10, firmware 6.0.1.30) by issuing a request with a stale sessionID cookie. Note what it is NOT: not a 302, not a 401, and not an empty body – it is HTTP 200 carrying a normal <ResponseData> envelope whose ActionStatus says statusCode 4. That detail is the whole point of reproducing it: a mock that redirected instead would let the client’s session-expiry handling look correct while missing the case real hardware actually produces.

Return type:

str

netgear_switch.virtual.web_gs728tpp.apply_write(state, xml_body)[source]

Apply one POST wcd write body and return the wcd status response.

The real UI writes EVERYTHING through this one endpoint, with the object name and the action attribute selecting the operation – so the mock must dispatch the same way rather than recognising one special upload. Each branch mirrors what the switch was observed to do on 10.2.5.10 (firmware 6.0.1.30) when the library drove that exact body.

An unrecognised object is a NON-zero statusCode, never a silent success: a writer that posts a body this firmware has no handler for must fail loudly here too.

Return type:

str

netgear_switch.virtual.web_gs728tpp.render_wcd(state, query)[source]

Route a (percent-decoded) wcd?{file=..}{Object}.. query to its renderer, or None if this face serves no such wcd query (the caller 404s, never fabricating a page).

Return type:

str | None