Protocol layer

Pure protocol knowledge: OIDs, packet formats, endpoint specs, command strings, and the parsers over their output. No sockets and no I/O — which is why almost all of it is testable against captured bytes, and why the measurement notes for each device behaviour live here in the source.

SNMP protocol logic (pure, I/O-free).

SNMP

SNMP protocol logic (pure, I/O-free).

SNMP OID constants and per-model vendor OID tables.

netgear_switch.protocols.snmp.oids.DHCP_MODE_OID_SUFFIX = '99.1'

UNVERIFIED — this Netgear private OID for DHCP-vs-static management-IP mode.

This is an unconfirmed guess used only so the mock and reader agree under test; it MUST be confirmed against real hardware via the capture utility (Slice 7) before it is trusted. Until then get_mgmt_ip returns IpMode.UNKNOWN when this OID is absent.

class netgear_switch.protocols.snmp.oids.VendorOids(
base,
poe_power_mw,
box_fan,
box_psu_power,
box_temp,
dhcp_mode_unverified,
syslog_admin_mode,
syslog_local_port,
syslog_host_addr,
syslog_host_port,
syslog_host_severity,
syslog_host_status,
mgmt_write_addr_unverified,
mgmt_write_netmask_unverified,
mgmt_write_gateway_unverified,
)[source]

Bases: object

Per-model Netgear vendor-specific OID table.

base: str
poe_power_mw: str
box_fan: str
box_psu_power: str
box_temp: str
dhcp_mode_unverified: str

The ONE symbol every call site uses for the DHCP-mode OID. See DHCP_MODE_OID_SUFFIX above — UNVERIFIED, best-effort read only. No call site may hard-code a .99.1 literal; they all reference this field.

syslog_admin_mode: str
syslog_local_port: str
syslog_host_addr: str
syslog_host_port: str
syslog_host_severity: str
syslog_host_status: str

Remote-logging configuration, under <base>.14 on BOTH vendor families – 4526.10 (FASTPATH) and 4526.11 (S3300) share the column layout.

Located 2026-08-02 by reading each switch’s own show logging / show logging hosts and then searching a full walk for those values; every field of the CLI output is accounted for by a column and the two agree. On m4300-24x (10.1.5.13) the host row reads 10.1.5.1 / port 514 / severity 6 / status 1 against a CLI table of 10.1.5.1  info  514 Active – so severity is the standard syslog scale (6 = info) and status 1 = Active.

<base>.17 is NOT this: it looks like logging until you notice it holds port 123 and the string “NTP Bits”. It is SNTP, and this fleet’s NTP server and syslog server are the same host, which is what makes the confusion easy.

The admin-mode enum is 1 = enabled, 2 = disabled, confirmed twice over on m4300-24x: syslog reads 1 while show logging says “Syslog Logging : enabled”, and the console column reads 2 while it says “Console Logging : disabled”. The console severity column independently reads 3 against a CLI “error”, matching the same syslog scale.

mgmt_write_addr_unverified: str
mgmt_write_netmask_unverified: str
mgmt_write_gateway_unverified: str

UNVERIFIED writable management-IP OIDs — placeholders pending Slice 7 hardware capture. They are NEVER trusted on real hardware (set_mgmt_ip is force-gated and documented UNVERIFIED); they exist so the mutable mock and the writer agree under test, mirroring the dhcp_mode_unverified precedent above. No call site may hard-code these literals.

netgear_switch.protocols.snmp.oids.has_vendor_oids(model)[source]

True when this model’s SNMP agent implements the Netgear vendor OID subtree (snmp_vendor_base set), so vendor_oids is safe to call.

False for a model whose agent serves EVERYTHING via standard MIBs and registers no 4526 vendor OIDs at all (verified: the GS728TPP – a walk of 1.3.6.1.4.1.4526 answers noSuchObject). Such a model’s PoE, box sensors and DHCP-mode reads use the standard-MIB code paths in snmp_read instead of the vendor columns; see there.

Return type:

bool

netgear_switch.protocols.snmp.oids.unimplemented_roots(model)[source]

OID roots this model’s real SNMP agent does NOT register at all.

Real Netgear firmware only instantiates a MIB module when the underlying hardware capability actually exists: the RFC3621 PoE MIB (PETH_PSE_PORT_TABLE) – and Netgear’s own vendor PoE-power column – is entirely ABSENT, not merely empty, on a non-PoE model such as the M4300-24X. Verified live: a GETNEXT/bulkwalk of the PoE MIB root on that switch answers a single noSuchObject, never silently falls through to whatever unrelated OID happens to sort next (see is_oid_implemented and virtual/faces/mibview.py). Every other MIB group this library reads (system/if/ifX/BRIDGE/Q-BRIDGE/IP/LLDP) is implemented by every currently-registered SNMP-backend model, so only the PoE-gated roots are tracked here; extend this list if a future model is found to lack some other subtree entirely.

Return type:

list[str]

netgear_switch.protocols.snmp.oids.is_oid_implemented(model, oid)[source]

False if oid falls under a subtree root unimplemented_roots says this model’s agent has no registration for at all; True otherwise.

This is deliberately narrower than “does this OID have a value right now”: a table that IS registered but simply has no rows yet (or an instance that’s absent) is a completely different, honest case already handled by StateMibView’s normal noSuchInstance/endOfMibView responses. Only a whole MIB module the device never registers gets noSuchObject here.

Return type:

bool

netgear_switch.protocols.snmp.oids.vendor_oids(model)[source]

Resolve vendor OIDs for a switch model.

Parameters:

model (SwitchModel) – A SwitchModel instance with snmp_vendor_base set.

Returns:

VendorOids dataclass populated with vendor-specific OID strings.

Raises:

UnsupportedCapabilityError – If the model has no SNMP vendor base.

Return type:

VendorOids

Pure SNMP-row -> models.py parsers. No I/O.

netgear_switch.protocols.snmp.parse.index_int_column(rows, base_oid)[source]

Map a single-int-index column walk to {index: int_value}.

Raises SnmpError on a value that is not an integer under base_oid: the walk is pinned to one column, so a non-integer means the table drifted.

Return type:

dict[int, int]

netgear_switch.protocols.snmp.parse.index_str_column(rows, base_oid)[source]

Map a single-index column walk to {index: str_value}.

An absent column (no rows under base_oid) yields an empty dict. But a row that IS present under base_oid with a single, non-integer index component is table drift, not absence, and raises SnmpError naming the offending OID — consistent with index_int_column. (A multi-component suffix belongs to a different, deeper column and is skipped.)

A text-name OCTET STRING (ifName/ifAlias/dot1qVlanStaticName) can legitimately arrive as str from the CLI transport or bytes from the pysnmp transport (its non-printable heuristic picks Hex-STRING for values with any byte outside the printable-ASCII range, e.g. a name with a trailing NUL). Both are valid text here: bytes is decoded to str (utf-8, replacing undecodable bytes) so the two transports yield the same model value. Any other type (e.g. an int) is a genuine wrong-type reply and still raises.

Return type:

dict[int, str]

netgear_switch.protocols.snmp.parse.physical_ports(if_types)[source]

The set of physical (ethernetCsmacd) ifIndexes from an ifType walk.

Returns None when the walk is EMPTY – the caller then keeps every interface, so a transport/mock that does not surface ifType is unchanged. When ifType IS present (every real switch), non-physical interfaces are excluded: the M4300 ifTable carries 128 ieee8023adLag(161) + a CPU(1) + a VLAN(135) interface alongside its 16 ethernetCsmacd(6) ports, none of which the web UI’s port pages list – so filtering here makes SNMP get_ports/ get_pvids agree field-for-field with the HTTP backend.

Return type:

set[int] | None

netgear_switch.protocols.snmp.parse.parse_port_status(admin, oper, speed, names, aliases, if_types=(), duplex=(), pause=())[source]

Per-port status. duplex/pause are the EtherLike-MIB columns.

Both are optional because they are genuinely absent on some agents (see oids.DOT3_STATS_DUPLEX_STATUS): the GS728TPP serves them, the GSM7252PS does not publish those columns at all. A port with no row stays None, which is what the CLI backend already reports where its own table omits the value – absence is never rendered as False.

Return type:

list[PortStatus]

netgear_switch.protocols.snmp.parse.parse_port_stats(
*,
in_octets,
out_octets,
in_ucast,
out_ucast,
in_errors,
out_errors,
if_types=(),
)[source]
Return type:

list[PortStats]

netgear_switch.protocols.snmp.parse.decode_port_bitmap(bitmap)[source]

Decode an SNMP VLAN port bitmap. Bit 7 of byte 0 = port 1.

An empty value is a legitimately absent bitmap -> no ports. Both transports normalize the non-printable OCTET STRING onto the wire to bytes, so that is the expected form and is used directly (MSB-first). If a bitmap ever arrives as a printable str it is latin-1 encoded first; a str that cannot round-trip through latin-1 is malformed and raises SnmpError naming the value.

Return type:

frozenset[int]

netgear_switch.protocols.snmp.parse.parse_vlans(
names,
egress,
untagged,
if_types=(),
current_egress=(),
current_untagged=(),
)[source]

VLANs from the Q-BRIDGE static table, completed by the current table.

if_types filters membership to physical ports, exactly as parse_port_status/parse_pvids already do. Without it a LAG shows up as a phantom member port: VERIFIED on the GS728TPP (10.2.5.10, firmware 6.0.1.30), whose 126-byte PortList sets bit 1000 – po 1, ifType 161 (ieee8023adLag), confirmed identity-mapped via dot1dBasePortIfIndex – in 11 of its 13 VLANs. The switch has 28 ports, so “member port 1000” is not something a caller can act on, and the HTTP backend never reports it.

current_egress/current_untagged add VLANs the STATIC table omits. That is not a hypothetical: the same GS728TPP publishes only 12 static rows (ids 2..99) while dot1qVlanCurrentTable has 13 – VLAN 1, the default VLAN, exists ONLY there, with dot1qVlanStatus = 1 (other) rather than 2 (permanent). Reading the static table alone silently loses the VLAN that carries this switch’s own management ports (24/25/27, untagged), which the web UI does list. Such a VLAN has no dot1qVlanStaticName row, so its name is None – matching what the HTTP backend reports for it.

The static bitmaps win where both tables have the VLAN: they are the CONFIGURED membership. On the live GS728TPP the two agreed byte-for-byte for all 12 shared VLANs, so this ordering was measured, not assumed.

Return type:

list[VLANInfo]

netgear_switch.protocols.snmp.parse.parse_pvids(rows, if_types=())[source]
Return type:

list[tuple[int, int]]

netgear_switch.protocols.snmp.parse.parse_base_mac(rows)[source]

Parse dot1dBaseBridgeAddress (BRIDGE-MIB scalar, standard MIB-II) into a colon-separated MAC string.

An absent scalar (no row under the OID at all) is honestly None – not every device necessarily answers this instance. A row that IS present but isn’t a 6-byte/6-char OCTET STRING is drift, not absence, and raises SnmpError naming the offending OID, consistent with the other column parsers in this module.

Return type:

str | None

netgear_switch.protocols.snmp.parse.parse_lldp(rows)[source]

Group lldpRemTable rows by local port into LLDPNeighbor entries.

The instance suffix is <column>.<timeMark>.<localPortNum>.<remIndex>; the middle component is the local port. A row present under the table prefix but with fewer than 4 suffix components, or a non-integer column or local-port component, is drift (not absence) and raises SnmpError naming the offending OID. A fully-empty neighbour group (every tracked column absent) carries no data and is skipped.

Return type:

list[LLDPNeighbor]

netgear_switch.protocols.snmp.parse.parse_macs(fdb, bridge_ports)[source]

Build the MAC/FDB table from dot1qTpFdbPort + dot1dBasePortIfIndex.

dot1qTpFdbPort gives the bridge PORT number keyed by <vlan>.<mac-as-6-oid-octets>; dot1dBasePortIfIndex maps that bridge port to an ifIndex (falling back to the bridge port number itself when unmapped). A bridge-port value that is present but not an integer is table drift and raises SnmpError naming the offending OID.

Return type:

list[MacEntry]

netgear_switch.protocols.snmp.parse.parse_poe(status, power_mw)[source]

Build PoE port status from RFC3621 pethPsePortTable + vendor mW.

status is a walk of pethPsePortTable; only columns 3 (admin) and 6 (detect) are honoured (the hard-won fix: never column 1). Rows are grouped by (group, port) from the <col>.<group>.<port> instance suffix. A port present in the walk but missing either tracked column is drift (not absence) and raises SnmpError naming the offending port. power_mw is the vendor per-port power walk, matched to a port by the final OID suffix component; a port without a vendor mW row gets power_mw=None.

Return type:

list[PoEStatus]

netgear_switch.protocols.snmp.parse.parse_box_sensors(rows_by_kind)[source]

Build box sensors from walk-discovered Netgear vendor columns.

Each tuple is (kind, unit, rows) for one vendor column walk (e.g. fan RPM, PSU power, temperature). Sensor indices are walk-discovered (they differ per model), not hardcoded. The literal string "Not Supported" is Netgear’s placeholder for an unpopulated slot and is skipped, not an error; any other non-integer value is present-but- malformed and raises SnmpError naming the offending OID.

Return type:

list[Sensor]

netgear_switch.protocols.snmp.parse.parse_entity_sensors(class_rows, name_rows, descr_rows)[source]

Build box sensors from the standard ENTITY-MIB physical inventory.

For a model whose SNMP agent implements NO Netgear vendor OIDs (verified: the GS728TPP), the fan/PSU components are exposed ONLY as ENTITY-MIB entPhysicalTable rows: entPhysicalClass (6=powerSupply, 7=fan) identifies each, entPhysicalName (falling back to entPhysicalDescr) names it. This is INVENTORY ONLY – the switch exposes NO live sensor value/status anywhere in SNMP (ENTITY-SENSOR-MIB and the vendor tree both answer noSuchObject on real hardware), so each Sensor carries value=NaN and unit="inventory": the component is honestly reported as present without a fabricated reading. (HTTP DOES expose a health status for these same components – that is a real per-backend difference, not a parser bug; see the cross-backend test.)

Rows are matched by their shared entPhysicalIndex (the trailing OID component). Only powerSupply/fan classes become sensors; chassis/slot/port rows are ignored. A non-integer class value present under the class column is drift and raises SnmpError naming the offending OID.

Return type:

list[Sensor]

netgear_switch.protocols.snmp.parse.parse_syslog(
admin_mode,
local_port,
host_addr,
host_port,
host_severity,
host_status,
*,
addr_base,
port_base,
severity_base,
status_base,
)[source]

Vendor logging columns -> SyslogConfig.

The host table is indexed by an integer row id, and every per-host column is matched to the address column by that index rather than by position – so a table with a gap in its indices (a deleted row) cannot silently shift one row’s port onto another row’s address.

A row whose address is empty is skipped. The address is the only field that makes a row meaningful, and reporting one collector fewer is far better than inventing where logs are being sent.

Return type:

SyslogConfig

netgear_switch.protocols.snmp.parse.parse_mgmt_ip(
addr,
netmask,
route_dest,
route_nexthop,
dhcp_mode,
base_mac,
addr_rfc4293=(),
)[source]

Build the management-IP config from ipAddrTable/ipRouteTable + vendor mode.

Address/netmask/gateway come from the standard MIBs (ipAddrTable, ipRouteTable) and are trustworthy. The DHCP-vs-static mode is UNVERIFIED (see oids.VendorOids.dhcp_mode_unverified): it is read best-effort and IpMode.UNKNOWN is returned whenever the mode OID is absent/unset — never a guessed dhcp/static. The mode OID is INTEGER-typed, so both transports normalize its value to a Python int (see SnmpRow’s docstring); only a recognized present value (1/2) maps to DHCP/STATIC, any other present value – including one that cannot be coerced to int at all – also yields UNKNOWN rather than raising, since this OID is explicitly best-effort. base_mac is the standard (non-UNVERIFIED) dot1dBaseBridgeAddress scalar walk – see parse_base_mac; an empty walk (OID absent) yields base_mac=None.

Return type:

MgmtIpConfig

netgear_switch.protocols.snmp.parse.parse_hostname(rows)[source]

Extract sysName from one exact-OID GET.

Raises rather than returning a placeholder when the scalar is absent. Every switch in this fleet answers sysName – it is a mandatory MIB-II scalar – so an absent one is a real failure to report, not an empty hostname to invent. An empty string is a different thing and is passed through: a switch with no name configured genuinely has one.

Return type:

str

netgear_switch.protocols.snmp.parse.parse_system_info(rows)[source]

Extract the raw sysDescr/sysObjectID scalar text from one combined GET.

Pure row -> (sys_descr, sys_object_id) extraction ONLY – no model matching happens here. Kept strictly separate from detect_model_from_sysdescr so the matching heuristic is unit-testable against plain strings, with no SnmpRow/client machinery involved at all.

Return type:

tuple[str | None, str | None]

netgear_switch.protocols.snmp.parse.detect_model_from_sysobjectid(sys_object_id, models)[source]

Identify a model from its sysObjectID via SYSOBJECTID_MODELS.

Returns the registry key ONLY when the OID is in the real-capture-confirmed map AND that key is present in models; otherwise None (never a guess). This is the AUTHORITATIVE detector – sysObjectID is a stable manufacturer product identifier, so unlike detect_model_from_sysdescr’s text heuristic it can safely distinguish SKUs whose sysDescr strings are textually indistinguishable (the S3300-52X vs the unregistered S3300-28X). read_system_info tries this first and only falls back to sysDescr matching when it returns None.

Return type:

str | None

netgear_switch.protocols.snmp.parse.detect_model_from_sysdescr(sys_descr, models)[source]

Match a switch’s sysDescr text against registered models’ names.

HONESTY CONSTRAINT: there is no ground-truth sysObjectID -> model table (see oids.SYS_OBJECT_ID – it is read as a raw signal but never used here). Matching is EXACT (case-insensitive) whole-word matching: the sysDescr string is split into whitespace-delimited candidate tokens (_candidate_tokens) and a registered model matches only when one of its own key/display_name/alias tokens (_model_match_tokens) equals one of those candidates in full – NEVER a bare substring/prefix check, and NEVER a guess:

  • A sysDescr containing an unregistered Netgear model name (e.g. "GS752TP", not in models) matches no token and correctly returns None – it is NEVER coerced onto some other, wrong, registered model just because it looks Netgear-ish.

  • A non-Netgear/garbage string matches nothing and also returns None.

  • CRITICAL (regression that motivated the switch away from substring matching): a real, unregistered Netgear model whose name EXTENDS a registered token must also return None, never the shorter registered model. Bare substring matching used to fail this both when the extension has no separator ("GS305EPP" used to wrongly match the registered "GS305EP", a distinct 123W model vs. the registered 63W one) AND when it has one ("S3300-28X" / "S3300-28X-PoE+" used to wrongly match the registered alias "S3300" for gsm7228ps, a distinct S3300 SKU). Whole-word equality rejects both: neither "GS305EPP" nor "S3300-28X" is ever equal to the shorter registered token, regardless of what character (alphanumeric or not) follows it in the original text.

  • A sysDescr matching MORE THAN ONE registered model’s tokens (meaning two registered models’ names collide and can’t be disambiguated by this heuristic) ALSO returns None rather than guessing between them. This never happens for the current registry (verified: no model’s match tokens equal another’s – e.g. “M4300-24X” vs “M4300-16X”, “GSM7252PS” vs “GSM7228PS”/”S3300” are all mutually exclusive), but the fallback is kept as a permanent safety net against a future registry addition introducing a collision.

Return type:

str | None

Shared SNMP transport seam: row type, error, and client protocols.

Pure and I/O-free, and transport-agnostic. The net-snmp CLI (sync) and pysnmp (async) transports both implement these protocols and return SnmpRow instances the parsers consume. No transport-specific types appear here.

class netgear_switch.protocols.snmp.client.SnmpRow(oid, value, snmp_type)[source]

Bases: object

One SNMP varbind: full numeric OID, normalized value, type token.

value is a normalized Python value so the sync (net-snmp CLI) and async (pysnmp) clients are interchangeable: int for integer-family types (INTEGER/Gauge32/Counter32/Counter64/Timeticks-numeric), str for text, OID and IP-address values, bytes for raw octet strings (Hex-STRING). Both clients MUST yield equal values for the same OID (Task 16 enforces it).

oid: str
value: int | str | bytes
snmp_type: str
exception netgear_switch.protocols.snmp.client.SnmpError[source]

Bases: NetgearSwitchError

An SNMP transport operation failed (timeout, connection, agent error).

netgear_switch.protocols.snmp.client.full_oid(oid, oid_index)[source]

Join an optional instance index onto a base OID as a full numeric OID.

A transport may hand back the whole numeric OID in oid with an empty oid_index, or split the instance into oid_index. Joining both and stripping any leading dot is correct either way.

Return type:

str

class netgear_switch.protocols.snmp.client.SnmpClient(*args, **kwargs)[source]

Bases: Protocol

Synchronous SNMP v2c read client for a single switch.

get(oids)[source]
Return type:

list[SnmpRow]

walk(base_oid)[source]
Return type:

list[SnmpRow]

class netgear_switch.protocols.snmp.client.AsyncSnmpClient(*args, **kwargs)[source]

Bases: Protocol

Asynchronous SNMP v2c read client for a single switch.

async get(oids)[source]
Return type:

list[SnmpRow]

async walk(base_oid)[source]
Return type:

list[SnmpRow]

class netgear_switch.protocols.snmp.client.SnmpWriteClient(*args, **kwargs)[source]

Bases: SnmpClient, Protocol

Synchronous SNMP v2c read+write client for a single switch.

Extends the read client with SET. set_many is one PDU (atomic). A write RW community can also read, so a single write client verifies its own writes via the inherited get/walk.

set(varbind)[source]
set_many(varbinds)[source]
class netgear_switch.protocols.snmp.client.AsyncSnmpWriteClient(*args, **kwargs)[source]

Bases: AsyncSnmpClient, Protocol

Asynchronous SNMP v2c read+write client for a single switch.

async set(varbind)[source]
async set_many(varbinds)[source]

Pure SNMP write encoding: SET varbinds and Q-BRIDGE bitmap read-modify-write.

No I/O and transport-agnostic. SetVarbind carries a net-snmp-style type letter (i INTEGER, u Gauge32/unsigned, s string, x hex/octets, a IpAddress) that both transports map onto their own SET call. The bitmap helpers do a read-modify-write so only the target port’s bit changes, leaving trunks and other access ports untouched (design spec §6).

class netgear_switch.protocols.snmp.write.SetVarbind(oid, value, type_letter)[source]

Bases: object

One SNMP SET varbind: full numeric OID, value, and net-snmp type letter.

oid: str
value: int | str | bytes
type_letter: str
netgear_switch.protocols.snmp.write.encode_port_bitmap(ports, width_bytes=8)[source]

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

Bit 7 (MSB) of byte 0 is port 1. The buffer grows past width_bytes if a port number needs it, so callers never pre-size for the actual port count.

Return type:

bytes

netgear_switch.protocols.snmp.write.set_port_bit(current, port, present, *, width_bytes=None)[source]

Read-modify-write one port’s bit in a VLAN bitmap; all others preserved.

Preserves the input bitmap’s byte width to avoid wire-length mismatches on SET for >64-port switches. width_bytes, if given (e.g. a model-derived width from vlan_bitmap_width), is honoured too: the result is at least 8 bytes, at least as wide as the input, and at least width_bytes wide.

Return type:

bytes

netgear_switch.protocols.snmp.write.membership_bitmaps(*, mode, port, egress, untagged, width_bytes=None)[source]

Compute (new_egress, new_untagged) for one port’s VLAN membership change.

UNTAGGED -> egress bit on + untagged bit on; TAGGED -> egress on, untagged off; EXCLUDED -> both off. Read-modify-write on the current bitmaps, so every other port’s membership is preserved. width_bytes is forwarded to set_port_bit for both columns (see vlan_bitmap_width).

Return type:

tuple[bytes, bytes]

netgear_switch.protocols.snmp.write.vlan_bitmap_width(model)[source]

Wire byte-width of model’s dot1q VLAN egress/untagged bitmaps.

dot1qVlanStaticEgressPorts/UntaggedPorts are packed 8 ports/byte, MSB-first (port 1 = bit 7 of byte 0). The Q-BRIDGE MIB’s own default PortList width is 8 bytes (64 ports); a model with more ports needs a wider bitmap or the SET’s wire length won’t match what the device expects.

Return type:

int

NSDP

Pure, zero-dependency NSDP wire protocol package.

Lifted from the standalone gdoc2netcfg/src/nsdp package (protocol/types/ parsers) and extended with a write path. No network here: sockets live in transport/{sync,aio}/nsdp_udp.py. NSDP needs no third-party dependency.

NSDP wire codec: 32-byte header, TLV entries, and packet encode/decode.

Lifted (field-for-field) from gdoc2netcfg/src/nsdp/protocol.py, with the header’s old opaque 4-byte reserved blob split once hardware showed what is in it. The header is struct layout >BB H H 2s 6s 6s I 4s 4s (32 bytes): version (always 0x01), operation, result, error-attr(2), reserved(2), client MAC(6), server MAC(6), sequence(4), signature b"NSDP" at offset 0x18, reserved(4). Each TLV is >HH (tag, length) followed by length value bytes; a packet ends with the 0xFFFF 0x0000 end-of-marker.

This module is a pure, zero-dependency codec: no sockets, no I/O. The write path (Op.WRITE_REQUEST/Op.WRITE_RESPONSE and NSDPPacket.add_tlv with a non-empty value) is new relative to the read-only prior-art client, but uses the exact same wire layout.

class netgear_switch.protocols.nsdp.protocol.Op(*values)[source]

Bases: IntEnum

NSDP operation codes (header byte 1).

READ_REQUEST/RESPONSE are used for discovery and property queries. WRITE_REQUEST/RESPONSE are used to modify switch configuration (requires authentication via Tag.PASSWORD or Tag.AUTH_V2_PASSWORD).

READ_REQUEST = 1
READ_RESPONSE = 2
WRITE_REQUEST = 3
WRITE_RESPONSE = 4
class netgear_switch.protocols.nsdp.protocol.Tag(*values)[source]

Bases: IntEnum

NSDP TLV tag identifiers.

Each tag represents a switch property. Tags are 16-bit unsigned integers encoded big-endian in the packet. See gdoc2netcfg/docs/nsdp-protocol.md (TLV Tag Registry) for byte-level encoding details of each tag’s value field.

START_OF_MARK = 0
END_OF_MARK = 65535
MODEL = 1
HOSTNAME = 3
MAC = 4
LOCATION = 5
IP_ADDRESS = 6
NETMASK = 7
GATEWAY = 8
DHCP_MODE = 11
FIRMWARE_VER_1 = 13
FIRMWARE_VER_2 = 14
PORT_COUNT = 24576
SERIAL_NUMBER = 30720
PASSWORD = 10
AUTH_V2_ENCPASS = 20
AUTH_V2_SALT = 23
AUTH_V2_PASSWORD = 26
PORT_STATUS = 3072
PORT_STATISTICS = 4096
PORT_NAME = 45056
VLAN_ENGINE = 8192
VLAN_PORT_CONF = 9216
VLAN_MEMBERS = 10240
VLAN_DESTROY = 11264
MAX_VLAN = 25600
PORT_PVID = 12288
QOS_ENGINE = 13312
PORT_QOS_PRIORITY = 14336
INGRESS_RATE_LIMIT = 19456
EGRESS_RATE_LIMIT = 20480
BROADCAST_FILTERING = 21504
BROADCAST_BANDWIDTH = 22528
PORT_MIRRORING = 23552
IGMP_SNOOPING = 26624
BLOCK_UNKNOWN_MULTICAST = 27648
IGMPV3_HEADER_VALIDATION = 28672
IGMP_STATIC_ROUTER_PORTS = 32768
LOOP_DETECTION = 36864
ACTIVE_FIRMWARE = 12
REBOOT = 19
FACTORY_RESET = 1024
class netgear_switch.protocols.nsdp.protocol.TLVEntry(tag, value=b'')[source]

Bases: object

One NSDP TLV: a 2-byte tag, 2-byte length, then that many value bytes.

tag: Tag | int
value: bytes = b''
encode()[source]
Return type:

bytes

classmethod decode(data)[source]
Return type:

tuple[TLVEntry, int]

class netgear_switch.protocols.nsdp.protocol.NSDPPacket(
op,
client_mac,
server_mac=b'\x00\x00\x00\x00\x00\x00',
sequence=0,
result=0,
tlvs=<factory>,
error_attr=0,
)[source]

Bases: object

A full NSDP datagram: a fixed header plus a list of TLVs.

op: Op
client_mac: bytes
server_mac: bytes = b'\x00\x00\x00\x00\x00\x00'
sequence: int = 0
result: int = 0
tlvs: list[TLVEntry]
error_attr: int = 0
property error_code: int

The switch’s error code alone (header byte 2).

result is the 16-bit field bytes 2-3; only its high byte is the error code (byte 3 is always 0 on every real reply captured so far), so result == 0x0300 means error code 3.

add_tlv(tag, value=b'')[source]
encode()[source]
Return type:

bytes

classmethod decode(data)[source]
Return type:

NSDPPacket

NSDP-native parsed value types. Lifted from gdoc2netcfg/src/nsdp/types.py.

These are the raw protocol shapes the parsers return; nsdp_read.py maps them onto the shared models.py types. Named with an Nsdp prefix so they never collide with the public models dataclasses.

class netgear_switch.protocols.nsdp.types.LinkSpeed(*values)[source]

Bases: IntEnum

DOWN = 0
HALF_10M = 1
FULL_10M = 2
HALF_100M = 3
FULL_100M = 4
GIGABIT = 5
TEN_GIGABIT = 6
TEN_GIGABIT_PRIOR_ART = 255
classmethod from_byte(value)[source]
Return type:

LinkSpeed

property speed_mbps: int
class netgear_switch.protocols.nsdp.types.VLANEngine(*values)[source]

Bases: IntEnum

DISABLED = 0
BASIC_PORT = 1
ADVANCED_PORT = 2
BASIC_802_1Q = 3
ADVANCED_802_1Q = 4
class netgear_switch.protocols.nsdp.types.NsdpPortStatus(port_id: 'int', speed: 'LinkSpeed', flow_control: 'bool | None' = None)[source]

Bases: object

port_id: int
speed: LinkSpeed
flow_control: bool | None = None
class netgear_switch.protocols.nsdp.types.NsdpPortName(port_id, name)[source]

Bases: object

One port’s operator description (tag 0x0B000 / PORT_NAME).

name is None when the TLV carries only the port byte, which is how a real GS110EMX reports a port with no description set.

port_id: int
name: str | None
class netgear_switch.protocols.nsdp.types.NsdpPortStatistics(
port_id: 'int',
bytes_received: 'int',
bytes_sent: 'int',
crc_errors: 'int',
)[source]

Bases: object

port_id: int
bytes_received: int
bytes_sent: int
crc_errors: int
class netgear_switch.protocols.nsdp.types.NsdpVlanMembership(
vlan_id: 'int',
member_ports: 'frozenset[int]',
tagged_ports: 'frozenset[int]' = frozenset(),
)[source]

Bases: object

vlan_id: int
member_ports: frozenset[int]
tagged_ports: frozenset[int] = frozenset({})
property untagged_ports: frozenset[int]
class netgear_switch.protocols.nsdp.types.NsdpPortPvid(port_id: 'int', vlan_id: 'int')[source]

Bases: object

port_id: int
vlan_id: int
class netgear_switch.protocols.nsdp.types.NsdpPortMirroring(destination_port, source_ports=frozenset({}))[source]

Bases: object

Port mirroring configuration (NSDP tag 0x5C00).

Lifted from gdoc2netcfg/src/nsdp/types.py::PortMirroring.

Variables:
  • destination_port (int) – Port receiving mirrored traffic (0 = disabled).

  • source_ports (frozenset[int]) – Ports being mirrored.

destination_port: int
source_ports: frozenset[int] = frozenset({})
class netgear_switch.protocols.nsdp.types.NsdpIgmpSnooping(enabled, vlan_id=None)[source]

Bases: object

IGMP snooping configuration (NSDP tag 0x6800).

Lifted from gdoc2netcfg/src/nsdp/types.py::IGMPSnooping.

Variables:
  • enabled (bool) – Whether IGMP snooping is enabled.

  • vlan_id (int | None) – VLAN for IGMP snooping (if applicable); None when the wire value is 0 (no VLAN association).

enabled: bool
vlan_id: int | None = None
class netgear_switch.protocols.nsdp.types.NsdpDevice(
model: 'str',
mac: 'str',
hostname: 'str | None' = None,
ip: 'str | None' = None,
netmask: 'str | None' = None,
gateway: 'str | None' = None,
firmware_version: 'str | None' = None,
dhcp_enabled: 'bool | None' = None,
port_count: 'int | None' = None,
serial_number: 'str | None' = None,
vlan_engine: 'VLANEngine | None' = None,
port_status: 'tuple[NsdpPortStatus,
...]'=(),
port_names: 'tuple[NsdpPortName,
...]'=(),
port_statistics: 'tuple[NsdpPortStatistics,
...]'=(),
vlan_members: 'tuple[NsdpVlanMembership,
...]'=(),
port_pvids: 'tuple[NsdpPortPvid,
...]'=<factory>,
qos_engine: 'int | None' = None,
port_mirroring: 'NsdpPortMirroring | None' = None,
igmp_snooping: 'NsdpIgmpSnooping | None' = None,
broadcast_filtering: 'bool | None' = None,
loop_detection: 'bool | None' = None,
)[source]

Bases: object

model: str
mac: str
hostname: str | None = None
ip: str | None = None
netmask: str | None = None
gateway: str | None = None
firmware_version: str | None = None
dhcp_enabled: bool | None = None
port_count: int | None = None
serial_number: str | None = None
vlan_engine: VLANEngine | None = None
port_status: tuple[NsdpPortStatus, ...] = ()
port_names: tuple[NsdpPortName, ...] = ()
port_statistics: tuple[NsdpPortStatistics, ...] = ()
vlan_members: tuple[NsdpVlanMembership, ...] = ()
port_pvids: tuple[NsdpPortPvid, ...]
qos_engine: int | None = None
port_mirroring: NsdpPortMirroring | None = None
igmp_snooping: NsdpIgmpSnooping | None = None
broadcast_filtering: bool | None = None
loop_detection: bool | None = None

NSDP write authentication (v1 XOR and v2 salted challenge-response).

Two schemes exist; a switch advertises which via the AUTH_V2_ENCPASS (0x0014) read: value 1 = v1, value 0x10 = v2.

v1 (older Plus firmware): the admin password is sent in a PASSWORD (0x000A) TLV “encrypted” by a repeating XOR against the 19-byte key NtgrSmartSwitchRock. XOR is its own inverse, so encode_password_v1 both encodes an outgoing and decodes an incoming PASSWORD TLV.

v2 (newer firmware, incl. GS110EMX fw 1.0.2.8 – LIVE-VERIFIED): a challenge-response. The client reads a fresh 4-byte salt from AUTH_V2_SALT (0x0017, which rotates on every read), then writes an 8-byte token in AUTH_V2_PASSWORD (0x001A) alongside the config change. The token is NOT a hash – it is an 8-byte XOR fold of the 20-byte password, the 4-byte salt and the switch’s own 6-byte MAC (the MAC from the salt read’s response header). auth_v2_password is transcribed from the reverse-engineered AuthV2Password in CursedHardware/go-nsdp (in turn from yaamai/go-nsdp); its “each output byte XORs three password bytes” shape is exactly the weakness NCC Group documented (CVE-2020-35221) for this scheme. Verified two ways: it reproduces go-nsdp’s own TestAuthV2Password vector byte-for-byte (password=”password”, mac 12:34:56:78:9a:bc, salt 12:34:56:78 -> c4:af:7c:00:a6:c4:1a:7d — see tests/protocols/nsdp/test_auth.py), and a real GS110EMX accepts a WRITE carrying [config…, AUTH_V2_PASSWORD=fold].

Investigation evidence (GS110EMX @ 10.1.5.25/.26/.27, fw 1.0.2.8, 2026-07-29):

  • The token is NOT any hash. Before finding go-nsdp, md5(merge(pw, salt)) and md5(pw+salt)/md5(salt+pw) were tried live, with the salt rendered as a decimal string (both endiannesses), a hex string, and raw bytes, and the payload sent as raw-16, 32-hex-ASCII, and each of those XOR’d with NtgrSmartSwitchRock. EVERY one was rejected error 13. The switch’s WEB UI does use md5(merge(password, rand)) (rand = a decimal nonce; confirmed by a successful HTTP login), but NSDP’s 0x001A token is the unrelated XOR fold here — the two auth paths do not share the transform.

  • AUTH_V2_ENCPASS (0x0014) returns 0x00000010 on this SKU (v2); a v1 unit returns 1. AUTH_V2_SALT (0x0017) is a 4-byte value that rotates on EVERY read. AUTH_V2_PASSWORD (0x001A) is write-only (a READ of it returns error 3).

  • Write structure matters: the 0x001A token must come FIRST, then the config TLVs ([0x001A, config…]) – this is what authenticates (error 0). Sending it LAST is rejected error 13; a malformed/wrong-length token leading the packet was seen to return error 4. Broadcast vs switch-MAC targeting and a real vs dummy client MAC do NOT matter – auth-first works with the library’s broadcast/dummy framing.

  • Lockout (belongs in the mock; see faces/nsdp.py): READS always work, even while writes are locked. Wrong-token writes return error 13 for the first few (4 consecutive seen at ~1.2 s spacing), then escalate to error 14, then the switch goes SILENT to writes (no reply) for a long cooldown (>10 min observed; each fresh failed attempt appears to restart the window). The exact failure count before 14 varied 2-5 across units, i.e. it is rate/time-based, not a clean counter.

netgear_switch.protocols.nsdp.auth.encode_password_v1(password)[source]

Repeating-XOR the ASCII password with NtgrSmartSwitchRock (its own inverse). No padding/truncation rule applies – the ciphertext is the password’s own length.

Return type:

bytes

netgear_switch.protocols.nsdp.auth.auth_v2_password(password, switch_mac, salt)[source]

Compute the 8-byte NSDP v2 auth token for a write.

switch_mac is the device’s own 6-byte MAC (the server MAC echoed in the AUTH_V2_SALT read response); salt is that read’s fresh 4-byte value. The password is taken as a 20-byte key (ASCII, zero-padded / truncated to 20 – the web UI caps the field at 20 chars). Transcribed byte-for-byte from CursedHardware/go-nsdp AuthV2Password.

Return type:

bytes

netgear_switch.protocols.nsdp.auth.encpass_is_v2(value)[source]

Decide the write-auth scheme from an AUTH_V2_ENCPASS (0x0014) value.

v2 iff the advertised value is 0x10 (observed 0x00000010 on a GS110EMX); any other value (notably 1) means legacy v1 XOR. An absent/empty value is treated as v1 – the historical default.

Return type:

bool

Per-tag NSDP byte parsers. Lifted from gdoc2netcfg/src/nsdp/parsers.py, plus ports_to_bitmap (the write-path inverse of bitmap_to_ports).

Every parser is total over the bytes it accepts and raises ValueError on a wrong length or a bad prefix, so a malformed TLV surfaces early rather than producing a silently-wrong value.

netgear_switch.protocols.nsdp.parsers.parse_ipv4(data)[source]
Return type:

str

netgear_switch.protocols.nsdp.parsers.parse_mac(data)[source]
Return type:

str

netgear_switch.protocols.nsdp.parsers.parse_port_status(data)[source]
Return type:

NsdpPortStatus

netgear_switch.protocols.nsdp.parsers.parse_port_name(data)[source]

Parse NSDP tag 0xB000: port byte + the operator description string.

MEASURED on real GS110EMX hardware (see Tag.PORT_NAME). A 1-byte TLV means “this port has no description” – that is what an undescribed port answers, so it maps to None rather than to an empty string that a caller could not distinguish from a description of “”.

Return type:

NsdpPortName

netgear_switch.protocols.nsdp.parsers.parse_port_statistics(data)[source]
Return type:

NsdpPortStatistics

netgear_switch.protocols.nsdp.parsers.parse_port_pvid(data)[source]
Return type:

NsdpPortPvid

netgear_switch.protocols.nsdp.parsers.parse_serial(data)[source]
Return type:

str

netgear_switch.protocols.nsdp.parsers.bitmap_to_ports(bitmap)[source]

MSB-first, 1-based: byte 0 bit 0x80 = port 1 … 0x01 = port 8.

Return type:

frozenset[int]

netgear_switch.protocols.nsdp.parsers.ports_to_bitmap(ports, width_bytes)[source]

Inverse of bitmap_to_ports for the write path (same MSB-first layout).

Return type:

bytes

netgear_switch.protocols.nsdp.parsers.parse_vlan_members(data, port_count=8)[source]
Return type:

NsdpVlanMembership

netgear_switch.protocols.nsdp.parsers.parse_port_mirroring(data)[source]

Parse NSDP tag 0x5C00: dest_port(1) + a variable-width source bitmap.

The source-port bitmap width is MODEL-dependent: a 5-port GS105PE returns a 2-byte bitmap (3-byte TLV in total), a 10-port GS110EMX a 3-byte bitmap (4-byte TLV) – both confirmed live 2026-07-21. Earlier this hard-required exactly 4 bytes and so raised on a real GS105PE (00 00 00 = mirroring off). Accept dest_port plus the remaining bytes as the bitmap instead.

Lifted from gdoc2netcfg/src/nsdp/parsers.py::parse_port_mirroring.

Return type:

NsdpPortMirroring

netgear_switch.protocols.nsdp.parsers.parse_igmp_snooping(data)[source]

Parse NSDP tag 0x6800 (>=2 bytes: unknown, enabled, [unknown, vlan?]).

Lifted from gdoc2netcfg/src/nsdp/parsers.py::parse_igmp_snooping.

Return type:

NsdpIgmpSnooping

netgear_switch.protocols.nsdp.parsers.parse_device(packet)[source]

Aggregate a READ_RESPONSE packet’s TLVs into an NsdpDevice.

Return type:

NsdpDevice

Shared NSDP transport seam: error, result check, and client Protocols.

Pure and transport-agnostic (mirrors protocols/snmp/client.py). NsdpError lives here beside the protocol rather than in errors.py, matching the SnmpError precedent; both subclass the shared NetgearSwitchError base so callers can still catch the library-wide root.

exception netgear_switch.protocols.nsdp.client.NsdpError[source]

Bases: NetgearSwitchError

An NSDP transport operation failed (timeout, malformed, bad password).

netgear_switch.protocols.nsdp.client.first_tlv_value(packet, tag)[source]

Return the value of the first TLV with tag, or None if absent.

Return type:

bytes | None

netgear_switch.protocols.nsdp.client.read_interface_mac(interface)[source]

Read a network interface’s 6-byte MAC from sysfs (Linux).

Return type:

bytes

netgear_switch.protocols.nsdp.client.check_result(packet)[source]

Raise NsdpError unless the response reports success (result 0x0000).

The 2-byte result is (error-byte << 8 | unk1); the error CODE alone is result >> 8 (NSDPPacket.error_code). Codes seen on real rejections: 3 = read-only, 4 = write-only, 7 = v1 denial, 13 = write auth refused, 14 = write lockout.

Every message NAMES the TLV tag the switch blamed (header bytes 4-5), because the switch tells us and a caller cannot debug “the request failed” otherwise (principle 1). That blamed tag is also what separates the two causes of error 13: attr 0x000A (ATTR_PASSWORD) means a v1 XOR password was offered to a firmware that only accepts the v2 salted challenge-response – a WIRING problem, not a credential one, and an operator told “bad password” there rotates a credential that was never wrong. Error 13 on any other attr really is a bad password: this library IMPLEMENTS v2 auth (auth.auth_v2_password, live-verified on a GS110EMX), so a rejected token means the password is wrong or the salt it was folded against went stale.

class netgear_switch.protocols.nsdp.client.NsdpClient(*args, **kwargs)[source]

Bases: Protocol

Synchronous NSDP read client for a single switch.

read(tags)[source]
Return type:

NSDPPacket

class netgear_switch.protocols.nsdp.client.NsdpWriteClient(*args, **kwargs)[source]

Bases: NsdpClient, Protocol

Synchronous NSDP read+write client for a single switch.

write(tlvs, *, password)[source]
Return type:

NSDPPacket

class netgear_switch.protocols.nsdp.client.AsyncNsdpClient(*args, **kwargs)[source]

Bases: Protocol

Asynchronous NSDP read client for a single switch.

async read(tags)[source]
Return type:

NSDPPacket

class netgear_switch.protocols.nsdp.client.AsyncNsdpWriteClient(*args, **kwargs)[source]

Bases: AsyncNsdpClient, Protocol

Asynchronous NSDP read+write client for a single switch.

async write(tlvs, *, password)[source]
Return type:

NSDPPacket

NSDP request framing + value-TLV encoders for the read and write paths.

Pure: builds NSDPPacket objects, no I/O. Two authenticated WRITE builders exist, one per scheme the transport auto-selects from AUTH_V2_ENCPASS:

The v2 auth is LIVE-VERIFIED on a GS110EMX (fw 1.0.2.8): a correctly-authed write returns header error 0 and reads back; a wrong token returns error 13. check_result maps the rejection codes; verify-after-write in nsdp_write.py remains the guard against a silently wrong value encoding.

Note on tag writability: the gdoc2netcfg reference spec marks PORT_PVID (0x3000) and VLAN_MEMBERS (0x2800) as READ-ONLY. The ProSafe utility does configure both over NSDP, and nsdp_write.py’s set_pvid / set_vlan_membership drive them with verify-after-write; see those methods for the live-verified status of each on the GS110EMX.

netgear_switch.protocols.nsdp.write.build_read_request(client_mac, server_mac, sequence, tags)[source]
Return type:

NSDPPacket

netgear_switch.protocols.nsdp.write.build_write_request(client_mac, server_mac, sequence, password, tlvs)[source]

Build a v1-authenticated WRITE: the XOR PASSWORD TLV, then config.

Return type:

NSDPPacket

netgear_switch.protocols.nsdp.write.build_write_request_v2(client_mac, server_mac, sequence, tlvs, auth_token)[source]

Build a v2-authenticated WRITE: the 8-byte AUTH_V2_PASSWORD token FIRST, then the config TLVs.

Ordering is load-bearing and LIVE-VERIFIED on a GS110EMX: leading with the 0x001A token authenticates and applies the write (header error 0); trailing it after the config change is rejected error 13. This matches yaamai/go-nsdp’s WriteWithAuth (auth TLV prepended). The caller must have just read a fresh AUTH_V2_SALT so the token matches the switch’s stored challenge.

Return type:

NSDPPacket

netgear_switch.protocols.nsdp.write.pvid_tlv(port, vlan)[source]
Return type:

TLVEntry

netgear_switch.protocols.nsdp.write.vlan_members_tlv(vlan, members, tagged, port_count)[source]
Return type:

TLVEntry

netgear_switch.protocols.nsdp.write.vlan_destroy_tlv(vlan)[source]

The write-only VLAN-destroy action TLV (tag 0x2C00, 2-byte VLAN id).

GROUNDED in ngadmin’s independent C implementation – lib/src/vlan.c::ngadmin_VLANDestroy builds exactly newShortAttr(ATTR_VLAN_DESTROY, vlan) and sends it as a write request. That is the evidence that replaced this library’s previous unproven claim that “NSDP has no VLAN create/destroy tag”. It is still NOT confirmed against hardware: authenticated NSDP writes DO work on the reachable GS110EMX (fw 1.0.2.8) now that v2 auth is implemented – PORT_PVID and VLAN_MEMBERS were written and read back live – but destroying a VLAN on a production switch was out of scope for that session, so this tag stayed un-exercised. Verify-after-write in nsdp_write.py is the runtime guard.

Return type:

TLVEntry

netgear_switch.protocols.nsdp.write.port_name_tlv(port, name)[source]

Per-port description write TLV (tag 0xB000), mirroring the read shape.

The READ encoding is measured (port byte + description bytes, see Tag.PORT_NAME); the write is the same shape and, like vlan_destroy_tlv, was never exercised against hardware.

Return type:

TLVEntry

netgear_switch.protocols.nsdp.write.hostname_tlv(name)[source]

Host-name write TLV (tag 0x0003), the same shape the read decodes.

The read side is measured – parsers decodes this tag as plain text and three live GS110EMX report their names through it – so the write is that encoding with nothing added: the bare name, ASCII, no length prefix and no port byte (unlike port_name_tlv above, whose tag is indexed by port).

Return type:

TLVEntry

netgear_switch.protocols.nsdp.write.ipv4_tlv(tag, dotted)[source]
Return type:

TLVEntry

netgear_switch.protocols.nsdp.write.dhcp_tlv(enabled)[source]
Return type:

TLVEntry

netgear_switch.protocols.nsdp.write.reboot_tlv()[source]
Return type:

TLVEntry

HTTP web UI

Pure (I/O-free) HTTP web-UI protocol layer: crypto, endpoints, parsers, forms.

Per-model web-UI endpoint/CGI definitions (pure data).

Each HttpModelSpec records how one model logs in and which page each read op scrapes. scheme_verified/reads_verified mark whether that model’s flows are grounded in captured prior art or still UNVERIFIED-pending-capture:

  • gs305ep (Plus PoE): login, dashboard/stats, PoE, VLAN/PVID, and reboot endpoints are GROUNDED in py_netgear_plus/models.py (GS30xSeries / GS30xEPxSeries: CRYPT_FUNCTION="merge_hash", LOGIN_TEMPLATE, PoE/VLAN CGI paths) and in rcfiles/bin/netgear-smp-vlan (identical merge hash scheme observed on GS105PE; 8021qCf.cgi / 8021qMembe.cgi / portPVID.cgi field shapes and wire codes). Both scheme_verified and reads_verified are True.

  • gsm7228ps (Smart Managed Pro / S3300-52X): the plaintext cheetah login form is GROUNDED in certbot-hook-netgear-switches/netgear-updater.py (S3300Updater), so scheme_verified is True. Its read pages are the SAME Cheetah XE grid as the sibling gsm7252ps and are GROUNDED in real captures of the live switch (tests/fixtures/http/gsm7228ps_*.html); ports/stats/PVIDs/VLANs/PoE/LLDP reuse the parse_xe_* parsers, while the MAC table (shifted columns, escaped 1/gN port names) and sensors (unsupported over HTTP) get S3300-specific handling – see HtmlDialect.S3300. reads_verified is True (HTTP cross-verified vs SNMP on 10.1.5.11); SNMP remains authoritative for the full sensor set. Its management IP is NOT “unreachable over HTTP” as this file once claimed – /ipConfiguration.html serves it, and more of it than SNMP does (see mgmt_ip_path below).

  • gs110emx (Plus EMx / Gambit): GROUNDED in real captures from a physical GS110EMX (tests/fixtures/http/gs110emx_*.html). The scheme is merge_hash_md5(password, rand) (identical function to gs305ep) POSTed as LoginPassword to /redirect.html (rand scraped from GET /, not from the POST target itself – see login_post_path); the response carries a Gambit TOKEN (not a cookie – no Set-Cookie is ever sent) that every subsequent request must carry (session_token_field). HTTP covers the FULL NSDP read surface here (ports/stats/VLANs/PVIDs/ mgmt-IP): an earlier probe guessed /iss/specific/{vlan,port,pvid}.html, got 404s and WRONGLY concluded “NSDP-only” – the real URLs (port_settings/vlan_pvidsetting/Cf8021q/vlanMembership) live only as JS string literals and were found live 2026-07-21. poe_* stay None (this model genuinely has no PoE, confirmed 404), as do reboot_path/logout_path (never captured, not guessed). CAVEAT on reads_verified=True: only VLAN 1’s membership page was captured, so the per-VLAN vlanIdSel select is live-confirmed but fixture-proven for VLAN 1 only.

  • gs105pe (Plus, 5-port): LIVE-VERIFIED on a real GS105PE (10.1.5.30, 2026-07-21) – registry verified=True, and BOTH scheme_verified and reads_verified are True, grounded in six real captures (tests/fixtures/http/gs105pe_*.html). The merge-hash login is shared with gs305ep, but the READ paths are NOT: the gs305ep copies were partly wrong (dashboard.cgi and getPoePortStatus.cgi both 404 on real hardware). Port status is status.cgi and device identity/mgmt-IP is switch_info.cgi; see HtmlDialect.GS105PE for the parser set.

  • gsm7252ps (Fully Managed / XE FASTPATH): the cheetah login (uname``+``pwd -> SID cookie) was validated LIVE on 10.1.5.22, so scheme_verified is True. Its read pages sit at the ROOT prefix and are grounded in real captures of that switch, covering EVERY read op including sensors and mgmt-IP (sysInfo.html). reads_verified is True: the HTTP reader output was cross-verified against SNMP on the live switch (10.1.5.22) – ports/PVIDs match, mgmt-IP is an exact match, and every read op returns real data.

class netgear_switch.protocols.http.endpoints.LoginScheme(*values)[source]

Bases: Enum

MERGE_HASH_CGI = 'merge_hash_cgi'
GAMBIT = 'gambit'
CHEETAH_FORM = 'cheetah_form'
CHEETAH_V1 = 'cheetah_v1'
XML_API = 'xml_api'
class netgear_switch.protocols.http.endpoints.HtmlDialect(*values)[source]

Bases: Enum

Which family of HTML the model’s read pages are written in, selecting the whole parser set http_read.py uses for ports/stats/PVIDs/VLAN-list.

The two families genuinely differ in wire shape: the gs305ep CGI pages use closed <tr class="portID">...</tr> rows and 8021qCf.cgi VLAN checkboxes, whereas the real GS110EMX firmware never closes a port row and lists VLANs as <tr class="vlanID tableTr"> rows (all GROUNDED in captures under tests/fixtures/http/). One dialect field per model beats a separate shape flag per read op.

STANDARD = 'standard'
GS110EMX = 'gs110emx'
GS105PE = 'gs105pe'
M4300 = 'm4300'
XE_FASTPATH = 'xe_fastpath'
S3300 = 's3300'
GOAHEAD_XML = 'goahead_xml'
netgear_switch.protocols.http.endpoints.dialect_has_csrf_hash(dialect)[source]

Whether this dialect’s pages carry an <input name="hash"> CSRF token.

http_write scrapes that token before every form post, so a dialect without one cannot be driven by those writers at all.

MEASURED 2026-08-02, not inferred. Live probes found NO hash on any write page of gsm7252ps (10.1.5.22: vlanStatus, poeInterfaceConfiguration, portPvidConfiguration, vlan_port_cfg, portsConfiguration) nor of gs110emx (10.1.5.25: Cf8021q, vlan_pvidsetting). Only the Plus .cgi pages have it – the surface HttpWriter was originally written against.

ONE definition, read by the capability oracle AND by the virtual switch, so the mock can never emit a token the hardware lacks. That divergence is exactly how HTTP create_vlan passed the entire test suite while failing on all four FASTPATH switches.

Return type:

bool

class netgear_switch.protocols.http.endpoints.XuiMgmtIpFields(address, netmask, gateway, mode, static_value, dhcp_value, apply_button)[source]

Bases: object

Which fields of a FASTPATH XUI management-IP page carry what.

Deliberately PER MODEL, never shared by dialect. The two Cheetah families put the same information on different pages under different names, and one page name that looks shared is not:

  • gsm7252ps / gsm7228ps: /ipConfiguration.html – address v_1_1_1, mask v_1_2_1, gateway v_1_3_1, protocol v_1_18_1 (the hidden twin of the visible radio, which is v_1_8_1 on gsm7252ps but v_1_4_1 on gsm7228ps – the same name means different things on the two boxes, so only the hidden one is used).

  • m4300-24x / m4300-16x: /v1/mgmtVlanIpv4Configuration.html – address v_1_6_1, mask v_1_7_1, gateway v_1_71_1, DHCP/static v_1_5_3 (Enable = DHCP, Disable = Manual, per the page’s own xeData["xew_1_5_3_Enable"] = "DHCP"). Their /v1/ipConfiguration.html exists and answers 200, but it is the SERVICE-PORT interface and reads 0.0.0.0/0.0.0.0 on both SKUs (live 2026-07-30) – reading mgmt-IP from it would report the switch as unaddressed.

  • gsm7252ps/gsm7228ps 404 on /mgmtVlanIpv4Configuration.html (live 2026-07-30), which is exactly why this is not one shared constant.

address: str
netmask: str
gateway: str
mode: str
static_value: str
dhcp_value: str
apply_button: str
class netgear_switch.protocols.http.endpoints.HttpModelSpec(
model_key: 'str',
scheme: 'LoginScheme',
scheme_verified: 'bool',
login_path: 'str',
password_field: 'str',
cookie_name: 'str',
needs_rand: 'bool',
dashboard_path: 'str | None',
stats_path: 'str | None',
poe_config_path: 'str | None',
poe_status_path: 'str | None',
vlan_config_path: 'str | None',
vlan_membership_path: 'str | None',
pvid_path: 'str | None',
reboot_path: 'str | None',
logout_path: 'str | None',
is_epx_poe: 'bool',
reads_verified: 'bool',
session_token_field: 'str | None' = None,
login_post_path: 'str | None' = None,
sysinfo_path: 'str | None' = None,
mgmt_ip_path: 'str | None' = None,
html_dialect: 'HtmlDialect' = <HtmlDialect.STANDARD: 'standard'>,
mac_table_path: 'str | None' = None,
username_field: 'str | None' = None,
username: 'str' = 'admin',
needs_referer: 'bool' = False,
lldp_path: 'str | None' = None,
cert_upload_path: 'str | None' = None,
cert_upload_file_field: 'str | None' = None,
cert_upload_form_fields: 'Mapping[str,
str]'=<factory>,
secure: 'bool' = False,
vlan_membership_post_path: 'str | None' = None,
web_port: 'int | None' = None,
port_config_path: 'str | None' = None,
mgmt_ip_fields: 'XuiMgmtIpFields | None' = None,
xml_write_path: 'str | None' = None,
syslog_path: 'str | None' = None,
users_path: 'str | None' = None,
http_service_path: 'str | None' = None,
https_service_path: 'str | None' = None,
ssh_service_path: 'str | None' = None,
telnet_service_path: 'str | None' = None,
)[source]

Bases: object

model_key: str
scheme: LoginScheme
scheme_verified: bool
login_path: str
password_field: str
cookie_name: str
needs_rand: bool
dashboard_path: str | None
stats_path: str | None
poe_config_path: str | None
poe_status_path: str | None
vlan_config_path: str | None
vlan_membership_path: str | None
pvid_path: str | None
reboot_path: str | None
logout_path: str | None
is_epx_poe: bool
reads_verified: bool
session_token_field: str | None = None
login_post_path: str | None = None
sysinfo_path: str | None = None
mgmt_ip_path: str | None = None
html_dialect: HtmlDialect = 'standard'
mac_table_path: str | None = None
username_field: str | None = None
username: str = 'admin'
needs_referer: bool = False
lldp_path: str | None = None
cert_upload_path: str | None = None
cert_upload_file_field: str | None = None
cert_upload_form_fields: Mapping[str, str]
secure: bool = False
vlan_membership_post_path: str | None = None
web_port: int | None = None
port_config_path: str | None = None
mgmt_ip_fields: XuiMgmtIpFields | None = None
xml_write_path: str | None = None
syslog_path: str | None = None
users_path: str | None = None
http_service_path: str | None = None
https_service_path: str | None = None
ssh_service_path: str | None = None
telnet_service_path: str | None = None
netgear_switch.protocols.http.endpoints.http_spec(model)[source]

Return the web-UI spec for model or raise if it has no HTTP backend.

Return type:

HttpModelSpec

Pure (I/O-free) parsers mapping web-UI HTML -> shared models types.

Regex-based (no lxml/bs4 dependency). Grounding varies BY PARSER, so check the specific function’s docstring rather than trusting a blanket claim:

  • The gs110emx_*, gs105pe_* and m4300_* parsers are GROUNDED in REAL device captures (tests/fixtures/http/{gs110emx,gs105pe,m4300}_*.html) and are live-verified – their column offsets/field names are confirmed.

  • The gs305ep/STANDARD-dialect parsers (parse_port_status, parse_port_stats, parse_poe_status, parse_pvids, parse_vlan_ids) match only SYNTHETIC fixtures headed UNVERIFIED-pending-capture; their offsets are a same-family guess (the gs105pe live work found some gs305ep-derived CGI paths 404 on real hardware), so confirm against a real GS305EP before relying on them in production.

Two different failure shapes are deliberate:

  • A token scrape (parse_login_rand/parse_csrf_hash/ parse_selected_vlan) returns None when the value is absent. The reader (Task 4) is the one with enough context to know whether that is fatal, and raises HttpAuthError/HttpUnexpectedPageError itself — this module never guesses.

  • A table/page parser that cannot find the structure the page is documented to always contain (e.g. no portID rows on dashboard.cgi, no hiddenMem on 8021qMembe.cgi) raises HttpUnexpectedPageError naming what was expected. These pages are never legitimately empty on a real switch (port tables always list every physical port), so a missing structure means the wrong page came back, not “empty switch” -> never silently swallowed into an empty list/dict.

netgear_switch.protocols.http.parse.parse_login_rand(html)[source]

Scrape the login nonce from <input id="rand" ... value="...">.

Return type:

str | None

netgear_switch.protocols.http.parse.parse_csrf_hash(html)[source]

Scrape the CSRF token from <input name="hash" value="...">.

Return type:

str | None

netgear_switch.protocols.http.parse.parse_gambit_token(html)[source]

Scrape the GS110EMX post-login session token.

GROUNDED in gs110emx_redirect.html (a real capture): the /redirect.html POST response is an auto-submit form carrying <input type="hidden" name="Gambit" value="..."> – that value is the session identity every subsequent request must carry (as a Gambit=<token> query param on GET, or a form field on POST; see transport/http/client.py). Returns None if the page has no such field at all, and "" if it has one with an empty value (a rejected login on the virtual face) – both are falsy, so a caller doing if not token catches either shape.

Return type:

str | None

netgear_switch.protocols.http.parse.parse_port_status(html)[source]

dashboard.cgi portID rows: [2]=port,[3]=link/speed,[4]=admin,[5]=name.

Return type:

list[PortStatus]

netgear_switch.protocols.http.parse.parse_port_stats(html)[source]

portStatistics.cgi portID rows: [1]=port,[2]=rx,[3]=tx,[4]=crc.

Return type:

list[PortStats]

netgear_switch.protocols.http.parse.parse_interface_stats(html)[source]

GS110EMX interface_stats.html portID rows (real hardware shape; see _OPEN_ROW_RE): [0]=port,[1]=bytes received,[2]=bytes sent, [3]=CRC error packets.

GROUNDED in gs110emx_interface_stats.html. The page exposes no packet counts and only ONE combined error column (mapped to rx_errors, matching the same column-4 -> rx_errors convention parse_port_stats uses for gs305ep); tx_errors/rx_packets/ tx_packets are honestly None – this model’s HTTP UI never reports them.

Return type:

list[PortStats]

netgear_switch.protocols.http.parse.parse_gs110emx_port_status(html)[source]

GS110EMX port_settings.html portID rows (OPEN-row shape, see _OPEN_ROW_RE): [1]=port#, [2]=description, [3]=link Up/Down, [5]=speed text.

GROUNDED in gs110emx_port_settings.html (a real capture). name is the port description (None when blank, as it is on a factory switch). admin_enabled comes from column [4], the port’s speed/admin MODE cell (backed by the PHYSICAL_MODE hidden input): it reads Auto/a forced speed when the port is administratively enabled and Disable when it is not. Hardcoding True here – as this once did, on the false premise that the page carries no admin state – would report an admin-disabled port as enabled. NSDP genuinely cannot see admin state and reports True, so the two backends are compared only on port/link_up/speed_mbps.

Return type:

list[PortStatus]

netgear_switch.protocols.http.parse.parse_gs110emx_pvids(html)[source]

GS110EMX vlan_pvidsetting.html portID rows (OPEN-row shape): [1]=port#, [2]=PVID. GROUNDED in gs110emx_pvid.html (a real capture).

Return type:

list[tuple[int, int]]

netgear_switch.protocols.http.parse.parse_gs110emx_vlan_ids(html)[source]

GS110EMX Cf8021q.html (Advanced 802.1Q) VLAN list: each <tr class="vlanID tableTr"> row’s first <td class="def"> is the VID. GROUNDED in gs110emx_cf8021q.html (a real capture).

Return type:

list[int]

netgear_switch.protocols.http.parse.parse_gs105pe_port_status(html)[source]

GS105PE status.cgi portID rows: [1]=port, [2]=link Up/Down, [4]=speed text (No Speed/100M/1000M).

GROUNDED in gs105pe_status.html (a real capture from 10.1.5.30). Its own column layout – link at [2], mode at [3], speed at [4] – differs from BOTH gs305ep’s dashboard.cgi and gs110emx’s port_settings.html, hence its own parser. admin_enabled comes from the mode cell [3] (Auto/forced speed when enabled, Disable when not); hardcoding True would report an admin-disabled port as enabled. name is None – this page has no description column.

Return type:

list[PortStatus]

netgear_switch.protocols.http.parse.parse_gs105pe_pvids(html)[source]

GS105PE portPVID.cgi portID rows: [1]=port, [2]=PVID. GROUNDED in gs105pe_pvid.html.

Return type:

list[tuple[int, int]]

netgear_switch.protocols.http.parse.parse_gs105pe_stats(html)[source]

GS105PE portStatistics.cgi -> per-port byte/CRC counters.

The VISIBLE <td> cells are unreliable (the first counter’s cell is left empty and populated by page JS). The authoritative values are the HIDDEN inputs that follow each counter cell: three consecutive (hi, lo) pairs – Bytes Received, Bytes Sent, CRC Error Packets – each a 64-bit counter split into two 32-bit halves (hi * 2**32 + lo). Verified live on 10.1.5.30 against the NSDP counters for the same ports.

Return type:

list[PortStats]

netgear_switch.protocols.http.parse.parse_gs105pe_sysinfo(html)[source]

GS105PE switch_info.cgi -> device identity + mgmt-IP config.

GROUNDED in gs105pe_switch_info.html. Identity comes from <td>Label</td><td>value</td> rows; the mgmt IP/mask/gateway from the lowercase ip_address/subnet_mask/gateway_address inputs (NOT gs110emx’s uppercase names); DHCP from the dhcpMode select, whose <option value="1" selected> means Enable/DHCP and 0 means Disable/STATIC (verified live: this unit is DHCP, matching its NSDP read).

Return type:

HttpSysInfo

netgear_switch.protocols.http.parse.parse_cheetah_rows(html)[source]

Group a Cheetah page’s cells into one dict per row instance.

Returns rows in first-seen instance order, each mapping field NAME (from the trailing HTML comment) to its value. Empty list if the page carries no such cells – the caller decides whether that is fatal.

Return type:

list[dict[str, str]]

netgear_switch.protocols.http.parse.parse_m4300_port_status(html)[source]

M4300 portsConfiguration.html -> per-port status.

baseport_ifIndex is the port number (matching the SNMP backend’s ifIndex keying), baseinterfaceListing_Interfaces the name (1/0/1), baseport_AdminMode the admin state, baseport_LinkStatus2 the link (Link Up/Link Down) and baseport_PhysicalStatus the speed text.

Return type:

list[PortStatus]

netgear_switch.protocols.http.parse.parse_m4300_stats(html)[source]

M4300 portStatistics.html -> per-port FRAME counters.

This page reports FRAMES, not octets (basePortStats_TotalFramesRx/Tx), and the detailed page only breaks frames into size buckets – neither exposes total bytes. So rx_bytes/tx_bytes are honestly None here and the counts land in rx_packets/tx_packets; a byte-level comparison against SNMP is therefore not possible for this model, but a PACKET-level one is.

Return type:

list[PortStats]

netgear_switch.protocols.http.parse.parse_m4300_pvids(html)[source]

M4300 portPvidConfiguration.html -> (port, pvid) pairs.

Return type:

list[tuple[int, int]]

netgear_switch.protocols.http.parse.parse_m4300_vlans(html)[source]

M4300 vlanStatus.html -> VLANs with their egress member ports.

SwitchingVlanCurrentConfig_VlanCurrentEgressPortList gives the member set. This page does NOT distinguish tagged from untagged, so both tagged_ports and untagged_ports are left EMPTY rather than guessed – only member_ports is populated (see the reader’s docs).

Return type:

list[VLANInfo]

netgear_switch.protocols.http.parse.parse_m4300_macs(html)[source]

M4300 basicAddressTable.html -> the MAC/FDB table (one page).

Two real-hardware traps this deliberately refuses to fall into:

  1. The Intf cell is NOT always a physical interface – the real capture contains lag 1, vlan 1 and the 0/15/1 service port. Taking “the trailing number” (an earlier bug) reported ALL of them as physical port 1, including the switch’s own base MAC. Only unit/slot/port names yield a port; entries learned on a LAG/VLAN/service interface have no physical port and are SKIPPED rather than mis-attributed.

  2. This page is PAGINATED. It states the true table size in SwitchingFdbStats_ActiveAddrEntries (1213 on the captured switch) while rendering ~20 rows. Returning that first page as if it were the whole FDB is a silent, badly-wrong answer, so a short page RAISES and names SNMP – which returns the complete table – as the way to get it.

Return type:

list[MacEntry]

netgear_switch.protocols.http.parse.parse_m4300_sysinfo(html)[source]

M4300 sysInfo.html -> management IP + base MAC.

IPv4 Management Address is rendered as addr/netmask inside a link; System MAC Address is a plain labelled cell. The page reports no DHCP /static indicator, so mode is honestly UNKNOWN rather than guessed – which matches what the SNMP backend reports for this model.

Return type:

MgmtIpConfig

netgear_switch.protocols.http.parse.parse_m4300_sensors(html)[source]

M4300 sysInfo.html -> TEMPERATURE sensors.

The page’s Temperature block renders numeric readings as <td>MAC</td><td>53 &#8451;</td>, which map straight onto Sensor. Threshold rows in that same block (Max Operating Temperature 81) are a static datasheet LIMIT, not a reading, and are excluded – see _IS_TEMP_LIMIT_RE. Its FAN block is deliberately NOT returned: it reports a non-numeric state (Fan-1 OK) and Sensor.value is a required float – emitting a fan would mean inventing a number. The SNMP backend, which reads real fan RPM, is the honest source for fan sensors on this model.

Return type:

list[Sensor]

netgear_switch.protocols.http.parse.parse_xe_rows(html)[source]

Group an XE page’s cells into one dict per row instance.

Returns rows in first-seen (page) order, each mapping the column COORDINATE ("1_2_10") to that cell’s value. Cells with no instance prefix – the blank NAME=v_g_1_2_1 “global”/template row and page-level scalars like NAME=v_1_1_1 (Total MAC Addresses) – are deliberately NOT rows and are skipped. An empty list means the page carries no such cells; the caller decides whether that is fatal.

Return type:

list[dict[str, str]]

netgear_switch.protocols.http.parse.parse_xe_port_status(html)[source]

GSM7252PS portsConfiguration.html -> per-port status.

ifindex (column 13) is the port number, matching the SNMP backend’s ifIndex keying. Speed comes from Physical Status (column 9, the NEGOTIATED result: "1000 Mbps" / "10G Full " / "Unknown"), NOT from Physical Mode (column 8), which is the CONFIGURED mode and reads "Auto" on an auto-negotiating port. A down port’s Physical Status is "Unknown" -> speed_mbps=None.

Return type:

list[PortStatus]

netgear_switch.protocols.http.parse.parse_xe_stats(html)[source]

GSM7252PS portStatistics.html -> per-port PACKET counters.

This page carries no octet column at all (its header row lists only packet/frame counts), so rx_bytes/tx_bytes are honestly None and a BYTE-level comparison against SNMP is impossible for this model – a PACKET-level one is not. Same honest shape as parse_m4300_stats.

The 1_1_103 interface column is required, not just used: the LLDP page uses the same 1_1_* coordinate space, so requiring the column only this page has keeps a wrong page from parsing into plausible garbage.

Return type:

list[PortStats]

netgear_switch.protocols.http.parse.parse_xe_pvids(html)[source]

GSM7252PS portPvidConfiguration.html -> (port, pvid) pairs.

Uses the CONFIGURED PVID column (4), not the Current one (9). On the real capture the two disagree on the trunk-member ports 1/0/50 and 1/0/51, where Current reads 0 and Configured reads 1 – and the SAME device’s SNMP capture reports 1, i.e. dot1qPvid is the CONFIGURED value. Reading column 9 would have made the HTTP backend silently disagree with SNMP on exactly the ports a LAG makes interesting.

Return type:

list[tuple[int, int]]

netgear_switch.protocols.http.parse.parse_xe_vlans(html)[source]

GSM7252PS vlanStatus.html -> VLANs with their egress member ports.

The Member Ports cell uses the same FASTPATH egress-list syntax the M4300 does ("1/0/46 - 1/0/47, 1/0/49, lag 1, lag 2"), so _expand_port_list is shared – including its refusal to expand lag N into physical ports.

Return type:

list[VLANInfo]

netgear_switch.protocols.http.parse.parse_s3300_vlans(html)[source]

S3300-52X vlanStatus.html -> VLANs with their egress member ports.

The page shape is the sibling gsm7252ps XE vlanStatus exactly, but the Member Ports cell uses the Smart firmware’s 1/gN/1/xgN ifNames (and ranges that may mix them, "1/g48 - 1/xg52"), which the 1/0/N-only _expand_port_list reads as EMPTY. _expand_s3300_port_list expands them by trailing port number, still skipping lag N. As on the sibling, tagged/untagged are left empty (the page does not distinguish them).

Return type:

list[VLANInfo]

netgear_switch.protocols.http.parse.parse_xe_macs(html)[source]

GSM7252PS basicAddressTable.html -> the MAC/FDB table.

Two real-capture traps, both the same ones parse_m4300_macs documents:

  1. The Port cell is not always a physical interface. The capture holds 11 entries learned on lag 1 and one on the 0/5/1 service port – the latter being the switch’s OWN base MAC (status “Management”). Physical ports on this firmware are <unit>/0/<port>, so a SLOT other than 0 is a service/CPU interface: both it and lag N are skipped rather than mis-attributed to physical port 1.

  2. The page states the true table size in “Total MAC Addresses”. If it ever renders FEWER rows than that, the web UI has paginated and returning the first page as the whole FDB would be a silently-wrong answer – so that RAISES and names SNMP as the complete source. (The captured page is not paginated: 242 stated, 243 rendered.)

Return type:

list[MacEntry]

netgear_switch.protocols.http.parse.parse_s3300_macs(html)[source]

S3300-52X basicAddressTable.html -> the MAC/FDB table.

Same XE grid as gsm7252ps but with the columns SHIFTED (see the map above) and port names in the Smart firmware’s 1/gN/1/xgN form. As on the gsm7252ps and M4300 parsers, an entry whose port is not a physical interface is SKIPPED rather than mis-attributed: the switch’s OWN base MAC is learned on the CPU interface (rendered c1, status “Management”), which _xe_port_from_iface does not resolve to a physical port. SNMP reports that same base MAC on the CPU ifIndex, so the HTTP FDB (physical entries only) differs from the SNMP FDB by exactly that one management entry – the same base-MAC omission parse_xe_macs makes.

Refuses a paginated (truncated) table rather than returning a partial FDB, exactly like the sibling parsers.

Return type:

list[MacEntry]

netgear_switch.protocols.http.parse.parse_s3300_mgmt(html)[source]

S3300-52X sysInfo.html -> base MAC ONLY (no IPv4 address).

This page really does carry only the switch’s Base MAC Address (a labelled cell, aid="1_16_1_right") – but the CONCLUSION that used to be drawn from that was wrong. It said the IPv4 management address “lives on a JS-menu-only page this backend cannot reach”, so get_mgmt_ip returned UNKNOWN mode with a None address. Live 2026-07-30 on 10.1.5.11: GET /ipConfiguration.html answers 200 with the real address, mask, gateway and method. This parser is therefore now used ONLY for the base MAC (uppercased, to match the SNMP/NSDP dot1dBaseBridgeAddress formatting), which that page does not carry – see http_read._fastpath_base_mac.

Return type:

MgmtIpConfig

netgear_switch.protocols.http.parse.parse_xe_poe(html)[source]

GSM7252PS poeInterfaceConfiguration.html -> per-port PoE status.

power_mw is the “Output Power” column, normalised to milliwatts by _poe_power_to_mw so it matches the vendor mW OID the SNMP backend reads (gsm7252ps renders integer mW, the M4300-16X renders decimal watts – see that helper). The Status column’s text is matched against the shared _DETECT_TEXT vocabulary; the captured values are “Delivering power”, “Searching” and “Other Fault” (the last -> FAULT, where SNMP’s numeric detect map has no code and honestly reports UNKNOWN).

Return type:

list[PoEStatus]

netgear_switch.protocols.http.parse.parse_xe_lldp(html)[source]

GSM7252PS lldpRemoteInventory.html -> LLDP neighbours.

remote_port_desc is honestly None for every neighbour: this page has no such column (SNMP’s lldpRemPortDesc is the source for it). The captured neighbour set matches the same device’s SNMP capture on chassis ID for every shared port.

An LLDP table with no rows is LEGITIMATELY empty (a switch may simply have no neighbours), so this returns [] rather than raising – but a page that is not this page at all (no 1_1_1 local-interface cells anywhere) still raises.

Return type:

list[LLDPNeighbor]

netgear_switch.protocols.http.parse.parse_xe_labelled_values(html)[source]

sysInfo.html -> {label: first value cell} for every bold-labelled row (identity, mgmt IP, and the first UNIT column of the status tables).

Returns {} for a page with no such rows – the caller decides whether that is fatal (parse_xe_mgmt_ip does; parse_xe_sensors does not, since it reads the status tables through _xe_status_rows instead).

Return type:

dict[str, str]

netgear_switch.protocols.http.parse.parse_xe_sensors(html)[source]

GSM7252PS sysInfo.html -> box sensors.

Three blocks, all present in the static HTML of the committed capture:

  • Temperature Status – real numeric readings (29&degC) per stack unit. A sensor reading N/A (the MAC row on the captured switch) is absent, not 0, and is skipped.

  • FAN Status – OK/NA per fan. Reported as unit="state" health flags, never as RPM (see _XE_HEALTHY_TEXT).

  • Device Status – only the RPS and Power Module rows, as kind="power" state flags; the firmware/serial rows in that same table are identity, not sensors.

Returns [] for a page with none of those tables; the caller decides.

Return type:

list[Sensor]

netgear_switch.protocols.http.parse.parse_xe_mgmt_ip(html)[source]

GSM7252PS sysInfo.html -> management IP + base MAC.

IPv4 Network Interface renders as addr/netmask inside a link to ipConfiguration.html; System MAC Address is a plain labelled cell. The page reports neither a gateway nor a DHCP/static indicator, so those stay None/UNKNOWN rather than guessed – which is exactly what the SNMP backend reports for this same device (see tests/fixtures/captures/gsm7252ps.json).

Return type:

MgmtIpConfig

netgear_switch.protocols.http.parse.SERVICE_NAMES: tuple[str, ...] = ('http', 'https', 'ssh', 'telnet')

The order get_services reports in, matching the CLI backend’s.

netgear_switch.protocols.http.parse.parse_service_page(html, service)[source]

One service’s config page -> its ServiceStatus.

Tries the XUI coordinate, then the plain-form radio group. Raises HttpUnexpectedPageError when NEITHER is present rather than reporting the service as disabled – a page that does not carry the control (the S3300’s httpConfiguration.html genuinely does not) says nothing about whether the service is running, and a login redirect says nothing either.

Return type:

ServiceStatus

netgear_switch.protocols.http.parse.parse_xui_users(html)[source]

userManagement.html -> the switch’s local login accounts.

Raises HttpUnexpectedPageError if the page carries no user rows. A switch always has at least the account this very request authenticated as, so an empty list means the fetch landed somewhere else – “this switch has no accounts” is not an answer any device would give.

Return type:

list[SwitchUser]

netgear_switch.protocols.http.parse.SYSLOG_HOST_ROW_STATUS = '2_1_5'

The cell an ADD sets to “Active” and a DELETE sets to “Delete”. NOT 2_1_2, which is the read-only mirror the table displays.

netgear_switch.protocols.http.parse.SYSLOG_HOST_INDEX = '2_1_6'

“Host Index” – the table’s own row handle, which SNMP walks as the OID instance and the CLI prints in its Index column. Surfaced so all three backends report the same SyslogServer for the same row; without it the cross-backend equivalence test fails on index=None vs index=1.

netgear_switch.protocols.http.parse.parse_xui_syslog(html)[source]

syslogConfiguration.html -> SyslogConfig (all FASTPATH models).

The collector rows are ordinary XUI table rows, so parse_xe_rows groups them; the admin status and local port are page-level scalars, read by coordinate above. A row whose coordinates are absent is skipped rather than defaulted – the blank g_2_1_* template row carries no instance prefix and never reaches here in the first place.

Raises HttpUnexpectedPageError if the admin-status scalar is missing: that is the one field every version of this page has, so its absence means the fetch landed somewhere else (a login redirect, a 404 body) and an enabled=False answer would be a fabrication.

Return type:

SyslogConfig

netgear_switch.protocols.http.parse.parse_poe_status(html)[source]

getPoePortStatus.cgi portID rows: [1]=port,[2]=state,[3]=power_mw.

Return type:

list[PoEStatus]

netgear_switch.protocols.http.parse.parse_pvids(html)[source]

portPVID.cgi rows: sel="text" cell = port, sel="input" cell = PVID.

Return type:

list[tuple[int, int]]

netgear_switch.protocols.http.parse.parse_vlan_ids(html)[source]

8021qCf.cgi VLAN checkboxes: name="vlanckN" value="VID".

Return type:

list[int]

netgear_switch.protocols.http.parse.parse_selected_vlan(html)[source]

8021qMembe.cgi selected VLAN in the dropdown.

Return type:

int | None

netgear_switch.protocols.http.parse.parse_membership(html, port_count)[source]

8021qMembe.cgi hiddenMem string: per-port 1=Untagged/2=Tagged/3=Excluded.

Return type:

dict[int, VlanMode]

netgear_switch.protocols.http.parse.parse_fastpath_err(html)[source]

The FASTPATH page’s own error banner, or None when it reports success.

Every one of these pages carries a hidden err_flag/err_msg pair, and its check_error() handler alerts the err_msg when err_flag == 1. The page still returns HTTP 200, so this is the ONLY signal that the switch refused the write. Returns the message (falling back to a generic string when the firmware sets the flag but leaves the text empty) so the caller can surface exactly what the device said.

Return type:

str | None

netgear_switch.protocols.http.parse.parse_fastpath_membership(html)[source]

FASTPATH switching/dot1q/vlan_port_cfg.html -> one VLAN’s membership.

See types.FastpathMembership for what the two views mean and why they can legitimately differ. Raises HttpUnexpectedPageError if the page is not this page (no _rw.html form, no hiddenMem, no port grid) or if it carries a wire code / grid state this parser does not know – never a silently partial result.

Return type:

FastpathMembership

netgear_switch.protocols.http.parse.fastpath_hidden_mem_with(page, port, mode)[source]

page.hidden_mem with just port’s code replaced by mode.

Every other slot – including the LAG pseudo-interfaces the library does not model – is preserved VERBATIM from what the device rendered, so an apply cannot silently rewrite an interface the caller never mentioned. (The same reasoning as the SNMP writer preserving the device’s own PortList width.) Raises HttpUnexpectedPageError if the page never rendered port.

Return type:

str

netgear_switch.protocols.http.parse.parse_xui_list_page(html, *, page='XUI list page')[source]

A FASTPATH XUI table page -> its write form + one XuiRow per row.

Raises HttpUnexpectedPageError when the write form is missing. An EMPTY row tuple is NOT an error and is not swallowed either – it is a real, meaningful answer that the caller interprets: the M4300-24X genuinely has no PoE, and its /v1/poeInterfaceConfiguration.html proves it with an HTTP 200 of 28152 bytes carrying the correct <TITLE>NETGEAR -  PoE Port Configuration</TITLE>, the full button set and ZERO <TR p="..."> rows (live 2026-07-30 on 10.1.5.13). A 404 would have been a missing page; this is a present page with no PSE ports.

Return type:

XuiListPage

netgear_switch.protocols.http.parse.parse_xui_form_page(html, *, page='XUI page')[source]

A FASTPATH XUI detail page -> its write form’s flat field map.

Return type:

XuiFormPage

netgear_switch.protocols.http.parse.parse_xui_mgmt_ip(
html,
*,
address_field,
netmask_field,
gateway_field,
mode_field,
page='XUI management-IP page',
)[source]

A FASTPATH XUI management-IP page -> MgmtIpConfig (without base MAC).

Field names are passed in rather than assumed: the two Cheetah families put the same four values under different names, and one of those names means different things on two switches of the SAME family – see endpoints.XuiMgmtIpFields. base_mac is left None here because neither family’s mgmt page carries the switch’s BASE MAC (gsm7228ps’s page has no MAC row at all; the M4300’s v_4_4_1 is the management interface’s MAC, one off from the base MAC SNMP reports) – the reader merges it from sysinfo_path.

Return type:

MgmtIpConfig

netgear_switch.protocols.http.parse.parse_gs110emx_port_form_fields(html)[source]

{port: {field: value}} from port_settings.html’s per-port rows.

Used to echo a port’s CURRENT FLOW_CONTROL_MODE back on an admin-mode apply, exactly as the page’s JS does – inventing a value there would silently rewrite the port’s flow control as a side effect of enabling it.

Return type:

dict[int, dict[str, str]]

netgear_switch.protocols.http.parse.parse_reboot_ok(html)[source]

A reboot response that does not contain an error banner.

Return type:

bool

netgear_switch.protocols.http.parse.parse_sysinfo(html)[source]

GS110EMX sysInfo.html -> device identity + mgmt-IP config.

GROUNDED in gs110emx_sysinfo.html (a real capture) – see HttpSysInfo for field provenance, including the ip_mode data-select-value inference. Raises HttpUnexpectedPageError naming whichever field(s) are missing rather than fabricating a partial result – this page is never legitimately missing any of these on a real switch.

Return type:

HttpSysInfo

netgear_switch.protocols.http.parse.parse_goahead_ports(body)[source]

GS728TPP Standard802_3List -> per-port status.

Only physical g<n> ports are returned; the page also lists LAG aggregations (LAG1..), which are not ports. speed_mbps is the negotiated speedOper while the link is up, and honestly None on a down port (whose speedOper still reports the configured rate).

duplexOperMode and flowControlOperType are decoded against SNMP rather than against a guess – see _GOAHEAD_DUPLEX_OPER and _GOAHEAD_FLOW_CONTROL.

Return type:

list[PortStatus]

netgear_switch.protocols.http.parse.parse_goahead_pvids(body)[source]

GS728TPP VLANInterfaceList -> (port, pvid) pairs (physical only).

Return type:

list[tuple[int, int]]

netgear_switch.protocols.http.parse.parse_goahead_vlan_names(body)[source]

GS728TPP VLANList -> {vlan_id: name or None}.

Return type:

dict[int, str | None]

netgear_switch.protocols.http.parse.parse_goahead_port_vlan_membership(body)[source]

GS728TPP VLANInterfaceList -> {vlan_id: (tagged, untagged)}.

Built from each physical port’s inline JoinVLANList (taggingMode 1=untagged, 2=tagged), which carries the complete per-port membership – so no separate per-VLAN membership request is needed.

Return type:

dict[int, tuple[frozenset[int], frozenset[int]]]

netgear_switch.protocols.http.parse.parse_goahead_vlans(vlans_body, membership_body)[source]

GS728TPP VLANs: VLANList names + per-port VLANInterfaceList membership -> full VLANInfo list (member/tagged/untagged sets).

Return type:

list[VLANInfo]

netgear_switch.protocols.http.parse.parse_goahead_macs(body)[source]

GS728TPP ForwardingTable -> the dynamic MAC/FDB table.

Only entries learned on a physical g<n> port are returned; a LAG aggregation carries no port number and is skipped rather than mis-attributed. An empty table is legitimate (a freshly-booted switch).

Return type:

list[MacEntry]

netgear_switch.protocols.http.parse.parse_goahead_poe(body)[source]

GS728TPP PoEPSEInterfaceList -> per-port PoE status.

power_mw is outputPower (the live draw, mW) and detect maps the detectionStatus wire code; the Test code (5) has no RFC3621 detect equivalent and reads UNKNOWN rather than being invented.

Return type:

list[PoEStatus]

netgear_switch.protocols.http.parse.parse_goahead_lldp(body)[source]

GS728TPP LLDPMEDNeighborList -> LLDP neighbours.

An empty neighbour list is LEGITIMATE (a switch with no neighbours), so this returns [] rather than raising; _goahead_section still raises if the whole section is absent (wrong page). Chassis/port-id MACs are canonicalized to upper-case (_canon_lldp_id) so they equal the SNMP reader’s formatting exactly.

Return type:

list[LLDPNeighbor]

netgear_switch.protocols.http.parse.parse_goahead_sensors(body)[source]

GS728TPP DiagnosticsUnitList -> box sensors.

Fans and PSUs report a health STATUS code (1=OK), not RPM/watts, so they are emitted as unit="state" flags (1.0 healthy, 0.0 any other reported state); an absent slot (status 5) is skipped. tempSensorValue is emitted as a numeric temperature only when it is a positive reading – a 0 with tempSensorStatus 2 (this unit’s captured value) is not a real reading and is not fabricated as 0 C. SNMP remains the source of real fan RPM / PSU watts on this model.

Return type:

list[Sensor]

netgear_switch.protocols.http.parse.parse_goahead_base_mac(body)[source]

GS728TPP SystemInfo (DeviceBasicInfo) -> the switch’s base MAC.

DeviceBasicInfo/MacAddre carries the switch’s own base MAC (e.g. "b0:39:56:77:54:29"). Uppercased to match the SNMP dot1dBaseBridgeAddress / NSDP identity-MAC formatting (see models.MgmtIpConfig.base_mac), so the HTTP and SNMP mgmt-IP reads agree field-for-field. The IPConf page has no MAC row, so get_mgmt_ip reads this from the separate SystemInfo page. Absent -> None (never fabricated).

Return type:

str | None

netgear_switch.protocols.http.parse.parse_goahead_hostname(body)[source]

GS728TPP SystemInfo (DeviceBasicInfo) -> the switch’s host name.

DeviceBasicInfo/deviceName is the host name, not merely a cosmetic label: MEASURED on the live switch (10.2.5.10, firmware 6.0.1.30, 2026-08-03) it reads sw-netgear-gs728tpp, byte-for-byte what SNMP reports through sysName.

Returns the raw value including "". An empty name is a REAL state on a switch that has never been named, so it must not be turned into None, which the caller would read as “this backend cannot tell you”.

Return type:

str

netgear_switch.protocols.http.parse.parse_goahead_mgmt_ip(body)[source]

GS728TPP IPConf_master.xml -> management IP + gateway.

IPv4InterfaceList/ifEntry carries the address/netmask (on the mgmt VLAN interface) and IPv4GatewayList/GWEntry the default gateway. The page carries no DHCP/static indicator and no base MAC (that is on the SystemInfo page), so mode is UNKNOWN and base_mac is None rather than guessed.

Return type:

MgmtIpConfig

Pure (I/O-free) web-UI write-form encoders.

Field names/values are GROUNDED against py_netgear_plus GS30xSeries get_switch_poe_port_data/get_power_cycle_poe_port_data and rcfiles/bin/netgear-smp-vlan (8021q/PVID forms). Each op requires the page’s CSRF hash (scraped just before the POST by the writer).

netgear_switch.protocols.http.forms.poe_apply_form(*, port, on, is_epx, csrf_hash)[source]
Return type:

dict[str, str]

netgear_switch.protocols.http.forms.poe_reset_form(*, port, csrf_hash)[source]
Return type:

dict[str, str]

netgear_switch.protocols.http.forms.pvid_form(*, port, vlan, csrf_hash)[source]
Return type:

dict[str, str]

netgear_switch.protocols.http.forms.membership_hidden_mem(states, port_count)[source]
Return type:

str

netgear_switch.protocols.http.forms.membership_form(*, vlan, hidden_mem, csrf_hash)[source]
Return type:

dict[str, str]

netgear_switch.protocols.http.forms.fastpath_membership_form(page, *, vlan, hidden_mem=None, apply=False)[source]

The POST body for the FASTPATH VLAN Membership page.

Starts from page.fields – every field the device itself rendered, verbatim – so nothing the browser sends is dropped (the M4300-16X rejects a POST that omits its per-page CSRFToken with 403 Forbidden) and nothing is invented. Only the four fields the browser’s own handlers touch are overridden:

  • vlanId – which VLAN to show/apply (the <select>’s value).

  • hiddenTagged/hiddenUnTagged – CLEARED, exactly as the firmware’s screen_refresh() and resethidden() do before submitting. They are OUTPUT fields (the device re-renders them); echoing stale values back is not what the browser does.

  • submt – "16" to apply, "0" for a read-only re-render.

hidden_mem overrides the membership codes (use parse.fastpath_hidden_mem_with); None keeps what the page rendered, which is required for a read (posting a DIFFERENT VLAN’s codes with submt=0 is precisely what the browser does when you pick another VLAN, and the firmware ignores them).

Return type:

dict[str, str]

netgear_switch.protocols.http.forms.xui_row_apply_form(page, row, changes, *, button, omit=())[source]

The POST body that applies changes to exactly ONE row of an XUI list.

Only that row’s fields are sent (plus its gecb checkbox, the page’s tokens, its list-navigation block, the form’s redirection block and the clicked button). That is deliberately NARROWER than a browser, which submits every row’s hidden inputs and lets the firmware apply only the checked ones – and it is narrower for a safety reason, not a convenience one: a body that never mentions the other 51 ports cannot change them even if a firmware ignored the checkboxes. LIVE-PROVEN on all four managed switches 2026-07-30: after this exact body, re-reading the whole table showed the target row’s cell changed and EVERY other cell of every other row byte-identical.

page.nav IS sent, and that is not decoration – it is the difference between a write that lands and one the firmware refuses. LIVE 2026-07-30/31 on gsm7252ps 10.1.5.22, port 1/0/35 (link-down, undescribed), the PoE apply answered HTTP 200 + err_flag=1 with one Error! Failed to Set '<column>' with '<value>' line per read-write column – even for a body that changed nothing – until the page’s own urlListUnit field rode along. Adding v_1_1_1 alone, or v_1_3_1 alone (the page aliases them: xeData["xalias_urlListUnit"] = "1_1_1|1_3_1|3_1_1|3_4_1"), made the identical write succeed; adding only the v_1_1_2 type filter did NOT. See XuiListPage.nav.

changes is keyed by bare column ("v_1_2_6"); the row’s own <unit>.<row0>.<count>. prefix is prepended here so a caller can never address the wrong row. A column the row does not render raises rather than being silently added – that would be writing a field the device never offered.

omit drops the named bare columns from this row’s echoed fields, for the columns the clicked BUTTON disables. These pages carry per-button shed lists in their own metadata – xeData.xa_<button>[14] is the “disable” set, and xuiShed(2, ...) sets disabled=true on each, so a browser never submits them for that button. A column the row does not render is ignored (models differ in which columns exist), because omit says “do not send this”, not “this must be here”.

Return type:

dict[str, str]

netgear_switch.protocols.http.forms.xui_form_apply_form(page, changes, *, button)[source]

The POST body that applies changes to an XUI detail page.

Starts from every field the device rendered – so the M4300-16X’s per-page CSRFToken (whose absence it answers with 403 Forbidden) rides along without this builder having to know about it – and overrides only the named fields. An unknown field raises rather than being invented.

Return type:

dict[str, str]

netgear_switch.protocols.http.forms.gs110emx_port_admin_form(*, port, enabled, flow_control_mode)[source]

The GS110EMX port-admin POST body (the Gambit token is added by the transport, exactly as it is for every other request on this model).

flow_control_mode is echoed from the port’s OWN row rather than defaulted – the page always sends it, so omitting it (or guessing) would rewrite the port’s flow control as a side effect of an admin-mode change.

Return type:

dict[str, str]

netgear_switch.protocols.http.forms.vlan_add_form(*, vlan, csrf_hash)[source]
Return type:

dict[str, str]

netgear_switch.protocols.http.forms.vlan_delete_form(*, vlan, checkbox_index, csrf_hash)[source]
Return type:

dict[str, str]

netgear_switch.protocols.http.forms.reboot_form(*, csrf_hash)[source]
Return type:

dict[str, str]

netgear_switch.protocols.http.forms.EMX_DHCP_ON = '1'

1 = Enable (DHCP), 2 = Disable (static). Read off the live page’s own <select name="dhcp_mode">, whose current value the page carries as <tr data-select-value="N"> – the options themselves have no selected attribute, so it is the row attribute that says which one is in force.

Type:

dhcp_mode on the GS110EMX sysInfo page

netgear_switch.protocols.http.forms.gs110emx_switch_info_form(
*,
switch_name,
dhcp_mode,
ip_address,
subnet_mask,
gateway_address,
)[source]

The GS110EMX sysInfo POST body – the WHOLE form, per the page’s own JS.

Transcribed from submitSwitchInfoForm() in the switch’s /function.js (read live from 10.1.5.27, 2026-08-05), which validates the name, then:

form1.elements["ACTION"].value = "Apply";
form1.submit();

– an ordinary whole-form POST, with ACTION the only field the script itself sets. Note the capital “Apply” here versus the lowercase “apply” the port-admin page sends; both spellings appear in that file, per page.

EVERY OTHER FIELD MUST BE ECHOED FROM THE PAGE. This one form carries the management addressing as well as the name, so a caller who omits or guesses dhcp_mode/IP_ADDRESS/SUBNET_MASK/GATEWAY_ADDRESS does not merely fail to rename the switch – it reconfigures the address it is talking to and strands the device. That is why this builder takes all of them and has no defaults.

The Gambit session token is added by the transport, as for every other request on this model.

Return type:

dict[str, str]

netgear_switch.protocols.http.forms.xui_row_add_form(page, values, *, status_column, button)[source]

The POST body that ADDS a row, by filling the page’s template row.

values is keyed by BARE column ("2_1_1"); the v_g_ prefix is added here so a caller cannot address a data row by mistake. A column the template does not render raises rather than being invented – the template row is the device’s own declaration of which columns a new row has.

status_column is the write-only row-status cell ("2_1_5" on the syslog page); it is set to Active, which is what the page’s Apply action array writes. The whole template row is echoed, including the columns the caller did not set, because the firmware renders them all and a body that dropped them would be submitting a different row than the page describes.

Return type:

dict[str, str]

netgear_switch.protocols.http.forms.xui_row_delete_form(page, row, *, status_column, button)[source]

The POST body that DELETES one row, by marking its row-status cell.

Same envelope as xui_row_apply_form – only this row’s fields, plus its checkbox – with the write-only row-status set to Delete and the page’s Delete button clicked.

Return type:

dict[str, str]

Pure (I/O-free) builders for GoAhead wcd XML write bodies.

The GS728TPP web UI is not an HTML form UI: every page reads through GET wcd?{file=...}{Object} and writes through a single POST wcd whose body is an XML document. Its site map has exactly one POST target – wcd – repeated for all 100-odd pages, so the object name and the action verb in the body, not the URL, are what select the operation.

The wire shape is GROUNDED, not inferred. Each page’s own JavaScript builds a post object and the framework serialises it; two of those builders were captured verbatim from the live switch (10.2.5.10, firmware 6.0.1.30):

Switching/VLAN/VlanMembership_jq.htm:

post.VLANMembershipList['set'] = [{VLAN: {VLANID: "5",
    MembershipList: [{VLANMember: {interfaceName: "g17",
        interfaceType: "1", membershipType: "2", taggingMode: "2"}}]}}]
post.VLANMembershipList['delete'] = [{VLAN: {VLANID: "5",
    MembershipList: [{VLANMember: {interfaceName: "g17",
        interfaceType: "1"}}]}}]

Switching/Ports/portConfiguration_master_jq.htm:

post.Standard802_3List = {set: [{Entry: {interfaceName: ..., ...}}]}

and the library’s own _build_gs728tpp_cert_xml – whose envelope came from the certbot hook that works against real GS728TPPs – serialises the same structure as:

<DeviceConfiguration><SSLCryptoCertificateImportList action="set">
  <Entry>...</Entry></SSLCryptoCertificateImportList></DeviceConfiguration>

So the rule is: the JS object key becomes the element name, the set/ delete key becomes the action attribute on the object element, and each list entry is one repeated child element.

netgear_switch.protocols.http.goahead.INTERFACE_PHYSICAL = '1'

interfaceType in every VLAN/port object – 1 = physical port, 2 = LAG. (No : after the name, deliberately. Napoleon reads a ONE-LINE #: comment shaped x: y as “type x, described as y”, which rendered a nonsense “Type: interfaceType in every VLAN/port object” field on the page and failed the nitpicky build. The two-line comment below is unaffected.)

netgear_switch.protocols.http.goahead.TAGGING_TAGGED = '2'

taggingMode, from the membership page’s own “Group Operation” select: “2” Tag All, “1” Untag All, “0” Remove All.

netgear_switch.protocols.http.goahead.write_body(obj, action, children)[source]

Render one POST wcd body.

obj is the page’s object name (VLANList, PoEPSEInterfaceList, …), action the verb the page’s JS used as the key (set or delete), and children the repeated child elements, each a single-key mapping whose key is the element name.

Return type:

str

netgear_switch.protocols.http.goahead.port_interface_name(port)[source]

17 -> "g17", the interfaceName every wcd object keys on.

The inverse of parse._goahead_port_num, which is what the read side already relies on: the live switch names its 28 physical ports g1.. g28 and its LAGs LAG1.. – so a name that does not match this shape is not a physical port at all. Kept beside the builders that use it, rather than formatted inline at each call site, so the convention has one definition on the write side too.

Return type:

str

netgear_switch.protocols.http.goahead.tagging_mode(mode)[source]

The page’s taggingMode code for a library VlanMode.

Return type:

str

netgear_switch.protocols.http.goahead.vlan_membership_body(vlan, port_name, mode)[source]

One port’s membership of vlan.

EXCLUDED is not a set with taggingMode 0 – the page routes it to a separate delete action carrying only the interface identity, with no membershipType/taggingMode. That asymmetry is the page’s, and reproducing it is the difference between removing a port and setting it to a mode the firmware does not have.

Return type:

str

netgear_switch.protocols.http.goahead.poe_admin_body(port_name, enabled)[source]

PoE admin state, via PoEPSEInterfaceList.

adminEnable 1 = enabled, 2 = disabled – the same codes the READ side already decodes from this object.

Note what is NOT here: this UI has no PoE reset/power-cycle control at all. Behaviour/UnitsPoe.js contains no reset, cycle or reboot action, and the page’s only buttons are Refresh/Cancel/Apply. A power cycle over HTTP is therefore an admin off-then-on re-arm of this same field – exactly what SnmpWriter does on models whose agent has no reset column either.

Return type:

str

netgear_switch.protocols.http.goahead.vlan_create_body(vlan, name)[source]

Create one VLAN, via VLANList.

There is no “add” verb on this UI: the framework (js/home.js) defines exactly ACTION_SET=”set”, ACTION_DELETE=”delete” and ACTION_RESTORE= “restore”, and createPostXml stamps a NEW row with ACTION_SET like any other edit. So creating and editing a VLAN are the same request shape.

The switch’s own page rejects ids outside 2-4093 (VlanConfig .checkValidVLANId), which is narrower than the 1-4094 the protocol allows – VLAN 1 is the default VLAN and cannot be created.

Return type:

str

netgear_switch.protocols.http.goahead.vlan_delete_body(vlan)[source]

Delete one VLAN, via VLANList.

The shape is taken verbatim from VlanConfig.Reset, which posts a literal string rather than building it through the framework – so it states the delete envelope exactly:

<DeviceConfiguration><VLANInterfaceList action="restoreAll"/>
  <VLANList action="delete"><VLAN><VLANID>4-4093</VLANID></VLAN>
</VLANList></DeviceConfiguration>

(That page-level “restore everything” is deliberately NOT reproduced here: this deletes the one VLAN it was asked to, and nothing else.)

Return type:

str

netgear_switch.protocols.http.goahead.pvid_body(port_name, vlan)[source]

One port’s PVID, via VLANInterfaceList – the object the read side already parses PVIDs and per-port membership out of.

The page’s own validation allows 1-4093 or 4095, and rejects 4094 explicitly (PortPVID.Apply).

Return type:

str

netgear_switch.protocols.http.goahead.port_config_body(port_name, port_id, *, admin_enabled=None, description=None)[source]

Port admin state and/or description, via Standard802_3List.

The page sends adminState 1 (up) / 2 (down) and omits every field the operator did not change – its JS sets them to undefined, which the serialiser drops – so this builder emits only what it is asked to change.

description is interfaceDescription, the same element the read side already parses. An EMPTY string is a real value here (it clears the label), which is why the parameter defaults to None for “leave alone” rather than using “” as the sentinel.

Return type:

str

netgear_switch.protocols.http.goahead.GOAHEAD_FORCED_SPEEDS: frozenset[tuple[int, bool]] = frozenset({(10, False), (10, True), (100, False), (100, True), (1000, True)})

The speed/duplex choices this UI offers, READ OFF the page’s own slctPortSpeed <option> list (captured in tests/fixtures/http/gs728tpp_ports.xml):

10H  10M Half Duplex      100H 100M Half Duplex     0 Auto
10F  10M Full Duplex      100F 100M Full Duplex
                          1000F 1000M Full Duplex

Two things fall out that a guess would have got wrong. This UI DOES offer a forced 1000 – unlike the FASTPATH CLI, whose grammar omits it – which is why the forced-1000 refusal lives in the CLI writer and not in PortSpeed. And there is no 1000H: gigabit half-duplex is not a thing the page will let an operator ask for, so neither will this builder.

netgear_switch.protocols.http.goahead.DUPLEX_ADMIN_FULL = '3'

3 = full, 2 = half (duplexAdmin = (duplexCode == "H") ? "2" : "3"). Deliberately NOT the same enum as duplexOperMode on the read side, where 2 means full – see parse._GOAHEAD_DUPLEX_OPER.

Type:

duplexAdminMode as the page’s SUBMIT path writes it

netgear_switch.protocols.http.goahead.AUTONEG_ON = '1'

1 = negotiating, 2 = forced.

Type:

autoNegotiationAdminEnabled

netgear_switch.protocols.http.goahead.port_speed_body(port_name, port_id, speed)[source]

Port speed/duplex via Standard802_3List, exactly as the page sends it.

Transcribed from the submit builder in the page’s own JS, which turns one dropdown value into three elements:

var autoNegAdmin = (speedAdmin == "0") ? "1" : "2";
if (speedAdmin == "0") duplexAdmin = "3";
else { duplexAdmin = (last char == "H") ? "2" : "3";
       speedAdmin = parseInt(speedAdmin, 10); }

So AUTO sends autoNegotiationAdminEnabled=1, speedAdmin=0, duplexAdminMode=3 – note it sends a speed of 0 rather than omitting the field – and a forced choice sends autoNegotiationAdminEnabled=2 with the parsed rate and the duplex code.

Return type:

str

Pure Netgear web-UI login crypto (no I/O).

The Plus family authenticates with md5(merge(password, rand)) where rand is a per-page nonce scraped from the login form and merge interleaves the two strings character by character. GROUNDED against rcfiles/bin/netgear-smp-vlan and py_netgear_plus/netgear_crypt.py.

netgear_switch.protocols.http.crypt.merge(str1, str2)[source]

Interleave two strings character by character (Netgear login scheme).

Return type:

str

netgear_switch.protocols.http.crypt.merge_hash_md5(password, rand)[source]

Return md5(merge(password, rand)) as lowercase hex (Plus login hash).

Return type:

str

Transport-agnostic web-UI session seam (Protocols only, no I/O).

Both the sync (httpx.Client) and async (httpx.AsyncClient) transports implement these. Readers/writers depend only on these three methods, so the pure protocol layer is the single shared codebase across sync and async.

class netgear_switch.protocols.http.session.MultipartFile(field, filename, content, content_type)[source]

Bases: object

One file part of a multipart/form-data POST (see post_multipart).

Used by the SSL-certificate upload flow: field is the form field name the switch expects the file under (e.g. gsm7228ps’s .v_1_3_1_handle), content is the raw file bytes served as filename with MIME content_type (e.g. application/octet-stream).

field: str
filename: str
content: bytes
content_type: str
class netgear_switch.protocols.http.session.HttpSession(*args, **kwargs)[source]

Bases: Protocol

Synchronous authenticated web-UI session for one switch.

login()[source]
get_page(path)[source]
Return type:

str

post_form(path, data)[source]
Return type:

str

post_multipart(path, data, file)[source]
Return type:

str

post_xml(path, body)[source]
Return type:

str

class netgear_switch.protocols.http.session.AsyncHttpSession(*args, **kwargs)[source]

Bases: Protocol

Asynchronous authenticated web-UI session for one switch.

async login()[source]
async get_page(path)[source]
Return type:

str

async post_form(path, data)[source]
Return type:

str

async post_multipart(path, data, file)[source]
Return type:

str

async post_xml(path, body)[source]
Return type:

str

HTTP-only device-info types that don’t fit the shared cross-backend models module (mirrors protocols/nsdp/types.py::NsdpDevice – a backend-specific read shape lives next to the protocol that produces it, not in models.py, until/unless a second backend needs the same shape).

class netgear_switch.protocols.http.types.FastpathMembership(
vlan_id,
vlan_ids,
name,
vlan_type,
tagged_ports,
untagged_ports,
hidden_mem,
port_slots,
configured,
fields,
action,
)[source]

Bases: object

One render of the FASTPATH “VLAN Membership” page (switching/dot1q/vlan_port_cfg.html -> ..._rw.html).

LIVE-DISCOVERED 2026-07-30 on all four managed switches (gsm7252ps 10.1.5.22, gsm7228ps/S3300-52X 10.1.5.11, m4300-24x 10.1.5.13, m4300-16x 10.1.5.20:49152) – see parse.parse_fastpath_membership and the fixtures tests/fixtures/http/{gsm7252ps,gsm7228ps,m4300,m4300_16x}_ vlanPortCfg_*.html. The page carries TWO different views of the same VLAN, and the difference is real, not noise:

tagged_ports/untagged_ports come from the page’s own hiddenTagged/hiddenUnTagged ifName lists, which are the CURRENT (operational) egress lists – byte-for-byte what show vlan <id> reports under Current: Include and what vlanStatus.html’s Member Ports cell lists. Their union therefore equals member_ports exactly.

configured comes from hidden_mem, the tri-state code the page SUBMITS, and is the CONFIGURED participation – what show vlan reports under Configured and what SNMP’s dot1qVlanStaticEgressPorts returns. These two views genuinely disagree on real hardware: on gsm7252ps VLAN 1, ports 1/0/50 and 1/0/51 are Current: Exclude / Configured: Include, so they appear in configured (and in SNMP’s static egress) but NOT in untagged_ports (nor in the CLI’s current list). Reads therefore report the current view (consistent with member_ports), while HttpWriter.set_vlan_membership writes and verifies the configured view – the only one the form can actually set.

fields is every form field the page rendered, verbatim, so a re-POST can be byte-faithful to what the browser sends instead of a guessed subset (the M4300-16X, for one, refuses a POST that drops its per-page CSRFToken).

vlan_id: int | None
vlan_ids: tuple[int, ...]
name: str | None
vlan_type: str | None
tagged_ports: frozenset[int]
untagged_ports: frozenset[int]
hidden_mem: str
port_slots: Mapping[int, int]
configured: Mapping[int, VlanMode]
fields: Mapping[str, str]
action: str
class netgear_switch.protocols.http.types.XuiRow(prefix, checkbox, fields)[source]

Bases: object

One repeating row of a FASTPATH “XE”/Cheetah XUI list page.

These pages (portsConfiguration.html, poeInterfaceConfiguration.html, basicAddressTable.html …) render every cell as a hidden input whose NAME is <unit>.<row0>.<count>.v_1_2_<column> – e.g. 1.35.52.v_1_2_6 is column 6 of the 36th row of a 52-row table on unit 1. prefix is that <unit>.<row0>.<count>. string, taken verbatim from the device (never computed from the port number: the row order is the device’s, and the count is the rendered row count, not the model’s port count – the PoE page of a 52-port switch has 48 rows).

checkbox is the row’s own gecb* selector, whose NAME differs per firmware (1.0.52.gecb5 on gsm7252ps, 1.0.52.gecb10 on gsm7228ps, 1.0.24.gecb_1_2 on the M4300s) – so it is scraped, not constructed. LIVE-CONFIRMED 2026-07-30 on all four managed switches: an apply POST changes ONLY the rows whose checkbox is present in the body.

prefix: str
checkbox: str | None
fields: Mapping[str, str]
field(column)[source]

This row’s value for column (e.g. "v_1_2_6"), or None.

Return type:

str | None

class netgear_switch.protocols.http.types.XuiListPage(
action,
hidden,
buttons,
rows,
tokens=<factory>,
nav=<factory>,
template=<factory>,
)[source]

Bases: object

One render of a FASTPATH XUI list page (a table of XuiRow).

action is the <FORM ACTION=...> of the page’s SECOND form – the write form (<page>.html/a1); the first (/a0) is the applet/redirect form and carries no data. hidden is that form’s trailing “redirection elements” block (submit_flag/submit_target/err_flag/err_msg/ clazz_information), echoed back on every POST. buttons maps the page’s button fields to their rendered labels (v_2_1_2 -> APPLY, v_2_1_3 -> RESET / Power Cycle Port(s)); the firmware’s own xuiProcessButtonActions ENABLES the clicked button’s hidden input before submitting, so the POST carries it.

tokens is the form’s page-level NON-DATA fields – in practice the per-page CSRFToken the AV-era M4300-16X firmware issues. It is carried into every apply because that firmware answers 403 Forbidden to a POST that drops it (live 2026-07-30 on 10.1.5.20:49152: the identical body with the token returned 200 and applied). Data cells (v_*) are deliberately NOT included – an apply must mention only the row it is changing.

nav is the page’s list-NAVIGATION block: the v_* fields the firmware renders in its class=deftestme navigation rows above and below the table (the “Go To Port” bar), which scope the list – e.g. v_1_1_1 = "1"/v_1_3_1 = "1" (xc="url-list", xeleName="Port Group Index", both aliased by the page’s own xeData["xalias_urlListUnit"] = "1_1_1|1_3_1|3_1_1|3_4_1") plus the interface-type filter v_1_1_2 = "^Physical$". They are ENABLED hidden inputs, so a browser submits them on every apply – and on the GSM7252PS PoE page the firmware REQUIRES one of the urlListUnit aliases to resolve the row at all (see forms.xui_row_apply_form). Kept separate from rows and from the v_g_* global “apply to all” row, neither of which belongs in a one-row apply.

action: str
hidden: Mapping[str, str]
buttons: Mapping[str, str]
rows: tuple[XuiRow, ...]
tokens: Mapping[str, str]
nav: Mapping[str, str]
template: Mapping[str, str]

The page’s blank v_g_<table>_<tr>_<col> TEMPLATE row, keyed by its FULL field name. This is the row an ADD fills in: the firmware renders it with every value empty inside display:none cells, and the page’s Apply button writes the row-status into it (xa_4_2_1 targets "2_1_5|g_2_1_5" with "Active"; Delete targets the same pair with "Delete").

Empty for a page that renders no template row – which is most of them, and is why this is a separate field rather than being folded into rows: a one-row apply must never mention it, and the existing xui_row_apply_form deliberately does not.

row_for(column, value)[source]

The row whose column renders value (e.g. the ifName cell).

Return type:

XuiRow | None

class netgear_switch.protocols.http.types.XuiFormPage(action, hidden, buttons, fields)[source]

Bases: object

One render of a FASTPATH XUI detail page (flat v_<a>_<b>_<c> fields).

Same second-form/hidden/buttons shape as XuiListPage, but the values are not in repeating rows – ipConfiguration.html and the M4300’s mgmtVlanIpv4Configuration.html are of this kind. fields is every named input the form rendered, verbatim, so a re-POST can echo the device’s own body (the M4300-16X refuses a POST that drops its per-page CSRFToken, which lives in exactly this map).

action: str
hidden: Mapping[str, str]
buttons: Mapping[str, str]
fields: Mapping[str, str]
class netgear_switch.protocols.http.types.HttpSysInfo(
product_name,
switch_name,
serial_number,
mac_address,
firmware_version,
ip_mode,
ip_address,
subnet_mask,
gateway_address,
)[source]

Bases: object

GS110EMX sysInfo.html: device identity + management-IP config.

GROUNDED in tests/fixtures/http/gs110emx_sysinfo.html (a real capture from a physical GS110EMX) – see parse.parse_sysinfo. ip_mode is inferred from the page’s <tr data-select-value="N"> wrapping the DHCP-mode <select>: the real capture carries no explicit selected attribute on either <option> (that gets set client-side by the page’s own JavaScript), so data-select-value – 0 selects the “Disable” option at index 0 (static IP), 1 selects “Enable” (DHCP) – is the best-grounded reading available; it is corroborated by the same capture carrying a fully-populated static IP/netmask/gateway alongside data-select-value="0".

CAVEAT: only the STATIC-IP branch above (data-select-value="0") was directly observed in the one real capture that exists. The DHCP branch (data-select-value="1" -> IpMode.DHCP) is inferred from the same <select>’s option ordering, not itself captured from a real DHCP-configured device – treat it as plausible-but-unverified until a DHCP-mode capture confirms it, even though HttpModelSpec.reads_verified is True for this model’s grounded surface overall.

product_name: str
switch_name: str
serial_number: str
mac_address: str
firmware_version: str
ip_mode: IpMode
ip_address: str
subnet_mask: str
gateway_address: str

FASTPATH CLI

FASTPATH CLI protocol: pure parsers + per-model command specs.

Per-model FASTPATH CLI command specs (pure data).

The CLI equivalent of protocols/http/endpoints.py. Each CliModelSpec records the show command each read op issues, the config-mode commands each VLAN WRITE op issues (see cli_write.CliWriter), the per-model physical interface-name template every per-port command is addressed by, plus the session-setup commands (enable + disable output paging), and two honesty flags:

FASTPATH’s show grammar is nearly identical across the Fully Managed (M4300/GSM7252PS) and Smart Managed Pro (GSM7228PS/S3300) lines, but the exact command set varies by firmware image:

  • the newer M4300 firmware (12.0.13.8) renamed two commands – see _M4300_OVERRIDES;

  • the Smart-firmware S3300 (gsm7228ps) rejects show vlan brief (“Invalid input”) but accepts the bare show vlan (like the M4300s) while KEEPING the older show network (unlike the M4300s’ show ip management) – see _GSM7228PS.

Physical-port naming also differs: the Fully Managed line prints 1/0/N while the Smart-firmware S3300 prints 1/gN (1-48) and 1/xgN (uplinks 49-52). Both are resolved by protocols.cli.parse._phys_port.

Transports: SSH is the default network CLI transport, but a model may carry a non-standard telnet port via CliModelSpec.telnet_port (the S3300’s telnet CLI listens on 60000, not 23) for models that expose TELNET but not SSH.

netgear_switch.protocols.cli.commands.address_kind(address)[source]

The address-KIND token a logging host line carries: ipv4/ipv6/dns.

The live line is logging host "10.1.5.1" ipv4 514 info – the kind is an explicit argument, not something the firmware infers, so it has to be derived from the address. Anything that is not a literal IP is dns, which is what the host table’s own column is headed (“IP Address/Hostname”).

Return type:

str

netgear_switch.protocols.cli.commands.fastpath_rate(mbps)[source]

How FASTPATH spells a port rate in a speed command: 10000 -> “10G”.

The two spellings are not a guess: a live gsm7252ps offered 10, 100 and 10G as the forced rates on 1/0/8 (2026-08-03), so sub-gigabit rates go as bare Mbit/s and gigabit multiples take the G suffix. Rates outside that measured set are still formatted by the same rule and SENT – the device answers “% Invalid input” for one it does not have, which the writer raises verbatim (see CliModelSpec.port_speed_forced_cmd).

Return type:

str

class netgear_switch.protocols.cli.commands.CliModelSpec(
model_key: 'str',
captured: 'bool',
reads_verified: 'bool',
writes_verified: 'bool' = True,
telnet_port: 'int' = 23,
enable_cmd: 'str' = 'enable',
paging_off_cmd: 'str' = 'terminal length 0',
version_cmd: 'str' = 'show version',
port_status_cmd: 'str' = 'show port all',
vlan_brief_cmd: 'str' = 'show vlan brief',
vlan_detail_cmd: 'str' = 'show vlan {vlan}',
pvid_cmd: 'str' = 'show vlan port all',
mac_table_cmd: 'str' = 'show mac-addr-table',
lldp_cmd: 'str' = 'show lldp remote-device all',
poe_cmd: 'str' = 'show poe port info all',
environment_cmd: 'str' = 'show environment',
network_cmd: 'str' = 'show network',
interface_stats_cmd: 'str' = 'show interface ethernet {iface}',
hosts_cmd: 'str' = 'show hosts',
users_cmd: 'str' = 'show users',
http_service_cmd: 'str' = 'show ip http',
telnet_service_cmd: 'str' = 'show telnetcon',
ssh_service_cmd: 'str' = 'show ip ssh',
logging_cmd: 'str' = 'show logging',
logging_hosts_cmd: 'str' = 'show logging hosts',
hostname_config_cmd: 'str' = 'hostname {name}',
logging_host_add_cmd: 'str' = 'logging host "{address}" {kind} {port} {severity}',
logging_host_remove_cmd: 'str' = 'logging host remove {index}',
logging_syslog_cmd: 'str' = 'logging syslog',
logging_no_syslog_cmd: 'str' = 'no logging syslog',
iface_template: 'str' = '1/0/{port}',
uplink_iface_template: 'str | None' = None,
first_uplink_port: 'int | None' = None,
vlan_database_cmd: 'str' = 'vlan database',
vlan_create_cmd: 'str' = 'vlan {vlan}',
vlan_name_cmd: 'str' = 'vlan name {vlan} {name}',
vlan_delete_cmd: 'str' = 'no vlan {vlan}',
configure_cmd: 'str' = 'configure',
interface_cmd: 'str' = 'interface {iface}',
switchport_general_cmd: 'str | None' = 'switchport mode general',
vlan_participation_cmd: 'str' = 'vlan participation {action} {vlan}',
vlan_tagging_cmd: 'str' = 'vlan tagging {vlan}',
vlan_no_tagging_cmd: 'str' = 'no vlan tagging {vlan}',
vlan_pvid_cmd: 'str' = 'vlan pvid {vlan}',
port_description_cmd: 'str' = "description '{text}'",
port_no_description_cmd: 'str' = 'no description',
port_description_show_cmd: 'str' = 'show port description {iface}',
port_speed_auto_cmd: 'str' = 'speed auto',
port_speed_forced_cmd: 'str' = 'speed {rate} {duplex}-duplex',
port_flow_control_cmd: 'str' = 'flowcontrol',
port_no_flow_control_cmd: 'str' = 'no flowcontrol',
exit_cmd: 'str' = 'exit',
poe_enable_cmd: 'str' = 'poe',
poe_disable_cmd: 'str' = 'no poe',
poe_reset_cmd: 'str' = 'poe reset',
port_enable_cmd: 'str' = 'no shutdown',
port_disable_cmd: 'str' = 'shutdown',
mgmt_ip_exec_cmds: 'tuple[str, ...]' = ('network parms {address} {netmask} {gateway}',),
mgmt_ip_config_cmds: 'tuple[str, ...]' = (),
reload_cmd: 'str' = 'reload',
)[source]

Bases: object

model_key: str
captured: bool
reads_verified: bool
writes_verified: bool = True
telnet_port: int = 23
enable_cmd: str = 'enable'
paging_off_cmd: str = 'terminal length 0'
version_cmd: str = 'show version'
port_status_cmd: str = 'show port all'
vlan_brief_cmd: str = 'show vlan brief'
vlan_detail_cmd: str = 'show vlan {vlan}'
pvid_cmd: str = 'show vlan port all'
mac_table_cmd: str = 'show mac-addr-table'
lldp_cmd: str = 'show lldp remote-device all'
poe_cmd: str = 'show poe port info all'
environment_cmd: str = 'show environment'
network_cmd: str = 'show network'
interface_stats_cmd: str = 'show interface ethernet {iface}'
hosts_cmd: str = 'show hosts'
users_cmd: str = 'show users'
http_service_cmd: str = 'show ip http'
telnet_service_cmd: str = 'show telnetcon'
ssh_service_cmd: str = 'show ip ssh'
logging_cmd: str = 'show logging'
logging_hosts_cmd: str = 'show logging hosts'
hostname_config_cmd: str = 'hostname {name}'
logging_host_add_cmd: str = 'logging host "{address}" {kind} {port} {severity}'
logging_host_remove_cmd: str = 'logging host remove {index}'
logging_syslog_cmd: str = 'logging syslog'
logging_no_syslog_cmd: str = 'no logging syslog'
iface_template: str = '1/0/{port}'
vlan_database_cmd: str = 'vlan database'
vlan_create_cmd: str = 'vlan {vlan}'
vlan_name_cmd: str = 'vlan name {vlan} {name}'
vlan_delete_cmd: str = 'no vlan {vlan}'
configure_cmd: str = 'configure'
interface_cmd: str = 'interface {iface}'
switchport_general_cmd: str | None = 'switchport mode general'
vlan_participation_cmd: str = 'vlan participation {action} {vlan}'
vlan_tagging_cmd: str = 'vlan tagging {vlan}'
vlan_no_tagging_cmd: str = 'no vlan tagging {vlan}'
vlan_pvid_cmd: str = 'vlan pvid {vlan}'
port_description_cmd: str = "description '{text}'"
port_no_description_cmd: str = 'no description'
port_description_show_cmd: str = 'show port description {iface}'
port_speed_auto_cmd: str = 'speed auto'
port_speed_forced_cmd: str = 'speed {rate} {duplex}-duplex'
port_flow_control_cmd: str = 'flowcontrol'
port_no_flow_control_cmd: str = 'no flowcontrol'
exit_cmd: str = 'exit'
poe_enable_cmd: str = 'poe'
poe_disable_cmd: str = 'no poe'
poe_reset_cmd: str = 'poe reset'
port_enable_cmd: str = 'no shutdown'
port_disable_cmd: str = 'shutdown'
mgmt_ip_exec_cmds: tuple[str, ...] = ('network parms {address} {netmask} {gateway}',)
mgmt_ip_config_cmds: tuple[str, ...] = ()
reload_cmd: str = 'reload'
vlan_detail(vlan)[source]
Return type:

str

iface(port)[source]

The interface NAME this firmware addresses physical port by.

Return type:

str

interface_stats(port)[source]
Return type:

str

vlan_create(vlan)[source]
Return type:

str

vlan_name(vlan, name)[source]
Return type:

str

vlan_delete(vlan)[source]
Return type:

str

interface(port)[source]
Return type:

str

vlan_participation(vlan, *, include)[source]
Return type:

str

vlan_tagging(vlan, *, tagged)[source]
Return type:

str

vlan_pvid(vlan)[source]
Return type:

str

port_description(text)[source]

Set or clear a port’s description (interface config mode).

Return type:

str

port_description_show(port)[source]

The per-port command that reports a description back.

Return type:

str

port_speed(speed)[source]

The interface-config command that applies speed (see the fields).

Return type:

str

logging_host_add(address, port, severity)[source]

The logging host line for one collector, as running-config shows it.

Return type:

str

logging_host_remove(index)[source]

Remove the collector at index (1-based, per show logging hosts).

Return type:

str

logging_syslog(*, enabled)[source]
Return type:

str

port_flow_control(*, enabled)[source]

Turn 802.3x flow control on or off (interface config mode).

Return type:

str

poe_admin(*, on)[source]
Return type:

str

port_admin(*, enabled)[source]
Return type:

str

mgmt_ip(address, netmask, gateway)[source]

(exec_commands, config_commands) for a management-IP write.

Exactly one of the two is non-empty, per this model’s dialect (see the mgmt_ip_* fields).

Return type:

tuple[tuple[str, …], tuple[str, …]]

class netgear_switch.protocols.cli.commands.ScpCertProfile(model_key, crypto, writemem_stuff, verify_port)[source]

Bases: object

Per-model FASTPATH SSL-cert-over-SCP deploy profile (pure data).

A TRANSCRIPTION of the working certbot-hook MODEL_PROFILES (see tmp/certbot_hook_prior_art.py – grounded prior art). Only the Fully Managed FASTPATH models that take a certificate over copy scp:// carry one; the Smart Managed Pro line (gsm7228ps/S3300) uses an HTTP multipart upload instead and is deliberately absent here.

  • crypto – "modern" or "legacy": which SSH key-exchange / host-key algorithm set the switch’s sshd needs. The library’s SSH transport already re-inserts the legacy algorithms this old firmware requires (see transport/cli/ssh.py); this flag is carried for the CALLER (e.g. the certbot hook) that stages the PEM and may open its own SCP source.

  • writemem_stuff – True when write memory’s confirm has a tiny timeout, so the y must be pre-stuffed in one write (GSM7252PS); False for the M4300s, which take a normal read-then-answer confirm.

  • verify_port – the HTTPS port a post-deploy fingerprint check connects to. NOT used by the deploy itself (the library only SENDS the copy commands; verification is the caller’s job), carried for parity with the prior art so a caller need not re-derive it.

model_key: str
crypto: str
writemem_stuff: bool
verify_port: int
netgear_switch.protocols.cli.commands.scp_cert_profile(model)[source]

Return the FASTPATH SCP cert-deploy profile for model.

Raises UnsupportedCapabilityError for any model with no copy scp:// cert-deploy path – i.e. every non-FASTPATH model, AND FASTPATH models whose cert upload uses a different mechanism (gsm7228ps: HTTP multipart). This is the gate the facade’s upload_certificate_scp dispatches on.

Return type:

ScpCertProfile

netgear_switch.protocols.cli.commands.cli_spec(model)[source]

Return the CLI command spec for model or raise if it has no CLI backend.

Return type:

CliModelSpec

Pure parsers for NETGEAR FASTPATH CLI show command output.

These are the CLI equivalent of protocols/http/parse.py: I/O-free functions turning the fixed-width, tabular text a FASTPATH switch prints over its console into the library’s public model dataclasses. Every function is grounded in the REAL captured transcripts of a live gsm7252ps (SSH, host 10.1.5.22) that are split, command-per-file, into tests/fixtures/cli/gsm7252ps_*.txt – each parser’s docstring names the fixture and the exact column map it was derived from. Nothing here is invented: expected values are transcribed from those fixtures.

FASTPATH prints two shapes:

  • Labelled scalars – Label.......... value dotted-leader lines (show version, show network, show interface ethernet <intf>), handled by labelled_values.

  • Fixed-width tables – a header, a ruler line of ---- groups, then rows whose columns are aligned to the ruler (show port all, show vlan port all, show vlan <id>, show mac-addr-table, show lldp remote-device all, show poe port info all and the tables in show environment). The ruler is the single source of truth for column boundaries – a naive str.split() would corrupt cells that legitimately contain spaces ("Delivering Power", "CPU Interface:  0/5/1", "Not Supported"), so table_columns/iter_table_rows slice strictly by the ruler’s dash-group spans.

netgear_switch.protocols.cli.parse.labelled_values(text)[source]

Parse Label.......... value dotted-leader lines into a dict.

Later duplicate labels overwrite earlier ones (only the last wins); callers that need every occurrence of a repeated label (e.g. IPv6 Prefix is) must not use this helper. Blank values (Bootcode Version...........) map to "".

Return type:

dict[str, str]

netgear_switch.protocols.cli.parse.iter_table_rows(text, *, after=None)[source]

Yield each data row (as sliced, stripped cells) of a fixed-width table.

The table is the block of lines following the first ruler (----) line – optionally the first ruler that appears AFTER a line containing after (used to pick one of show environment’s three sub-tables). Iteration stops at the first blank line or the next ruler after the table body.

Return type:

Iterator[list[str]]

netgear_switch.protocols.cli.parse.header_columns(text, *, after=None)[source]

Reconstruct each table column’s HEADER NAME, in order.

The header of a fixed-width FASTPATH table often wraps over two or three lines (High Power / Max Power (mW) / Output Current (mA) stack their words above the ruler). Each of those header lines is sliced by the SAME ruler spans that slice the data rows, and the per-column pieces are joined (whitespace-collapsed) into one name. This lets a parser locate a column by NAME rather than a fixed index – needed because the column set is not identical across firmware images (e.g. the M4300 show poe port info all omits the Temperature column the gsm7252ps prints). Returns [] if no ruler is found.

Return type:

list[str]

netgear_switch.protocols.cli.parse.parse_version(text, models)[source]

show version -> DetectedModel.

Column/label map (gsm7252ps_show_version.txt):

System Description  -> the sysDescr-equivalent string matched against the
                       registry (contains the model name "GSM7252PS").
Machine Model       -> fallback match token if the description is absent.

sys_object_id is always None (the CLI exposes no sysObjectID). Model matching reuses the SNMP backend’s exact whole-word matcher so CLI and SNMP identify a switch identically.

Return type:

DetectedModel

netgear_switch.protocols.cli.parse.parse_physical_mode(cell)[source]

The “Physical Mode” cell -> the port’s CONFIGURED speed, or None.

This is the column set_port_speed verifies itself against, and it is a DIFFERENT column from “Physical Status”: Physical Mode is what the port is set to, Physical Status what it negotiated. On a down port the first still reads Auto/100 Full while the second is blank – which is the whole reason the two are separate fields (see models.PortSpeed).

Values measured live 2026-08-03 on gsm7252ps 10.1.5.22 port 1/0/8: Auto by default, 100 Full after speed 100 full-duplex. None for a blank cell, and for a word no measured firmware emits – the writer’s verify-after-write is what makes an undecodable value loud, rather than every get_ports on unfamiliar firmware raising.

Return type:

PortSpeed | None

netgear_switch.protocols.cli.parse.parse_port_status(text)[source]

show port all -> per physical-port PortStatus.

admin_enabled = Admin Mode == “Enable”; link_up = Link Status == “Up”; speed_mbps and full_duplex both from Physical Status, which carries them together (“1000 Full”); flow_control from the Flow Mode column, found by header name (see _PORT_FLOW_HEADER). All three are None on a down port, which has negotiated nothing. speed_config comes from Physical Mode and is reported whether the port is up or down – it is a setting, not a negotiation result. lag N aggregation rows are skipped (not physical ports). description is honestly None: this command carries no ifAlias column.

Return type:

list[PortStatus]

netgear_switch.protocols.cli.parse.parse_vlan_brief(text)[source]

show vlan brief -> [(vlan_id, name), ...] (no membership).

Membership/tagging is NOT on this page; the reader follows up with show vlan <id> per VLAN (parse_vlan_detail).

Return type:

list[tuple[int, str]]

netgear_switch.protocols.cli.parse.parse_vlan_detail(text, *, name=None)[source]

show vlan <id> -> one VLANInfo (egress membership + tagging).

The VLAN ID:/VLAN Name: scalar header names the VLAN; the per- interface table’s columns are Interface | Current | Configured | Tagging. Current == "Include" means the port is an egress member; Tagging then splits it into tagged vs untagged. lag N rows are dropped (the library models physical ports). name (from show vlan brief) overrides the page’s own name when supplied.

Return type:

VLANInfo

netgear_switch.protocols.cli.parse.parse_pvids(text)[source]

show vlan port all -> [(port, pvid), ...] for physical ports.

Uses the Port VLAN ID Configured column (the persistent PVID), matching what dot1qPvid reports over SNMP; lag N rows are skipped.

Return type:

list[tuple[int, int]]

netgear_switch.protocols.cli.parse.parse_mac_table(text)[source]

show mac-addr-table -> [MacEntry, ...].

MacEntry.port is the IfIndex column (49 for 1/0/49, 418 for lag 1, 417 for the CPU/Management row) – the same ifIndex the SNMP FDB join yields, so CLI and SNMP report the same port for a given MAC. The Interface column (which may contain internal spaces, e.g. "CPU Interface:  0/5/1") is only surfaced via the fixed-width slice and is not otherwise used. VLAN ID comes from the first column.

Return type:

list[MacEntry]

netgear_switch.protocols.cli.parse.parse_lldp(text)[source]

show lldp remote-device all -> [LLDPNeighbor, ...].

Columns: Local Interface | RemID | Chassis ID | Port ID | System Name. A local-interface row with no neighbour (only the interface printed, e.g. 1/0/6) is skipped. remote_port_desc is honestly None – this command has no port-description column (SNMP’s lldpRemPortDesc is the source for it). Chassis IDs are uppercased to match the SNMP/HTTP backends.

Return type:

list[LLDPNeighbor]

netgear_switch.protocols.cli.parse.parse_poe(text)[source]

show poe port info all -> per-port PoEStatus.

power_mw is the Power (mW) column – the live output draw, matching the vendor mW OID the SNMP backend reads. detect maps the Status text (“Delivering Power” -> DELIVERING, “Searching” -> SEARCHING, “Disabled” -> DISABLED, anything containing “Fault” -> FAULT). This command has NO admin column, so admin_enabled is INFERRED: a port whose Status is anything other than “Disabled” is admin-enabled (a searching/delivering PSE port is administratively on). Documented inference, not a fabricated field.

Columns are keyed by HEADER NAME, not a fixed index, because the M4300 image drops the Temperature column the gsm7252ps prints – see header_columns.

Return type:

list[PoEStatus]

netgear_switch.protocols.cli.parse.parse_environment(text)[source]

show environment -> box sensors.

Emits, in order:

  • one kind="temperature" (unit="C") Sensor per Temperature Sensors row – name from the Description column (CPU/System/MAC-A/MAC-B), value the Temp (C) column.

  • one kind="fan" Sensor per Fans row with a NUMERIC Speed (RPM as the value, unit="RPM"); a fan reporting "Not Supported" is absent, not zero, and is skipped.

  • one kind="power" Sensor per Power-supplies row carrying its State as a health flag (unit="state", value 1.0 when Operational else 0.0).

Return type:

list[Sensor]

netgear_switch.protocols.cli.parse.parse_services(http_text, telnet_text, ssh_text)[source]

The four management services, from the three commands that report them.

Captured 2026-08-02 from m4300-24x (10.1.5.13) and gsm7252ps (10.1.5.22).

show ip http carries BOTH the plain and secure web servers:

HTTP Mode (Unsecure)........................... Enabled
HTTP Port...................................... 80
HTTP Mode (Secure)............................. Enabled
Secure Port.................................... 443

show telnetcon – NOT show telnet – reports the INBOUND server:

Telnet Server Admin Mode....................... Enable
Telnet Server Port............................. 23

show telnet describes the switch as a telnet client (“Allow New Outbound Telnet Sessions”), which says nothing about whether the server this library’s TELNET backend connects to is running. Reading it as the server state would be wrong in the way that looks right.

show ip ssh reports SSH, and its field set differs by firmware: the gsm7252ps prints no SSH Port line at all, so that port is honestly None rather than assumed to be 22.

Return type:

list[ServiceStatus]

netgear_switch.protocols.cli.parse.parse_users(text)[source]

show users -> the switch’s local login accounts.

Captured 2026-08-02 from m4300-24x (10.1.5.13) and gsm7252ps (10.1.5.22); both list admin and guest, under a header that wraps over three lines:

User        SNMPv3         SNMPv3        SNMPv3
User Name                 Access Mode   Access Mode  Authentication  Encryption
------------------------  ------------  -----------  --------------  ----------
admin                     Privilege-15  Read Only    MD5             None

Sliced by the ruler rather than split on whitespace, because an access mode legitimately contains a space (Read Only, Read/Write) and a naive split would tear it in half.

The ACCESS-MODE VOCABULARY differs by firmware – see models.PRIVILEGED_ACCESS_MODES – so the raw text is preserved on SwitchUser.access_mode and only the normalised privileged flag interprets it.

Return type:

list[SwitchUser]

netgear_switch.protocols.cli.parse.parse_syslog(logging_text, hosts_text)[source]

show logging + show logging hosts -> SyslogConfig.

Captured 2026-08-02 from m4300-24x (10.1.5.13), m4300-16x (10.1.5.20) and gsm7252ps (10.1.5.22):

Syslog Logging                      : enabled
Logging Client Local Port           : 514

Index   IP Address/Hostname     Severity    Port   Status  Mode  Auth  Cert#
----- ------------------------ ---------- ------ --------- ----- ----- -----
1     10.1.5.1                 info       514    Active    udp

The host table’s column set differs by firmware. The M4300s emit eight columns (through Cert#); the gsm7252ps emits only the first five. Both are parsed by taking the first five whitespace-separated fields and ignoring anything after Status, so neither shape can shift a value into the wrong field – the same class of trap as the VLAN PortList width.

RAISES if logging_text is not a logging block at all. This used to return SyslogConfig(enabled=False, local_port=0, servers=()) for ANY unparseable input – so a switch answering “Command not found” was reported as “remote logging is off, no collectors, source port 0”. That is a confident wrong answer to a question that was never answered, and a fabricated 0 besides; principle 1 wants the failure, not a plausible blank.

Return type:

SyslogConfig

netgear_switch.protocols.cli.parse.parse_hostname(text)[source]

show hosts -> the switch’s host name.

The command reports far more than the name – DNS servers, the domain list, resolver retry counts, the static host-to-address table – and only the first labelled field is wanted:

Host name...................................... sw-netgear-m4300-24x
Default domain................................. Domain name is not configured
Name servers (Preference order)................ 8.8.8.8, 10.1.5.1

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 it exactly “Host name”.

This is deliberately NOT show running-config | include hostname. The two report different values: on m4300-16x running-config holds “manage-sw-netgear-m4300-16x-poe-s2” against this command’s “sw-netgear-m4300-16x-poe-s2”, and on gsm7252ps running-config has no hostname line at all while this command still answers. show hosts is the one that matches SNMP’s sysName, so parsing it is what stops the CLI and SNMP backends disagreeing about the same switch.

Raises rather than returning “” when the label is absent: every FASTPATH switch measured answers it, so silence means the command failed or the output drifted, and a blank host name would be a fabrication.

Return type:

str

netgear_switch.protocols.cli.parse.parse_mgmt_ip(text)[source]

show network -> MgmtIpConfig.

Label map (gsm7252ps_show_network.txt / m4300_24x_show_ip_management):

IP Address              -> address
Subnet Mask             -> netmask
Default Gateway         -> gateway
Burned In MAC Address   -> base_mac (uppercased, as the other backends do)
Configured IPv4 Protocol-> mode (DHCP -> DHCP, else STATIC)

show network labels the mode “Configured IPv4 Protocol”; M4300 12.0’s show ip management labels it “Method” instead – accept either.

Return type:

MgmtIpConfig

netgear_switch.protocols.cli.parse.parse_interface_counters(text, port)[source]

show interface ethernet <intf> -> one port’s PortStats.

The command output carries no interface number, so port is supplied by the caller. Field selection is aligned with the SNMP backend’s get_stats (see the label map above) so CLI and SNMP report the same six counters.

Return type:

PortStats

netgear_switch.protocols.cli.parse.parse_port_description(text)[source]

The Description field of show port description <iface>.

GROUNDED in live output from 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

Returns None for an unset description (the label is present with an empty value, exactly as above) so it matches what every other backend reports for an absent label, rather than "".

This command exists because show port all carries NO description column – which is why parse_port_status honestly reports description=None and why a CLI description write has to verify itself through here instead.

Return type:

str | None