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,
Bases:
objectPer-model Netgear vendor-specific OID table.
- 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.1literal; they all reference this field.
- syslog_host_status: str¶
Remote-logging configuration, under
<base>.14on 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 hostsand 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 of10.1.5.1 info 514 Active– so severity is the standard syslog scale (6 = info) and status 1 = Active.<base>.17is 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 whileshow loggingsays “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_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_unverifiedprecedent 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_baseset), sovendor_oidsis 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.4526answersnoSuchObject). Such a model’s PoE, box sensors and DHCP-mode reads use the standard-MIB code paths insnmp_readinstead of the vendor columns; see there.- Return type:
- 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 singlenoSuchObject, never silently falls through to whatever unrelated OID happens to sort next (seeis_oid_implementedandvirtual/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.
- netgear_switch.protocols.snmp.oids.is_oid_implemented(model, oid)[source]¶
False if
oidfalls under a subtree rootunimplemented_rootssays 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 normalnoSuchInstance/endOfMibViewresponses. Only a whole MIB module the device never registers getsnoSuchObjecthere.- Return type:
- 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:
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.
- 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
strfrom the CLI transport orbytesfrom 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:bytesis decoded tostr(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.
- netgear_switch.protocols.snmp.parse.physical_ports(if_types)[source]¶
The set of physical (ethernetCsmacd) ifIndexes from an ifType walk.
Returns
Nonewhen 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.
- netgear_switch.protocols.snmp.parse.parse_port_status(admin, oper, speed, names, aliases, if_types=(), duplex=(), pause=())[source]¶
Per-port status.
duplex/pauseare 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 staysNone, which is what the CLI backend already reports where its own table omits the value – absence is never rendered as False.- Return type:
- netgear_switch.protocols.snmp.parse.parse_port_stats(
- *,
- in_octets,
- out_octets,
- in_ucast,
- out_ucast,
- in_errors,
- out_errors,
- if_types=(),
- 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 printablestrit is latin-1 encoded first; a str that cannot round-trip through latin-1 is malformed and raises SnmpError naming the value.
- netgear_switch.protocols.snmp.parse.parse_vlans(
- names,
- egress,
- untagged,
- if_types=(),
- current_egress=(),
- current_untagged=(),
VLANs from the Q-BRIDGE static table, completed by the current table.
if_typesfilters membership to physical ports, exactly asparse_port_status/parse_pvidsalready 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_untaggedadd VLANs the STATIC table omits. That is not a hypothetical: the same GS728TPP publishes only 12 static rows (ids 2..99) whiledot1qVlanCurrentTablehas 13 – VLAN 1, the default VLAN, exists ONLY there, withdot1qVlanStatus = 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 nodot1qVlanStaticNamerow, 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.
- 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:
- netgear_switch.protocols.snmp.parse.parse_macs(fdb, bridge_ports)[source]¶
Build the MAC/FDB table from dot1qTpFdbPort + dot1dBasePortIfIndex.
dot1qTpFdbPortgives the bridge PORT number keyed by<vlan>.<mac-as-6-oid-octets>;dot1dBasePortIfIndexmaps 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.
- netgear_switch.protocols.snmp.parse.parse_poe(status, power_mw)[source]¶
Build PoE port status from RFC3621 pethPsePortTable + vendor mW.
statusis 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_mwis the vendor per-port power walk, matched to a port by the final OID suffix component; a port without a vendor mW row getspower_mw=None.
- 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.
- 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
entPhysicalTablerows:entPhysicalClass(6=powerSupply, 7=fan) identifies each,entPhysicalName(falling back toentPhysicalDescr) 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 carriesvalue=NaNandunit="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.
- 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,
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:
- netgear_switch.protocols.snmp.parse.parse_mgmt_ip(
- addr,
- netmask,
- route_dest,
- route_nexthop,
- dhcp_mode,
- base_mac,
- addr_rfc4293=(),
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.UNKNOWNis 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 Pythonint(seeSnmpRow’s docstring); only a recognized present value (1/2) maps to DHCP/STATIC, any other present value – including one that cannot be coerced tointat all – also yields UNKNOWN rather than raising, since this OID is explicitly best-effort.base_macis the standard (non-UNVERIFIED) dot1dBaseBridgeAddress scalar walk – seeparse_base_mac; an empty walk (OID absent) yieldsbase_mac=None.- Return type:
- netgear_switch.protocols.snmp.parse.parse_hostname(rows)[source]¶
Extract
sysNamefrom 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:
- 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 fromdetect_model_from_sysdescrso the matching heuristic is unit-testable against plain strings, with no SnmpRow/client machinery involved at all.
- 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; otherwiseNone(never a guess). This is the AUTHORITATIVE detector – sysObjectID is a stable manufacturer product identifier, so unlikedetect_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_infotries this first and only falls back to sysDescr matching when it returnsNone.- 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 inmodels) matches no token and correctly returnsNone– 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"forgsm7228ps, 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
Nonerather 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:
objectOne SNMP varbind: full numeric OID, normalized value, type token.
valueis a normalized Python value so the sync (net-snmp CLI) and async (pysnmp) clients are interchangeable:intfor integer-family types (INTEGER/Gauge32/Counter32/Counter64/Timeticks-numeric),strfor text, OID and IP-address values,bytesfor raw octet strings (Hex-STRING). Both clients MUST yield equal values for the same OID (Task 16 enforces it).
- exception netgear_switch.protocols.snmp.client.SnmpError[source]¶
Bases:
NetgearSwitchErrorAn 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
oidwith an emptyoid_index, or split the instance intooid_index. Joining both and stripping any leading dot is correct either way.- Return type:
- class netgear_switch.protocols.snmp.client.SnmpClient(*args, **kwargs)[source]¶
Bases:
ProtocolSynchronous SNMP v2c read client for a single switch.
- class netgear_switch.protocols.snmp.client.AsyncSnmpClient(*args, **kwargs)[source]¶
Bases:
ProtocolAsynchronous SNMP v2c read client for a single switch.
- class netgear_switch.protocols.snmp.client.SnmpWriteClient(*args, **kwargs)[source]¶
Bases:
SnmpClient,ProtocolSynchronous SNMP v2c read+write client for a single switch.
Extends the read client with SET.
set_manyis one PDU (atomic). A write RW community can also read, so a single write client verifies its own writes via the inheritedget/walk.
- class netgear_switch.protocols.snmp.client.AsyncSnmpWriteClient(*args, **kwargs)[source]¶
Bases:
AsyncSnmpClient,ProtocolAsynchronous SNMP v2c read+write client for a single switch.
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:
objectOne SNMP SET varbind: full numeric OID, value, and net-snmp type letter.
- 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_bytesif a port number needs it, so callers never pre-size for the actual port count.- Return type:
- 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 fromvlan_bitmap_width), is honoured too: the result is at least 8 bytes, at least as wide as the input, and at leastwidth_byteswide.- Return type:
- 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_bytesis forwarded toset_port_bitfor both columns (seevlan_bitmap_width).
- netgear_switch.protocols.snmp.write.vlan_bitmap_width(model)[source]¶
Wire byte-width of
model’s dot1q VLAN egress/untagged bitmaps.dot1qVlanStaticEgressPorts/UntaggedPortsare 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:
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:
IntEnumNSDP 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:
IntEnumNSDP 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:
objectOne NSDP TLV: a 2-byte tag, 2-byte length, then that many value bytes.
- 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,
Bases:
objectA full NSDP datagram: a fixed header plus a list of TLVs.
- property error_code: int¶
The switch’s error code alone (header byte 2).
resultis 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), soresult == 0x0300means error code 3.
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¶
- 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
- class netgear_switch.protocols.nsdp.types.NsdpPortName(port_id, name)[source]¶
Bases:
objectOne port’s operator description (tag 0x0B000 / PORT_NAME).
nameisNonewhen the TLV carries only the port byte, which is how a real GS110EMX reports a port with no description set.
- class netgear_switch.protocols.nsdp.types.NsdpPortStatistics(
- port_id: 'int',
- bytes_received: 'int',
- bytes_sent: 'int',
- crc_errors: 'int',
Bases:
object
- class netgear_switch.protocols.nsdp.types.NsdpVlanMembership(
- vlan_id: 'int',
- member_ports: 'frozenset[int]',
- tagged_ports: 'frozenset[int]' = frozenset(),
Bases:
object
- class netgear_switch.protocols.nsdp.types.NsdpPortPvid(port_id: 'int', vlan_id: 'int')[source]¶
Bases:
object
- class netgear_switch.protocols.nsdp.types.NsdpPortMirroring(destination_port, source_ports=frozenset({}))[source]¶
Bases:
objectPort mirroring configuration (NSDP tag 0x5C00).
Lifted from
gdoc2netcfg/src/nsdp/types.py::PortMirroring.- Variables:
- class netgear_switch.protocols.nsdp.types.NsdpIgmpSnooping(enabled, vlan_id=None)[source]¶
Bases:
objectIGMP snooping configuration (NSDP tag 0x6800).
Lifted from
gdoc2netcfg/src/nsdp/types.py::IGMPSnooping.- Variables:
- 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,
Bases:
object- 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, ...]¶
- port_mirroring: NsdpPortMirroring | None = None¶
- igmp_snooping: NsdpIgmpSnooping | 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 usemd5(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:
- 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_macis the device’s own 6-byte MAC (the server MAC echoed in theAUTH_V2_SALTread response);saltis 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-nsdpAuthV2Password.- Return type:
- 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:
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_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 toNonerather than to an empty string that a caller could not distinguish from a description of “”.- Return type:
- netgear_switch.protocols.nsdp.parsers.bitmap_to_ports(bitmap)[source]¶
MSB-first, 1-based: byte 0 bit 0x80 = port 1 … 0x01 = port 8.
- netgear_switch.protocols.nsdp.parsers.ports_to_bitmap(ports, width_bytes)[source]¶
Inverse of
bitmap_to_portsfor the write path (same MSB-first layout).- Return type:
- 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). Acceptdest_portplus the remaining bytes as the bitmap instead.Lifted from
gdoc2netcfg/src/nsdp/parsers.py::parse_port_mirroring.- Return type:
- 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:
- netgear_switch.protocols.nsdp.parsers.parse_device(packet)[source]¶
Aggregate a READ_RESPONSE packet’s TLVs into an NsdpDevice.
- Return type:
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:
NetgearSwitchErrorAn 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, orNoneif 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:
- netgear_switch.protocols.nsdp.client.check_result(packet)[source]¶
Raise
NsdpErrorunless the response reports success (result 0x0000).The 2-byte
resultis (error-byte << 8 | unk1); the error CODE alone isresult >> 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:
ProtocolSynchronous NSDP read client for a single switch.
- class netgear_switch.protocols.nsdp.client.NsdpWriteClient(*args, **kwargs)[source]¶
Bases:
NsdpClient,ProtocolSynchronous NSDP read+write client for a single switch.
- class netgear_switch.protocols.nsdp.client.AsyncNsdpClient(*args, **kwargs)[source]¶
Bases:
ProtocolAsynchronous NSDP read client for a single switch.
- class netgear_switch.protocols.nsdp.client.AsyncNsdpWriteClient(*args, **kwargs)[source]¶
Bases:
AsyncNsdpClient,ProtocolAsynchronous NSDP read+write client for a single switch.
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:
build_write_request— v1: prepend the XORPASSWORD(0x000A) TLV.build_write_request_v2— v2: the 8-byteAUTH_V2_PASSWORD(0x001A) token FIRST, then the config TLVs (seeauth.auth_v2_password). The ordering is load-bearing: trailing the token is rejected error 13.
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:
- netgear_switch.protocols.nsdp.write.build_write_request(client_mac, server_mac, sequence, password, tlvs)[source]¶
Build a v1-authenticated WRITE: the XOR
PASSWORDTLV, then config.- Return type:
- 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_PASSWORDtoken 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:
- netgear_switch.protocols.nsdp.write.vlan_members_tlv(vlan, members, tagged, port_count)[source]¶
- Return type:
- 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_VLANDestroybuilds exactlynewShortAttr(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 innsdp_write.pyis the runtime guard.- Return type:
- 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, likevlan_destroy_tlv, was never exercised against hardware.- Return type:
- 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 –
parsersdecodes 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 (unlikeport_name_tlvabove, whose tag is indexed by port).- Return type:
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 inpy_netgear_plus/models.py(GS30xSeries / GS30xEPxSeries:CRYPT_FUNCTION="merge_hash",LOGIN_TEMPLATE, PoE/VLAN CGI paths) and inrcfiles/bin/netgear-smp-vlan(identicalmergehash scheme observed on GS105PE;8021qCf.cgi/8021qMembe.cgi/portPVID.cgifield shapes and wire codes). Bothscheme_verifiedandreads_verifiedareTrue.gsm7228ps(Smart Managed Pro / S3300-52X): the plaintext cheetah login form is GROUNDED incertbot-hook-netgear-switches/netgear-updater.py(S3300Updater), soscheme_verifiedisTrue. Its read pages are the SAME Cheetah XE grid as the siblinggsm7252psand are GROUNDED in real captures of the live switch (tests/fixtures/http/gsm7228ps_*.html); ports/stats/PVIDs/VLANs/PoE/LLDP reuse theparse_xe_*parsers, while the MAC table (shifted columns, escaped1/gNport names) and sensors (unsupported over HTTP) get S3300-specific handling – seeHtmlDialect.S3300.reads_verifiedisTrue(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.htmlserves it, and more of it than SNMP does (seemgmt_ip_pathbelow).gs110emx(Plus EMx / Gambit): GROUNDED in real captures from a physical GS110EMX (tests/fixtures/http/gs110emx_*.html). The scheme ismerge_hash_md5(password, rand)(identical function togs305ep) POSTed asLoginPasswordto/redirect.html(randscraped fromGET /, not from the POST target itself – seelogin_post_path); the response carries aGambitTOKEN (not a cookie – noSet-Cookieis 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_*stayNone(this model genuinely has no PoE, confirmed 404), as doreboot_path/logout_path(never captured, not guessed). CAVEAT onreads_verified=True: only VLAN 1’s membership page was captured, so the per-VLANvlanIdSelselect 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) – registryverified=True, and BOTHscheme_verifiedandreads_verifiedareTrue, grounded in six real captures (tests/fixtures/http/gs105pe_*.html). The merge-hash login is shared withgs305ep, but the READ paths are NOT: the gs305ep copies were partly wrong (dashboard.cgiandgetPoePortStatus.cgiboth 404 on real hardware). Port status isstatus.cgiand device identity/mgmt-IP isswitch_info.cgi; seeHtmlDialect.GS105PEfor the parser set.gsm7252ps(Fully Managed / XE FASTPATH): the cheetah login (uname``+``pwd-> SID cookie) was validated LIVE on 10.1.5.22, soscheme_verifiedisTrue. 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_verifiedisTrue: 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:
EnumWhich family of HTML the model’s read pages are written in, selecting the whole parser set
http_read.pyuses 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 and8021qCf.cgiVLAN checkboxes, whereas the real GS110EMX firmware never closes a port row and lists VLANs as<tr class="vlanID tableTr">rows (all GROUNDED in captures undertests/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_writescrapes 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
.cgipages have it – the surfaceHttpWriterwas 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_vlanpassed the entire test suite while failing on all four FASTPATH switches.- Return type:
- class netgear_switch.protocols.http.endpoints.XuiMgmtIpFields(address, netmask, gateway, mode, static_value, dhcp_value, apply_button)[source]¶
Bases:
objectWhich 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– addressv_1_1_1, maskv_1_2_1, gatewayv_1_3_1, protocolv_1_18_1(the hidden twin of the visible radio, which isv_1_8_1on gsm7252ps butv_1_4_1on 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– addressv_1_6_1, maskv_1_7_1, gatewayv_1_71_1, DHCP/staticv_1_5_3(Enable= DHCP,Disable= Manual, per the page’s ownxeData["xew_1_5_3_Enable"] = "DHCP"). Their/v1/ipConfiguration.htmlexists and answers 200, but it is the SERVICE-PORT interface and reads0.0.0.0/0.0.0.0on 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.
- 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,
Bases:
object- scheme: LoginScheme¶
- html_dialect: HtmlDialect = 'standard'¶
- mgmt_ip_fields: XuiMgmtIpFields | None = None¶
- netgear_switch.protocols.http.endpoints.http_spec(model)[source]¶
Return the web-UI spec for
modelor raise if it has no HTTP backend.- Return type:
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_*andm4300_*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 headedUNVERIFIED-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) returnsNonewhen the value is absent. The reader (Task 4) is the one with enough context to know whether that is fatal, and raisesHttpAuthError/HttpUnexpectedPageErroritself — this module never guesses.A table/page parser that cannot find the structure the page is documented to always contain (e.g. no
portIDrows on dashboard.cgi, nohiddenMemon 8021qMembe.cgi) raisesHttpUnexpectedPageErrornaming 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.htmlPOST 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 aGambit=<token>query param on GET, or a form field on POST; seetransport/http/client.py). ReturnsNoneif 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 doingif not tokencatches either shape.- Return type:
str | None
- netgear_switch.protocols.http.parse.parse_port_status(html)[source]¶
dashboard.cgi
portIDrows: [2]=port,[3]=link/speed,[4]=admin,[5]=name.- Return type:
- netgear_switch.protocols.http.parse.parse_port_stats(html)[source]¶
portStatistics.cgi
portIDrows: [1]=port,[2]=rx,[3]=tx,[4]=crc.
- netgear_switch.protocols.http.parse.parse_interface_stats(html)[source]¶
GS110EMX
interface_stats.htmlportIDrows (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 torx_errors, matching the same column-4 -> rx_errors conventionparse_port_statsuses for gs305ep);tx_errors/rx_packets/tx_packetsare honestlyNone– this model’s HTTP UI never reports them.
- netgear_switch.protocols.http.parse.parse_gs110emx_port_status(html)[source]¶
GS110EMX
port_settings.htmlportIDrows (OPEN-row shape, see_OPEN_ROW_RE): [1]=port#, [2]=description, [3]=linkUp/Down, [5]=speed text.GROUNDED in
gs110emx_port_settings.html(a real capture).nameis the port description (Nonewhen blank, as it is on a factory switch).admin_enabledcomes from column [4], the port’s speed/admin MODE cell (backed by thePHYSICAL_MODEhidden input): it readsAuto/a forced speed when the port is administratively enabled andDisablewhen it is not. HardcodingTruehere – 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 reportsTrue, so the two backends are compared only on port/link_up/speed_mbps.- Return type:
- netgear_switch.protocols.http.parse.parse_gs110emx_pvids(html)[source]¶
GS110EMX
vlan_pvidsetting.htmlportIDrows (OPEN-row shape): [1]=port#, [2]=PVID. GROUNDED ings110emx_pvid.html(a real capture).
- 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 ings110emx_cf8021q.html(a real capture).
- netgear_switch.protocols.http.parse.parse_gs105pe_port_status(html)[source]¶
GS105PE
status.cgiportID rows: [1]=port, [2]=linkUp/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_enabledcomes from the mode cell [3] (Auto/forced speed when enabled,Disablewhen not); hardcodingTruewould report an admin-disabled port as enabled.nameisNone– this page has no description column.- Return type:
- netgear_switch.protocols.http.parse.parse_gs105pe_pvids(html)[source]¶
GS105PE
portPVID.cgiportID rows: [1]=port, [2]=PVID. GROUNDED ings105pe_pvid.html.
- 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.
- 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 lowercaseip_address/subnet_mask/gateway_addressinputs (NOT gs110emx’s uppercase names); DHCP from thedhcpModeselect, whose<option value="1" selected>means Enable/DHCP and0means Disable/STATIC (verified live: this unit is DHCP, matching its NSDP read).- Return type:
- 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.
- netgear_switch.protocols.http.parse.parse_m4300_port_status(html)[source]¶
M4300
portsConfiguration.html-> per-port status.baseport_ifIndexis the port number (matching the SNMP backend’s ifIndex keying),baseinterfaceListing_Interfacesthe name (1/0/1),baseport_AdminModethe admin state,baseport_LinkStatus2the link (Link Up/Link Down) andbaseport_PhysicalStatusthe speed text.- Return type:
- 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. Sorx_bytes/tx_bytesare honestlyNonehere and the counts land inrx_packets/tx_packets; a byte-level comparison against SNMP is therefore not possible for this model, but a PACKET-level one is.
- netgear_switch.protocols.http.parse.parse_m4300_pvids(html)[source]¶
M4300
portPvidConfiguration.html->(port, pvid)pairs.
- netgear_switch.protocols.http.parse.parse_m4300_vlans(html)[source]¶
M4300
vlanStatus.html-> VLANs with their egress member ports.SwitchingVlanCurrentConfig_VlanCurrentEgressPortListgives the member set. This page does NOT distinguish tagged from untagged, so bothtagged_portsanduntagged_portsare left EMPTY rather than guessed – onlymember_portsis populated (see the reader’s docs).
- 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:
The
Intfcell is NOT always a physical interface – the real capture containslag 1,vlan 1and the0/15/1service port. Taking “the trailing number” (an earlier bug) reported ALL of them as physical port 1, including the switch’s own base MAC. Onlyunit/slot/portnames yield a port; entries learned on a LAG/VLAN/service interface have no physical port and are SKIPPED rather than mis-attributed.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.
- netgear_switch.protocols.http.parse.parse_m4300_sysinfo(html)[source]¶
M4300
sysInfo.html-> management IP + base MAC.IPv4 Management Addressis rendered asaddr/netmaskinside a link;System MAC Addressis a plain labelled cell. The page reports no DHCP /static indicator, somodeis honestlyUNKNOWNrather than guessed – which matches what the SNMP backend reports for this model.- Return type:
- 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 ℃</td>, which map straight ontoSensor. 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) andSensor.valueis a requiredfloat– 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.
- 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 blankNAME=v_g_1_2_1“global”/template row and page-level scalars likeNAME=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.
- 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:
- 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_bytesare honestlyNoneand a BYTE-level comparison against SNMP is impossible for this model – a PACKET-level one is not. Same honest shape asparse_m4300_stats.The
1_1_103interface column is required, not just used: the LLDP page uses the same1_1_*coordinate space, so requiring the column only this page has keeps a wrong page from parsing into plausible garbage.
- 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.
- 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_listis shared – including its refusal to expandlag Ninto physical ports.
- 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
vlanStatusexactly, but the Member Ports cell uses the Smart firmware’s1/gN/1/xgNifNames (and ranges that may mix them,"1/g48 - 1/xg52"), which the1/0/N-only_expand_port_listreads as EMPTY._expand_s3300_port_listexpands them by trailing port number, still skippinglag N. As on the sibling, tagged/untagged are left empty (the page does not distinguish them).
- 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_macsdocuments:The Port cell is not always a physical interface. The capture holds 11 entries learned on
lag 1and one on the0/5/1service 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 andlag Nare skipped rather than mis-attributed to physical port 1.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.)
- 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/xgNform. 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 (renderedc1, status “Management”), which_xe_port_from_ifacedoes 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 omissionparse_xe_macsmakes.Refuses a paginated (truncated) table rather than returning a partial FDB, exactly like the sibling parsers.
- 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”, soget_mgmt_ipreturnedUNKNOWNmode with aNoneaddress. Live 2026-07-30 on 10.1.5.11:GET /ipConfiguration.htmlanswers 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 – seehttp_read._fastpath_base_mac.- Return type:
- netgear_switch.protocols.http.parse.parse_xe_poe(html)[source]¶
GSM7252PS
poeInterfaceConfiguration.html-> per-port PoE status.power_mwis the “Output Power” column, normalised to milliwatts by_poe_power_to_mwso 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_TEXTvocabulary; 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).
- netgear_switch.protocols.http.parse.parse_xe_lldp(html)[source]¶
GSM7252PS
lldpRemoteInventory.html-> LLDP neighbours.remote_port_descis honestlyNonefor 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 (no1_1_1local-interface cells anywhere) still raises.- Return type:
- 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_ipdoes;parse_xe_sensorsdoes not, since it reads the status tables through_xe_status_rowsinstead).
- 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°C) per stack unit. A sensor readingN/A(the MAC row on the captured switch) is absent, not 0, and is skipped.FAN Status –
OK/NAper fan. Reported asunit="state"health flags, never as RPM (see_XE_HEALTHY_TEXT).Device Status – only the
RPSandPower Modulerows, askind="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.
- netgear_switch.protocols.http.parse.parse_xe_mgmt_ip(html)[source]¶
GSM7252PS
sysInfo.html-> management IP + base MAC.IPv4 Network Interfacerenders asaddr/netmaskinside a link to ipConfiguration.html;System MAC Addressis a plain labelled cell. The page reports neither a gateway nor a DHCP/static indicator, so those stayNone/UNKNOWNrather than guessed – which is exactly what the SNMP backend reports for this same device (seetests/fixtures/captures/gsm7252ps.json).- Return type:
- 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
HttpUnexpectedPageErrorwhen 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:
- netgear_switch.protocols.http.parse.parse_xui_users(html)[source]¶
userManagement.html-> the switch’s local login accounts.Raises
HttpUnexpectedPageErrorif 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:
- 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_rowsgroups 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 blankg_2_1_*template row carries no instance prefix and never reaches here in the first place.Raises
HttpUnexpectedPageErrorif 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 anenabled=Falseanswer would be a fabrication.- Return type:
- netgear_switch.protocols.http.parse.parse_poe_status(html)[source]¶
getPoePortStatus.cgi
portIDrows: [1]=port,[2]=state,[3]=power_mw.
- netgear_switch.protocols.http.parse.parse_pvids(html)[source]¶
portPVID.cgi rows:
sel="text"cell = port,sel="input"cell = PVID.
- netgear_switch.protocols.http.parse.parse_vlan_ids(html)[source]¶
8021qCf.cgi VLAN checkboxes:
name="vlanckN" value="VID".
- 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
hiddenMemstring: per-port 1=Untagged/2=Tagged/3=Excluded.
- netgear_switch.protocols.http.parse.parse_fastpath_err(html)[source]¶
The FASTPATH page’s own error banner, or
Nonewhen it reports success.Every one of these pages carries a hidden
err_flag/err_msgpair, and itscheck_error()handler alerts theerr_msgwhenerr_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.FastpathMembershipfor what the two views mean and why they can legitimately differ. RaisesHttpUnexpectedPageErrorif the page is not this page (no_rw.htmlform, nohiddenMem, no port grid) or if it carries a wire code / grid state this parser does not know – never a silently partial result.- Return type:
page.hidden_memwith justport’s code replaced bymode.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
HttpUnexpectedPageErrorif the page never renderedport.- Return type:
- netgear_switch.protocols.http.parse.parse_xui_list_page(html, *, page='XUI list page')[source]¶
A FASTPATH XUI table page -> its write form + one
XuiRowper row.Raises
HttpUnexpectedPageErrorwhen 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.htmlproves 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:
- 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:
- netgear_switch.protocols.http.parse.parse_xui_mgmt_ip(
- html,
- *,
- address_field,
- netmask_field,
- gateway_field,
- mode_field,
- page='XUI management-IP page',
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_macis leftNonehere because neither family’s mgmt page carries the switch’s BASE MAC (gsm7228ps’s page has no MAC row at all; the M4300’sv_4_4_1is the management interface’s MAC, one off from the base MAC SNMP reports) – the reader merges it fromsysinfo_path.- Return type:
- netgear_switch.protocols.http.parse.parse_gs110emx_port_form_fields(html)[source]¶
{port: {field: value}}fromport_settings.html’s per-port rows.Used to echo a port’s CURRENT
FLOW_CONTROL_MODEback 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.
- netgear_switch.protocols.http.parse.parse_reboot_ok(html)[source]¶
A reboot response that does not contain an error banner.
- Return type:
- 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) – seeHttpSysInfofor field provenance, including theip_modedata-select-valueinference. RaisesHttpUnexpectedPageErrornaming 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:
- 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_mbpsis the negotiatedspeedOperwhile the link is up, and honestlyNoneon a down port (whosespeedOperstill reports the configured rate).duplexOperModeandflowControlOperTypeare decoded against SNMP rather than against a guess – see_GOAHEAD_DUPLEX_OPERand_GOAHEAD_FLOW_CONTROL.- Return type:
- netgear_switch.protocols.http.parse.parse_goahead_pvids(body)[source]¶
GS728TPP
VLANInterfaceList->(port, pvid)pairs (physical only).
- netgear_switch.protocols.http.parse.parse_goahead_vlan_names(body)[source]¶
GS728TPP
VLANList->{vlan_id: name or 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(taggingMode1=untagged, 2=tagged), which carries the complete per-port membership – so no separate per-VLAN membership request is needed.
- netgear_switch.protocols.http.parse.parse_goahead_vlans(vlans_body, membership_body)[source]¶
GS728TPP VLANs:
VLANListnames + per-portVLANInterfaceListmembership -> fullVLANInfolist (member/tagged/untagged sets).
- 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).
- netgear_switch.protocols.http.parse.parse_goahead_poe(body)[source]¶
GS728TPP
PoEPSEInterfaceList-> per-port PoE status.power_mwisoutputPower(the live draw, mW) anddetectmaps thedetectionStatuswire code; theTestcode (5) has no RFC3621 detect equivalent and reads UNKNOWN rather than being invented.
- 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_sectionstill 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:
- 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.tempSensorValueis emitted as a numeric temperature only when it is a positive reading – a 0 withtempSensorStatus2 (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.
- netgear_switch.protocols.http.parse.parse_goahead_base_mac(body)[source]¶
GS728TPP
SystemInfo(DeviceBasicInfo) -> the switch’s base MAC.DeviceBasicInfo/MacAddrecarries the switch’s own base MAC (e.g."b0:39:56:77:54:29"). Uppercased to match the SNMP dot1dBaseBridgeAddress / NSDP identity-MAC formatting (seemodels.MgmtIpConfig.base_mac), so the HTTP and SNMP mgmt-IP reads agree field-for-field. The IPConf page has no MAC row, soget_mgmt_ipreads 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/deviceNameis 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 readssw-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:
- netgear_switch.protocols.http.parse.parse_goahead_mgmt_ip(body)[source]¶
GS728TPP
IPConf_master.xml-> management IP + gateway.IPv4InterfaceList/ifEntrycarries the address/netmask (on the mgmt VLAN interface) andIPv4GatewayList/GWEntrythe default gateway. The page carries no DHCP/static indicator and no base MAC (that is on the SystemInfo page), somodeis UNKNOWN andbase_macis None rather than guessed.- Return type:
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).
- Return type:
- 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-pageCSRFTokenwith403 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’sscreen_refresh()andresethidden()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_memoverrides the membership codes (useparse.fastpath_hidden_mem_with);Nonekeeps what the page rendered, which is required for a read (posting a DIFFERENT VLAN’s codes withsubmt=0is precisely what the browser does when you pick another VLAN, and the firmware ignores them).
- netgear_switch.protocols.http.forms.xui_row_apply_form(page, row, changes, *, button, omit=())[source]¶
The POST body that applies
changesto exactly ONE row of an XUI list.Only that row’s fields are sent (plus its
gecbcheckbox, the page’stokens, 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.navIS 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=1with oneError! Failed to Set '<column>' with '<value>'line per read-write column – even for a body that changed nothing – until the page’s ownurlListUnitfield rode along. Addingv_1_1_1alone, orv_1_3_1alone (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 thev_1_1_2type filter did NOT. SeeXuiListPage.nav.changesis 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.omitdrops 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, andxuiShed(2, ...)setsdisabled=trueon 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), becauseomitsays “do not send this”, not “this must be here”.
- netgear_switch.protocols.http.forms.xui_form_apply_form(page, changes, *, button)[source]¶
The POST body that applies
changesto an XUI detail page.Starts from every field the device rendered – so the M4300-16X’s per-page
CSRFToken(whose absence it answers with403 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.
- netgear_switch.protocols.http.forms.gs110emx_port_admin_form(*, port, enabled, flow_control_mode)[source]¶
The GS110EMX port-admin POST body (the
Gambittoken is added by the transport, exactly as it is for every other request on this model).flow_control_modeis 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.
- 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 noselectedattribute, so it is the row attribute that says which one is in force.- Type:
dhcp_modeon the GS110EMX sysInfo page
- netgear_switch.protocols.http.forms.gs110emx_switch_info_form(
- *,
- switch_name,
- dhcp_mode,
- ip_address,
- subnet_mask,
- gateway_address,
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
ACTIONthe 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_ADDRESSdoes 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
Gambitsession token is added by the transport, as for every other request on this model.
- 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.
valuesis keyed by BARE column ("2_1_1"); thev_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_columnis the write-only row-status cell ("2_1_5"on the syslog page); it is set toActive, 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.
- 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 toDeleteand the page’s Delete button clicked.
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 shapedx: yas “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 wcdbody.objis the page’s object name (VLANList,PoEPSEInterfaceList, …),actionthe verb the page’s JS used as the key (setordelete), andchildrenthe repeated child elements, each a single-key mapping whose key is the element name.- Return type:
- netgear_switch.protocols.http.goahead.port_interface_name(port)[source]¶
17->"g17", theinterfaceNameevery 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 portsg1..g28and its LAGsLAG1.. – 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:
- netgear_switch.protocols.http.goahead.tagging_mode(mode)[source]¶
The page’s
taggingModecode for a libraryVlanMode.- Return type:
- netgear_switch.protocols.http.goahead.vlan_membership_body(vlan, port_name, mode)[source]¶
One port’s membership of
vlan.EXCLUDED is not a
setwith taggingMode 0 – the page routes it to a separatedeleteaction 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:
- netgear_switch.protocols.http.goahead.poe_admin_body(port_name, enabled)[source]¶
PoE admin state, via
PoEPSEInterfaceList.adminEnable1 = 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.jscontains 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:
- 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”, andcreatePostXmlstamps 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:
- 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:
- 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:
- 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
adminState1 (up) / 2 (down) and omits every field the operator did not change – its JS sets them toundefined, which the serialiser drops – so this builder emits only what it is asked to change.descriptionisinterfaceDescription, 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:
- 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 intests/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 no1000H: 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 asduplexOperModeon the read side, where 2 means full – seeparse._GOAHEAD_DUPLEX_OPER.- Type:
duplexAdminModeas 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 sendsautoNegotiationAdminEnabled=2with the parsed rate and the duplex code.- Return type:
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:
- netgear_switch.protocols.http.crypt.merge_hash_md5(password, rand)[source]¶
Return
md5(merge(password, rand))as lowercase hex (Plus login hash).- Return type:
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:
objectOne file part of a
multipart/form-dataPOST (seepost_multipart).Used by the SSL-certificate upload flow:
fieldis the form field name the switch expects the file under (e.g. gsm7228ps’s.v_1_3_1_handle),contentis the raw file bytes served asfilenamewith MIMEcontent_type(e.g.application/octet-stream).
- class netgear_switch.protocols.http.session.HttpSession(*args, **kwargs)[source]¶
Bases:
ProtocolSynchronous authenticated web-UI session for one switch.
- class netgear_switch.protocols.http.session.AsyncHttpSession(*args, **kwargs)[source]¶
Bases:
ProtocolAsynchronous authenticated web-UI session for one switch.
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,
Bases:
objectOne 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_membershipand the fixturestests/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_portscome from the page’s ownhiddenTagged/hiddenUnTaggedifName lists, which are the CURRENT (operational) egress lists – byte-for-byte whatshow vlan <id>reports underCurrent: Includeand whatvlanStatus.html’s Member Ports cell lists. Their union therefore equalsmember_portsexactly.configuredcomes fromhidden_mem, the tri-state code the page SUBMITS, and is the CONFIGURED participation – whatshow vlanreports underConfiguredand what SNMP’sdot1qVlanStaticEgressPortsreturns. These two views genuinely disagree on real hardware: on gsm7252ps VLAN 1, ports1/0/50and1/0/51areCurrent: Exclude / Configured: Include, so they appear inconfigured(and in SNMP’s static egress) but NOT inuntagged_ports(nor in the CLI’s current list). Reads therefore report the current view (consistent withmember_ports), whileHttpWriter.set_vlan_membershipwrites and verifies the configured view – the only one the form can actually set.fieldsis 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-pageCSRFToken).
- class netgear_switch.protocols.http.types.XuiRow(prefix, checkbox, fields)[source]¶
Bases:
objectOne 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_6is column 6 of the 36th row of a 52-row table on unit 1.prefixis 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).checkboxis the row’s owngecb*selector, whose NAME differs per firmware (1.0.52.gecb5on gsm7252ps,1.0.52.gecb10on gsm7228ps,1.0.24.gecb_1_2on 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.
- class netgear_switch.protocols.http.types.XuiListPage(
- action,
- hidden,
- buttons,
- rows,
- tokens=<factory>,
- nav=<factory>,
- template=<factory>,
Bases:
objectOne render of a FASTPATH XUI list page (a table of
XuiRow).actionis 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.hiddenis that form’s trailing “redirection elements” block (submit_flag/submit_target/err_flag/err_msg/clazz_information), echoed back on every POST.buttonsmaps 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 ownxuiProcessButtonActionsENABLES the clicked button’s hidden input before submitting, so the POST carries it.tokensis the form’s page-level NON-DATA fields – in practice the per-pageCSRFTokenthe AV-era M4300-16X firmware issues. It is carried into every apply because that firmware answers403 Forbiddento 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.navis the page’s list-NAVIGATION block: thev_*fields the firmware renders in itsclass=deftestmenavigation 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 ownxeData["xalias_urlListUnit"] = "1_1_1|1_3_1|3_1_1|3_4_1") plus the interface-type filterv_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 theurlListUnitaliases to resolve the row at all (seeforms.xui_row_apply_form). Kept separate fromrowsand from thev_g_*global “apply to all” row, neither of which belongs in a one-row apply.- 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 insidedisplay:nonecells, and the page’s Apply button writes the row-status into it (xa_4_2_1targets"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 existingxui_row_apply_formdeliberately does not.
- class netgear_switch.protocols.http.types.XuiFormPage(action, hidden, buttons, fields)[source]¶
Bases:
objectOne render of a FASTPATH XUI detail page (flat
v_<a>_<b>_<c>fields).Same second-form/
hidden/buttonsshape asXuiListPage, but the values are not in repeating rows –ipConfiguration.htmland the M4300’smgmtVlanIpv4Configuration.htmlare of this kind.fieldsis 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-pageCSRFToken, which lives in exactly this map).
- 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,
Bases:
objectGS110EMX
sysInfo.html: device identity + management-IP config.GROUNDED in
tests/fixtures/http/gs110emx_sysinfo.html(a real capture from a physical GS110EMX) – seeparse.parse_sysinfo.ip_modeis inferred from the page’s<tr data-select-value="N">wrapping the DHCP-mode<select>: the real capture carries no explicitselectedattribute on either<option>(that gets set client-side by the page’s own JavaScript), sodata-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 alongsidedata-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 thoughHttpModelSpec.reads_verifiedisTruefor this model’s grounded surface overall.
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:
captured– True only for a model with a REAL captured CLI transcript backing its parsers:gsm7252ps(seetests/fixtures/cli/gsm7252ps_*.txt),m4300-24x(tests/fixtures/cli/m4300_24x_*.txt, captured live from 10.1.5.13 on 2026-07-29),m4300-16x(tests/fixtures/cli/m4300_16x_*.txt, captured live from 10.1.5.20 on 2026-07-29) andgsm7228ps(the S3300-52X;tests/fixtures/cli/gsm7228ps_*.txt, captured live from 10.1.5.11 on 2026-07-30 over telnet on port 60000).reads_verified– True for gsm7252ps (live CLI-vs-SNMP cross-verified on 10.1.5.22), m4300-24x (live CLI-verified on 10.1.5.13, 2026-07-29), m4300-16x (live CLI-verified on 10.1.5.20, 2026-07-29: ports/PVIDs/VLANs/MACs/LLDP/ sensors/stats/mgmt-IP AND PoE all correct) and gsm7228ps (live telnet CLI captured on 10.1.5.11, 2026-07-30, and cross-verified against that model’s SNMP capturetests/fixtures/captures/gsm7228ps.json).
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 bareshow vlan(like the M4300s) while KEEPING the oldershow 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 hostline 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 isdns, which is what the host table’s own column is headed (“IP Address/Hostname”).- Return type:
- netgear_switch.protocols.cli.commands.fastpath_rate(mbps)[source]¶
How FASTPATH spells a port rate in a
speedcommand: 10000 -> “10G”.The two spellings are not a guess: a live gsm7252ps offered
10,100and10Gas the forced rates on 1/0/8 (2026-08-03), so sub-gigabit rates go as bare Mbit/s and gigabit multiples take theGsuffix. 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 (seeCliModelSpec.port_speed_forced_cmd).- Return type:
- 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',
Bases:
object- port_description(text)[source]¶
Set or clear a port’s description (interface config mode).
- Return type:
- port_description_show(port)[source]¶
The per-port command that reports a description back.
- Return type:
- port_speed(speed)[source]¶
The interface-config command that applies
speed(see the fields).- Return type:
- logging_host_add(address, port, severity)[source]¶
The
logging hostline for one collector, as running-config shows it.- Return type:
- logging_host_remove(index)[source]¶
Remove the collector at
index(1-based, pershow logging hosts).- Return type:
- class netgear_switch.protocols.cli.commands.ScpCertProfile(model_key, crypto, writemem_stuff, verify_port)[source]¶
Bases:
objectPer-model FASTPATH SSL-cert-over-SCP deploy profile (pure data).
A TRANSCRIPTION of the working certbot-hook
MODEL_PROFILES(seetmp/certbot_hook_prior_art.py– grounded prior art). Only the Fully Managed FASTPATH models that take a certificate overcopy 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 (seetransport/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 whenwrite memory’s confirm has a tiny timeout, so theymust 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.
- netgear_switch.protocols.cli.commands.scp_cert_profile(model)[source]¶
Return the FASTPATH SCP cert-deploy profile for
model.Raises
UnsupportedCapabilityErrorfor any model with nocopy 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’supload_certificate_scpdispatches on.- Return type:
- netgear_switch.protocols.cli.commands.cli_spec(model)[source]¶
Return the CLI command spec for
modelor raise if it has no CLI backend.- Return type:
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.......... valuedotted-leader lines (show version,show network,show interface ethernet <intf>), handled bylabelled_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 alland the tables inshow environment). The ruler is the single source of truth for column boundaries – a naivestr.split()would corrupt cells that legitimately contain spaces ("Delivering Power","CPU Interface: 0/5/1","Not Supported"), sotable_columns/iter_table_rowsslice strictly by the ruler’s dash-group spans.
- netgear_switch.protocols.cli.parse.labelled_values(text)[source]¶
Parse
Label.......... valuedotted-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"".
- 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 containingafter(used to pick one ofshow environment’s three sub-tables). Iteration stops at the first blank line or the next ruler after the table body.
- 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 M4300show poe port info allomits theTemperaturecolumn the gsm7252ps prints). Returns[]if no ruler is found.
- 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_idis alwaysNone(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:
- 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_speedverifies 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 readsAuto/100 Fullwhile the second is blank – which is the whole reason the two are separate fields (seemodels.PortSpeed).Values measured live 2026-08-03 on gsm7252ps 10.1.5.22 port 1/0/8:
Autoby default,100 Fullafterspeed 100 full-duplex.Nonefor 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 everyget_portson unfamiliar firmware raising.- Return type:
PortSpeed | None
- netgear_switch.protocols.cli.parse.parse_port_status(text)[source]¶
show port all-> per physical-portPortStatus.admin_enabled= Admin Mode == “Enable”;link_up= Link Status == “Up”;speed_mbpsandfull_duplexboth from Physical Status, which carries them together (“1000 Full”);flow_controlfrom the Flow Mode column, found by header name (see_PORT_FLOW_HEADER). All three areNoneon a down port, which has negotiated nothing.speed_configcomes from Physical Mode and is reported whether the port is up or down – it is a setting, not a negotiation result.lag Naggregation rows are skipped (not physical ports).descriptionis honestlyNone: this command carries no ifAlias column.- Return type:
- 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).
- netgear_switch.protocols.cli.parse.parse_vlan_detail(text, *, name=None)[source]¶
show vlan <id>-> oneVLANInfo(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;Taggingthen splits it into tagged vs untagged.lag Nrows are dropped (the library models physical ports).name(fromshow vlan brief) overrides the page’s own name when supplied.- Return type:
- netgear_switch.protocols.cli.parse.parse_pvids(text)[source]¶
show vlan port all->[(port, pvid), ...]for physical ports.Uses the
Port VLAN ID Configuredcolumn (the persistent PVID), matching what dot1qPvid reports over SNMP;lag Nrows are skipped.
- netgear_switch.protocols.cli.parse.parse_mac_table(text)[source]¶
show mac-addr-table->[MacEntry, ...].MacEntry.portis theIfIndexcolumn (49 for1/0/49, 418 forlag 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.
- 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_descis honestlyNone– 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:
- netgear_switch.protocols.cli.parse.parse_poe(text)[source]¶
show poe port info all-> per-portPoEStatus.power_mwis thePower (mW)column – the live output draw, matching the vendor mW OID the SNMP backend reads.detectmaps the Status text (“Delivering Power” -> DELIVERING, “Searching” -> SEARCHING, “Disabled” -> DISABLED, anything containing “Fault” -> FAULT). This command has NO admin column, soadmin_enabledis 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
Temperaturecolumn the gsm7252ps prints – seeheader_columns.
- 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 theTemp (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).
- 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 httpcarries BOTH the plain and secure web servers:HTTP Mode (Unsecure)........................... Enabled HTTP Port...................................... 80 HTTP Mode (Secure)............................. Enabled Secure Port.................................... 443
show telnetcon– NOTshow telnet– reports the INBOUND server:Telnet Server Admin Mode....................... Enable Telnet Server Port............................. 23
show telnetdescribes 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 sshreports SSH, and its field set differs by firmware: the gsm7252ps prints noSSH Portline at all, so that port is honestly None rather than assumed to be 22.- Return type:
- 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
adminandguest, 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 onSwitchUser.access_modeand only the normalisedprivilegedflag interprets it.- Return type:
- 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 afterStatus, so neither shape can shift a value into the wrong field – the same class of trap as the VLAN PortList width.RAISES if
logging_textis not a logging block at all. This used to returnSyslogConfig(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:
- 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 hostsis 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:
- 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 networklabels the mode “Configured IPv4 Protocol”; M4300 12.0’sshow ip managementlabels it “Method” instead – accept either.- Return type:
- netgear_switch.protocols.cli.parse.parse_interface_counters(text, port)[source]¶
show interface ethernet <intf>-> one port’sPortStats.The command output carries no interface number, so
portis 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:
- netgear_switch.protocols.cli.parse.parse_port_description(text)[source]¶
The
Descriptionfield ofshow 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
Nonefor 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 allcarries NO description column – which is whyparse_port_statushonestly reportsdescription=Noneand why a CLI description write has to verify itself through here instead.- Return type:
str | None