Backend operations

The model-driven readers and writers: one pair per backend, each mapping a facade call to that protocol’s mechanism for that model. Every one can be constructed directly and pointed at a client of your choosing — including one aimed at the virtual switch.

SNMP

Model-driven SNMP read operations over a sync or async client.

netgear_switch.snmp_read.read_system_info(client)[source]

Identify a switch’s model via SNMP: sysObjectID first, then sysDescr.

Deliberately NOT a method on SnmpReader: every other read in this module requires a already-known SwitchModel (_require_snmp gates the reader’s construction), but model identification exists precisely for the case where the caller does NOT yet know/trust the model – so this takes a bare, unbound SnmpClient instead. A real-capture-confirmed sysObjectID (parse.detect_model_from_sysobjectid) wins when present; otherwise the sysDescr text heuristic (parse.detect_model_from_sysdescr) is tried. Both are honesty-constrained (never guess; key=None means genuinely unidentified – an unregistered model or a non-Netgear device).

Return type:

DetectedModel

async netgear_switch.snmp_read.async_read_system_info(client)[source]

Async twin of read_system_info – see there.

Return type:

DetectedModel

class netgear_switch.snmp_read.SnmpReader(client, model)[source]

Bases: object

get_ports()[source]
Return type:

list[PortStatus]

get_stats()[source]
Return type:

list[PortStats]

get_vlans()[source]
Return type:

list[VLANInfo]

get_pvids()[source]
Return type:

list[tuple[int, int]]

get_lldp()[source]
Return type:

list[LLDPNeighbor]

get_macs()[source]
Return type:

list[MacEntry]

get_poe()[source]
Return type:

list[PoEStatus]

get_sensors()[source]
Return type:

list[Sensor]

get_mgmt_ip()[source]
Return type:

MgmtIpConfig

get_hostname()[source]

The switch’s host name, from the standard MIB-II sysName scalar.

Standard, so this works on every SNMP model – including gs728tpp, which publishes no Netgear vendor subtree at all.

Return type:

str

get_syslog()[source]

Remote-logging configuration: whether it is on, and where it sends.

VENDOR columns, so a model with no Netgear subtree cannot serve this. gs728tpp is exactly that model – a walk of 1.3.6.1.4.1.4526 answers noSuchObject – and it is refused by name rather than returned empty, which would read as “no collectors configured”.

Return type:

SyslogConfig

get_system_info()[source]

Identify this switch’s model via sysDescr (see read_system_info).

Reuses this reader’s already-connected client. Unlike every other method here, the result does NOT depend on self.model matching the real device – useful to confirm/discover a switch’s real model via a reader that was (possibly wrongly) constructed against a different model key.

Return type:

DetectedModel

get_users()[source]

This backend does not serve local user accounts.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

Return type:

list[SwitchUser]

get_services()[source]

This backend does not serve management-service state.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

Return type:

list[ServiceStatus]

class netgear_switch.snmp_read.AsyncSnmpReader(client, model)[source]

Bases: object

async get_ports()[source]
Return type:

list[PortStatus]

async get_stats()[source]
Return type:

list[PortStats]

async get_vlans()[source]
Return type:

list[VLANInfo]

async get_pvids()[source]
Return type:

list[tuple[int, int]]

async get_lldp()[source]
Return type:

list[LLDPNeighbor]

async get_macs()[source]
Return type:

list[MacEntry]

async get_poe()[source]
Return type:

list[PoEStatus]

async get_sensors()[source]
Return type:

list[Sensor]

async get_mgmt_ip()[source]
Return type:

MgmtIpConfig

async get_syslog()[source]

Async twin of SnmpReader.get_syslog – see there.

Return type:

SyslogConfig

async get_hostname()[source]

Async twin of SnmpReader.get_hostname – see there.

Return type:

str

async get_system_info()[source]

Async twin of SnmpReader.get_system_info – see there.

Return type:

DetectedModel

async get_users()[source]

This backend does not serve local user accounts.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

Return type:

list[SwitchUser]

async get_services()[source]

This backend does not serve management-service state.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

Return type:

list[ServiceStatus]

Model-driven SNMP write/control over a write-capable sync or async client.

Parallel to snmp_read.py. Every write performs the SET then re-reads and verifies (WriteVerificationError with before/after on mismatch — a real commitFailed surfaces as an SnmpError from the transport first). Disruptive writes to a protected_ports port are refused unless force=True (design spec §6).

netgear_switch.snmp_write.decode_vlan_bitmap(bitmap)[source]

Inverse of _vlan_bitmap: which VLAN ids a switchport bitmap names.

Return type:

frozenset[int]

class netgear_switch.snmp_write.PoeCycleTimeouts(off_timeout=30.0, on_timeout=60.0, poll_interval=2.0)[source]

Bases: object

Injectable PoE-cycle deadlines (seconds). Defaults match design spec §6; tests pass tiny values so cycles run fast against the coherent mock.

off_timeout: float = 30.0
on_timeout: float = 60.0
poll_interval: float = 2.0
class netgear_switch.snmp_write.SnmpWriter(client, model, *, protected_ports=frozenset())[source]

Bases: object

Synchronous SNMP write facade over one switch.

set_poe(port, on, *, force=False)[source]
cycle_poe(
port,
*,
force=False,
timeouts=_DEFAULT_POE_TIMEOUTS,
sleep=time.sleep,
clock=time.monotonic,
)[source]
clear_poe_fault(
port,
*,
force=False,
timeouts=_DEFAULT_POE_TIMEOUTS,
sleep=time.sleep,
clock=time.monotonic,
)[source]
set_port_enabled(port, enabled, *, force=False)[source]
set_port_description(port, description, *, force=False)[source]

Set a port’s ifAlias, the standard per-port description column.

WRITABILITY MEASURED 2026-08-03 on a GS728TPP (10.2.5.10, firmware 6.0.1.30): a SET of ifAlias.17 was accepted and read straight back through get_ports.

Clearing it (description="") is the case that needed transport work rather than a new OID: snmpset ... s "" is refused by the net-snmp CLI itself, so the transport sends an empty OCTET STRING as an empty hex string instead (see _set_argv). Without that, a description could be set and never removed.

set_pvid(port, vlan, *, force=False)[source]
set_vlan_membership(vlan, port, mode, *, force=False)[source]
create_vlan(vlan, name, *, force=False)[source]
delete_vlan(vlan, *, force=False)[source]
set_port_speed(port, speed, *, force=False)[source]

This backend cannot configure a port’s speed.

Refused by name rather than approximated. What SNMP offers here is ifSpeed/ifHighSpeed, and those report the rate the link NEGOTIATED – writing one would be writing a counter, not a setting. The column that would genuinely serve this is MAU-MIB’s ifMauDefaultType/ifMauAutoNegAdminStatus (mib-2.26); no switch here has been walked for it, so its presence is UNKNOWN rather than absent, and the 2026-08-03 OID sweep does not settle it (that sweep covered the 4526 VENDOR subtree only). Use a CLI backend, or establish the MAU subtree first.

set_flow_control(port, enabled, *, force=False)[source]

This backend cannot configure flow control.

Refused by name. EtherLike-MIB’s dot3PauseAdminMode is the column that would serve this, and it is READ on the one model that publishes it (the GS728TPP) – but no SET has ever been issued against it here, so whether the agent accepts one is unknown. This library does not offer a write it has never seen succeed.

add_syslog_collector(host, *, port=514, severity=6, force=False)[source]

This agent will not CREATE a syslog host row. MEASURED, not assumed.

Probed on m4300-24x 10.1.5.13 (FASTPATH 12.0.13.8, 2026-08-05) with the Read/Write community, against a free index. Five mechanisms, five refusals, with the agent’s own SMI error-status:

createAndGo(4) + every column, one PDU -> inconsistentValue
createAndWait(5) alone                 -> inconsistentValue
createAndGo(4) alone                   -> inconsistentValue
the value columns alone (auto-create?) -> commitFailed
active(1) at a row that does not exist -> commitFailed

The same agent ACCEPTS a SET of every column of an EXISTING row, and accepts destroy – see remove_syslog_collector – so this is the agent declining row creation specifically, not a permissions problem. (The first run of that probe used the READ community and “refused” everything, which is CLAUDE.md principle 4’s own example. Ask the switch with show snmpcommunity.)

Same shape as the GS728TPP’s refusal to create a VLAN row. Add over a CLI backend, where the command is the device’s own running-config line.

remove_syslog_collector(host, *, force=False)[source]

Remove a collector by writing RowStatus destroy(6) to its row.

LIVE-VERIFIED on m4300-24x 10.1.5.13 (2026-08-05): a throwaway collector added over the CLI was destroyed with a single SET of <base>.14.1.4.5.1.7.<index> = 6, and the switch’s own show logging hosts confirmed the row was gone.

Note the asymmetry, which is the agent’s and not this library’s: it DESTROYS rows but refuses to CREATE them (see add_syslog_collector).

<index> is the table’s own row index – the OID instance, which get_syslog surfaces as SyslogServer.index. It is SPARSE, so it is read fresh here and never derived from a row’s position; deriving it addresses the wrong row, and the agent accepts that as a silent no-op.

set_syslog_enabled(enabled, *, force=False)[source]

Turn remote syslog on or off.

Writes the vendor logging admin-mode column (<base>.14.1.4.1.0), whose enum is 1 = enabled, 2 = disabled – established from captured CLI rather than assumed, see oids.VendorOids.syslog_admin_mode.

WRITABILITY MEASURED 2026-08-02 by SETting each switch the value it already held, which cannot change device state but still distinguishes a writable column from a read-only one: m4300-24x (10.1.5.13), gsm7252ps (10.1.5.22) and gsm7228ps (10.1.5.11) all accepted it.

Deliberately narrower than get_syslog reads. Adding or removing a COLLECTOR means creating a row in the host table, which needs a row-status write that has not been driven against hardware; offering it here on the strength of the read alone would be the inference this project refuses.

Not force-gated: toggling log delivery cannot strand a switch and is reversible by writing the old value back.

set_hostname(name, *, force=False)[source]

Set the switch’s host name via the standard MIB-II sysName.

GROUNDED, unlike set_mgmt_ip below: sysName was confirmed writable on every SNMP model in this fleet on 2026-08-02, by SETting each switch the value it already held. See oids.SYS_NAME for the hosts and communities, and for why this is NOT the same value as the FASTPATH hostname running-config directive.

Not force-gated: renaming a switch cannot strand it the way a mgmt-IP write can, and it is trivially reversible by writing the old name back. force is accepted so the signature matches every other writer.

set_mgmt_ip(address, netmask, gateway, *, force=False)[source]

Set the switch’s own management IP (address/netmask/gateway).

UNVERIFIED write path (see oids.VendorOids mgmt_write_* fields): the exact writable OIDs are placeholders pending Slice 7 hardware capture, so this is force-gated (a wrong mgmt-IP write can strand the switch — design spec §11.1). DHCP-mode switching is intentionally NOT offered here because even its read OID is unverified; do not fabricate it.

class netgear_switch.snmp_write.AsyncSnmpWriter(client, model, *, protected_ports=frozenset())[source]

Bases: object

Asynchronous SNMP write facade (mirror of SnmpWriter).

async set_poe(port, on, *, force=False)[source]
async cycle_poe(
port,
*,
force=False,
timeouts=_DEFAULT_POE_TIMEOUTS,
sleep=asyncio.sleep,
clock=time.monotonic,
)[source]
async clear_poe_fault(
port,
*,
force=False,
timeouts=_DEFAULT_POE_TIMEOUTS,
sleep=asyncio.sleep,
clock=time.monotonic,
)[source]
async set_port_enabled(port, enabled, *, force=False)[source]
async set_port_description(port, description, *, force=False)[source]

Async twin of SnmpWriter.set_port_description – see it.

async set_pvid(port, vlan, *, force=False)[source]
async set_vlan_membership(vlan, port, mode, *, force=False)[source]
async create_vlan(vlan, name, *, force=False)[source]
async delete_vlan(vlan, *, force=False)[source]
async set_port_speed(port, speed, *, force=False)[source]

This backend cannot configure a port’s speed.

Refused by name rather than approximated. What SNMP offers here is ifSpeed/ifHighSpeed, and those report the rate the link NEGOTIATED – writing one would be writing a counter, not a setting. The column that would genuinely serve this is MAU-MIB’s ifMauDefaultType/ifMauAutoNegAdminStatus (mib-2.26); no switch here has been walked for it, so its presence is UNKNOWN rather than absent, and the 2026-08-03 OID sweep does not settle it (that sweep covered the 4526 VENDOR subtree only). Use a CLI backend, or establish the MAU subtree first.

async set_flow_control(port, enabled, *, force=False)[source]

This backend cannot configure flow control.

Refused by name. EtherLike-MIB’s dot3PauseAdminMode is the column that would serve this, and it is READ on the one model that publishes it (the GS728TPP) – but no SET has ever been issued against it here, so whether the agent accepts one is unknown. This library does not offer a write it has never seen succeed.

async add_syslog_collector(host, *, port=514, severity=6, force=False)[source]

Async twin of SnmpWriter.add_syslog_collector – see it for the five measured refusals.

async remove_syslog_collector(host, *, force=False)[source]

Async twin of SnmpWriter.remove_syslog_collector – see it.

async set_syslog_enabled(enabled, *, force=False)[source]

Async twin of SnmpWriter.set_syslog_enabled – see there.

async set_hostname(name, *, force=False)[source]

Async twin of SnmpWriter.set_hostname – see there.

async set_mgmt_ip(address, netmask, gateway, *, force=False)[source]

Set the switch’s own management IP (address/netmask/gateway).

UNVERIFIED write path (see oids.VendorOids mgmt_write_* fields): the exact writable OIDs are placeholders pending Slice 7 hardware capture, so this is force-gated (a wrong mgmt-IP write can strand the switch — design spec §11.1). DHCP-mode switching is intentionally NOT offered here because even its read OID is unverified; do not fabricate it.

NSDP

Model-driven NSDP read operations over a sync or async client.

Parallel to snmp_read.py. Maps NSDP TLVs onto the SAME public models types: port link/speed/flow-control, per-port descriptions, byte/CRC statistics, VLAN membership, PVID and management IP.

MAC/FDB, LLDP, sensors and PoE raise UnsupportedCapabilityError, and that is now MEASURED rather than asserted – see _NO_MACS & co. below for the captured evidence and how to reproduce it.

class netgear_switch.nsdp_read.NsdpReader(client, model)[source]

Bases: object

Synchronous NSDP read facade over one switch.

get_ports()[source]
Return type:

list[PortStatus]

get_stats()[source]
Return type:

list[PortStats]

get_vlans()[source]
Return type:

list[VLANInfo]

get_pvids()[source]
Return type:

list[tuple[int, int]]

get_hostname()[source]

The switch’s host name, from the NSDP HOSTNAME tag (0x0003).

The same value SNMP would report as sysName – except that a Plus switch has no SNMP agent at all, which is why NSDP carries it.

A switch that has never been named answers the tag with nothing, and that is a real answer rather than a failure: unlike SNMP’s sysName, which is a mandatory scalar, this tag is genuinely optional.

Return type:

str

get_mgmt_ip()[source]
Return type:

MgmtIpConfig

get_device()[source]

Return the COMPLETE raw NsdpDevice for this switch: every tag parse_device knows how to decode, in one round trip. Unlike the other get_* ops above, this returns the NSDP-native shape (including the raw port-status speed byte) rather than mapping onto the shared models types – callers that need the full protocol surface (e.g. gdoc2netcfg’s DiscoveryDB) use this instead of the per-field ops.

Return type:

NsdpDevice

get_macs()[source]
Return type:

list[MacEntry]

get_lldp()[source]
Return type:

list[LLDPNeighbor]

get_sensors()[source]
Return type:

list[Sensor]

get_poe()[source]
Return type:

list[PoEStatus]

get_users()[source]

This backend does not serve local user accounts.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

Return type:

list[SwitchUser]

get_services()[source]

This backend does not serve management-service state.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

Return type:

list[ServiceStatus]

get_syslog()[source]

This backend does not serve remote-logging configuration.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

Return type:

SyslogConfig

class netgear_switch.nsdp_read.AsyncNsdpReader(client, model)[source]

Bases: object

Asynchronous NSDP read facade (mirror of NsdpReader).

async get_ports()[source]
Return type:

list[PortStatus]

async get_stats()[source]
Return type:

list[PortStats]

async get_vlans()[source]
Return type:

list[VLANInfo]

async get_pvids()[source]
Return type:

list[tuple[int, int]]

async get_hostname()[source]

Async twin of NsdpReader.get_hostname – see there.

Return type:

str

async get_mgmt_ip()[source]
Return type:

MgmtIpConfig

async get_device()[source]

Async twin of NsdpReader.get_device – see there.

Return type:

NsdpDevice

async get_macs()[source]
Return type:

list[MacEntry]

async get_lldp()[source]
Return type:

list[LLDPNeighbor]

async get_sensors()[source]
Return type:

list[Sensor]

async get_poe()[source]
Return type:

list[PoEStatus]

async get_users()[source]

This backend does not serve local user accounts.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

Return type:

list[SwitchUser]

async get_services()[source]

This backend does not serve management-service state.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

Return type:

list[ServiceStatus]

async get_syslog()[source]

This backend does not serve remote-logging configuration.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

Return type:

SyslogConfig

Model-driven NSDP write/control over a write-capable sync or async client.

Parallel to snmp_write.py. Every write performs the NSDP WRITE_REQUEST (authenticated with whichever scheme the switch advertises – v1 XOR or the v2 salted challenge-response) then re-reads to verify (WriteVerificationError with before/after on mismatch; a bad password / transport error surfaces as NsdpError from the client first). Disruptive per-port writes to a protected_ports port are refused unless force=True.

VLAN create/delete ARE implemented here (create = write the VLAN_MEMBERS tag for a not-yet-existing VLAN id; delete = the VLAN_DESTROY action tag 0x2C00), replacing a previous unproven “NSDP has no VLAN create/destroy tag” refusal – see protocols/nsdp/write.py::vlan_destroy_tlv for the ngadmin evidence.

FIRMWARE NOTE, measured 2026-07-29/30: GS110EMX firmware 1.0.2.8 refuses the v1 XOR password outright (WRITE_REQUEST answered error=13/14 on ATTR_PASSWORD) and requires the v2 salted auth. That scheme is implemented and live-verified (see protocols/nsdp/auth.py), so writes DO work on that firmware; the client picks the scheme from AUTH_V2_ENCPASS automatically. check_result names the blamed attribute when a switch still refuses.

class netgear_switch.nsdp_write.NsdpWriter(client, model, *, password, protected_ports=frozenset())[source]

Bases: object

Synchronous NSDP write facade over one switch.

set_hostname(name, *, force=False)[source]

Set the switch’s host name over NSDP (tag 0x0003).

The Plus family’s only write route for this: those switches have no SNMP agent and no CLI, so without this they cannot be renamed at all.

Not force-gated – renaming cannot strand a switch, and it is reversible by writing the old name back. Verified by re-reading the tag.

set_port_description(port, description, *, force=False)[source]

Set a port’s description over NSDP tag 0xB000 (PORT_NAME).

The READ encoding is measured on three real GS110EMX units – one TLV per port, byte 0 the port number and the rest the description – and the write is that same shape (port_name_tlv). The write itself has NOT been exercised against hardware: the three Plus units in this fleet were powered off when it was attempted. Verify-after-write below is the guard that makes that safe to ship – a wrong shape cannot pass silently.

set_pvid(port, vlan, *, force=False)[source]
set_vlan_membership(vlan, port, mode, *, force=False)[source]
set_mgmt_ip(address, netmask, gateway, *, force=False)[source]
set_poe(port, on, *, force=False)[source]
cycle_poe(port, *, force=False, timeouts=None)[source]
clear_poe_fault(port, *, force=False, timeouts=None)[source]
set_port_enabled(port, enabled, *, force=False)[source]
create_vlan(vlan, name, *, force=False)[source]

Create vlan by writing an EMPTY VLAN_MEMBERS record for it.

NSDP has no separate “add VLAN” action: the 802.1Q VLAN table is the set of VLAN ids that have a VLAN_MEMBERS (0x2800) record, so writing one for an id the switch does not yet list is the create. (ngadmin does the same thing – ngadmin_setVLANDotConf only ever writes the membership attribute, and its VLAN list is whatever comes back from reading it.) name is accepted and ignored: the tag carries a VLAN id and two port bitmaps and no name field, and there is no name tag in the measured inventory – so a name is silently unstorable here rather than pretended.

delete_vlan(vlan, *, force=False)[source]

Delete vlan with the VLAN_DESTROY action tag (0x2C00).

Grounded in ngadmin’s ngadmin_VLANDestroy – see protocols/nsdp/write.py::vlan_destroy_tlv. Deleting a VLAN drops every member port out of it, so it is force-gated exactly like the other disruptive writes.

set_port_speed(port, speed, *, force=False)[source]

This backend cannot configure a port’s speed.

Refused by name rather than approximated: NSDP’s per-port speed byte is a LINK-STATE code, not a setting – its own value 0x00 is DOWN (see protocols.nsdp.types.LinkSpeed), which a configuration field could not mean. No speed/duplex ADMIN tag has been identified in the tag inventory captured from live GS110EMX units.

set_flow_control(port, enabled, *, force=False)[source]

This backend cannot configure flow control.

Refused by name: NSDP’s PORT_STATUS carries a flow-control byte that this library READS, but no write TLV for it has been identified in the tag inventory captured from live GS110EMX units.

add_syslog_collector(host, *, port=514, severity=6, force=False)[source]

This backend cannot add a syslog collector.

Refused by name: NSDP has no logging surface at all. That is measured absence – an exhaustive tag sweep of a live GS110EMX turned up no syslog tag of any kind, which is the same finding that keeps NSDP off get_syslog.

remove_syslog_collector(host, *, force=False)[source]

This backend cannot remove a syslog collector.

Refused by name: NSDP has no logging surface at all. That is measured absence – an exhaustive tag sweep of a live GS110EMX turned up no syslog tag of any kind, which is the same finding that keeps NSDP off get_syslog.

set_syslog_enabled(enabled, *, force=False)[source]

This backend does not serve a remote-logging toggle.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

class netgear_switch.nsdp_write.AsyncNsdpWriter(client, model, *, password, protected_ports=frozenset())[source]

Bases: object

Asynchronous NSDP write facade (mirror of NsdpWriter).

async set_port_description(port, description, *, force=False)[source]

Async twin of NsdpWriter.set_port_description – see it.

async set_pvid(port, vlan, *, force=False)[source]
async set_vlan_membership(vlan, port, mode, *, force=False)[source]
async set_mgmt_ip(address, netmask, gateway, *, force=False)[source]
async set_poe(port, on, *, force=False)[source]
async cycle_poe(port, *, force=False, timeouts=None)[source]
async clear_poe_fault(port, *, force=False, timeouts=None)[source]
async set_port_enabled(port, enabled, *, force=False)[source]
async create_vlan(vlan, name, *, force=False)[source]

Async twin of NsdpWriter.create_vlan – see there.

async delete_vlan(vlan, *, force=False)[source]

Async twin of NsdpWriter.delete_vlan – see there.

async set_hostname(name, *, force=False)[source]

This backend does not serve a host-name write.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

async set_port_speed(port, speed, *, force=False)[source]

This backend cannot configure a port’s speed.

Refused by name rather than approximated: NSDP’s per-port speed byte is a LINK-STATE code, not a setting – its own value 0x00 is DOWN (see protocols.nsdp.types.LinkSpeed), which a configuration field could not mean. No speed/duplex ADMIN tag has been identified in the tag inventory captured from live GS110EMX units.

async set_flow_control(port, enabled, *, force=False)[source]

This backend cannot configure flow control.

Refused by name: NSDP’s PORT_STATUS carries a flow-control byte that this library READS, but no write TLV for it has been identified in the tag inventory captured from live GS110EMX units.

async add_syslog_collector(host, *, port=514, severity=6, force=False)[source]

This backend cannot add a syslog collector.

Refused by name: NSDP has no logging surface at all. That is measured absence – an exhaustive tag sweep of a live GS110EMX turned up no syslog tag of any kind, which is the same finding that keeps NSDP off get_syslog.

async remove_syslog_collector(host, *, force=False)[source]

This backend cannot remove a syslog collector.

Refused by name: NSDP has no logging surface at all. That is measured absence – an exhaustive tag sweep of a live GS110EMX turned up no syslog tag of any kind, which is the same finding that keeps NSDP off get_syslog.

async set_syslog_enabled(enabled, *, force=False)[source]

This backend does not serve a remote-logging toggle.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

HTTP web UI

Model-driven web-UI read operations over a sync or async HttpSession.

Parallel to snmp_read.py/nsdp_read.py. Construction is gated on HttpModelSpec.reads_verified: a model whose web reads are still UNVERIFIED-pending-capture (gsm7228ps cheetah/S3300) refuses to construct rather than return fabricated data – the facade never gets a plausible-but-wrong result from an unverified scrape. Ops a model’s HTTP surface genuinely does not expose (e.g. gs110emx has no PoE, so PoE/MAC/LLDP/ sensor reads; see protocols/http/endpoints.py) raise UnsupportedCapabilityError honestly instead of silently returning [], via _require_path’s per-op None-path check below. gs110emx’s web UI DOES cover the full NSDP read surface (ports/stats/VLANs/PVIDs/mgmt-IP) – the port/stats/PVID/VLAN-list parsers are selected by HttpModelSpec.html_dialect and get_mgmt_ip by sysinfo_path because gs110emx’s pages use a different HTML dialect than gs305ep’s (see protocols/http/parse.py).

The gsm7252ps (XE_FASTPATH) web UI covers EVERY read op this library has – ports/stats/PVIDs/VLANs/MACs/PoE/LLDP plus sensors and mgmt-IP from sysInfo.html – so no op is carved out for that model. Its spec says reads_verified=True: the HTTP output was cross-verified against SNMP on the live switch (10.1.5.22). The honesty gate (which refuses to construct while reads_verified is False) still guards models that have NOT been cross-verified, e.g. gsm7228ps.

All page-path selection and HTML-to-model conversion lives in the module-level helpers below (pure, I/O-free); HttpReader/AsyncHttpReader differ only in whether session.get_page/post_form is awaited.

netgear_switch.http_read.fastpath_membership_paths(spec, model_key)[source]

(GET page, POST target) for the managed FASTPATH VLAN-membership page.

Both must be populated for a managed model; a None here is a spec defect, not a device limitation, so it raises with the field name rather than degrading the read (principle 1: fail loud).

Return type:

tuple[str, str]

class netgear_switch.http_read.HttpReader(session, model)[source]

Bases: object

Synchronous web-UI read facade over one switch.

get_ports()[source]
Return type:

list[PortStatus]

get_stats()[source]
Return type:

list[PortStats]

get_poe()[source]
Return type:

list[PoEStatus]

get_pvids()[source]
Return type:

list[tuple[int, int]]

get_vlans()[source]
Return type:

list[VLANInfo]

read_fastpath_membership(vlan)[source]

One VLAN’s membership page from the managed FASTPATH web UI.

The GET shows whichever VLAN the firmware last selected, so any other VLAN needs the form POST the browser’s own screen_refresh() makes: the full field set with submt=0, which re-renders WITHOUT applying (confirmed live – re-reading a VLAN returned a byte-identical page). Shared by get_vlans and HttpWriter.set_vlan_membership.

Return type:

FastpathMembership

get_macs()[source]
Return type:

list[MacEntry]

get_lldp()[source]
Return type:

list[LLDPNeighbor]

get_sensors()[source]
Return type:

list[Sensor]

get_hostname()[source]

The switch’s host name, from its device-identity page.

Only the two dialects whose identity page actually carries the field can serve this: gs110emx’s sysInfo.html and gs105pe’s switch_info.cgi, both of which already expose it as HttpSysInfo.switch_name. Every other dialect’s identity page has no such field, and is refused by name rather than returning “” – an empty string is a real host name on a switch that has never been named, so it must not double as “this backend cannot tell you”.

Return type:

str

get_mgmt_ip()[source]
Return type:

MgmtIpConfig

get_users()[source]

Local login accounts, from this model’s user-management page.

Refuses by name on a model whose UI has no such page located, rather than returning empty: an empty answer would be indistinguishable from a switch that genuinely has no accounts.

Return type:

list[SwitchUser]

get_services()[source]

Management-service state, one page per service.

Refuses by name unless ALL FOUR pages are located for this model – see _service_paths.

Return type:

list[ServiceStatus]

get_syslog()[source]

Remote-logging configuration, from this model’s syslog page.

Refuses by name on a model whose UI has no such page located, rather than returning empty: an empty answer would be indistinguishable from a switch that genuinely logs nowhere.

Return type:

SyslogConfig

class netgear_switch.http_read.AsyncHttpReader(session, model)[source]

Bases: object

Asynchronous web-UI read facade (mirror of HttpReader).

async get_ports()[source]
Return type:

list[PortStatus]

async get_stats()[source]
Return type:

list[PortStats]

async get_poe()[source]
Return type:

list[PoEStatus]

async get_pvids()[source]
Return type:

list[tuple[int, int]]

async get_vlans()[source]
Return type:

list[VLANInfo]

async read_fastpath_membership(vlan)[source]

Async twin of HttpReader.read_fastpath_membership (see its docs).

Return type:

FastpathMembership

async get_macs()[source]
Return type:

list[MacEntry]

async get_lldp()[source]
Return type:

list[LLDPNeighbor]

async get_sensors()[source]
Return type:

list[Sensor]

async get_mgmt_ip()[source]
Return type:

MgmtIpConfig

async get_users()[source]

Local login accounts, from this model’s user-management page.

Refuses by name on a model whose UI has no such page located, rather than returning empty: an empty answer would be indistinguishable from a switch that genuinely has no accounts.

Return type:

list[SwitchUser]

async get_services()[source]

Management-service state, one page per service.

Refuses by name unless ALL FOUR pages are located for this model – see _service_paths.

Return type:

list[ServiceStatus]

async get_syslog()[source]

Remote-logging configuration, from this model’s syslog page.

Refuses by name on a model whose UI has no such page located, rather than returning empty: an empty answer would be indistinguishable from a switch that genuinely logs nowhere.

Return type:

SyslogConfig

async get_hostname()[source]

This backend does not serve a host name field.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

Return type:

str

Model-driven web-UI write operations with verify-after-write + guards.

Parallel to snmp_write.py. Every mutating op: (1) enforces protected_ports on disruptive ports unless force=True; (2) GETs the target page to scrape the fresh CSRF hash; (3) POSTs the encoded form; (4) re-GETs and re-parses to confirm the change actually took — raising WriteVerificationError(before, after) on divergence, NEVER silently succeeding.

set_port_enabled, set_mgmt_ip and clear_poe_fault used to raise UnsupportedCapabilityError for EVERY model. They were missing implementations, not device limitations, and are now built:

  • set_port_enabled — the managed models’ portsConfiguration.html Admin Mode column (LIVE-VERIFIED on all four: gsm7252ps, gsm7228ps, m4300-24x, m4300-16x), and the GS110EMX’s differently-shaped port_settings.html Physical Mode POST (LIVE-VERIFIED on 10.1.5.26).

  • clear_poe_fault — the managed PoE page’s hidden write-only “Port Reset” column driven by its RESET button (LIVE-VERIFIED on gsm7252ps, gsm7228ps and m4300-16x), and the Plus UI’s PoEPortConfig.cgi reset.

  • set_mgmt_ip — each managed model’s own management-IP form. The APPLY is deliberately NOT live-verified: doing so would move a real switch’s management address and drop the session mid-write. See the method docstring for exactly what is and is not proven.

Where an op still raises for a model, the refusal names captured device output: the M4300-24X’s PoE page has zero rows because the SKU has no PSE, and the GS110EMX has no PoE page at all. gsm7252ps used to be on that list – its PoE form was recorded as answering err_flag=1 to every write – and it did not belong there: the body was missing the page’s own list-unit field, and once it rides along the write lands (see endpoints.py and forms.xui_row_apply_form).

VLAN membership on the MANAGED (FASTPATH/Cheetah) models — gsm7252ps, gsm7228ps/S3300 and both M4300 SKUs — goes through _set_fastpath_membership against the live-discovered switching/dot1q/vlan_port_cfg_rw.html endpoint; the Plus-class models keep the 8021qMembe.cgi path. That is a fifth step for these pages: they answer HTTP 200 even when they REFUSE the write, reporting it in a hidden err_flag/err_msg pair, so the apply response is checked for that before verification (see _raise_on_fastpath_err_flag) and the switch’s own message is what the caller gets.

class netgear_switch.http_write.HttpWriter(session, model, *, protected_ports=frozenset())[source]

Bases: object

set_port_description(port, description, *, force=False)[source]

Label a port through the ports page’s interfaceDescription.

XML-API only for now: that page carries the field and the read side already parses it. The FASTPATH XUI port pages have a description column too, but its cell id has not been captured, and guessing one would post into an unknown cell.

set_poe(port, on, *, force=False)[source]
cycle_poe(port, *, force=False, timeouts=None)[source]
clear_poe_fault(port, *, force=False, timeouts=None)[source]

Clear a PoE fault on port by re-running the port’s PoE detection.

On the managed FASTPATH models this is the page’s own hidden write-only “Port Reset” column driven by its RESET button – the same mechanism the CLI backend uses (poe reset) and the same one SnmpWriter emulates with an admin off/on re-arm. On the Plus-class CGI UI it is the PoEPortConfig.cgi reset form, identical to cycle_poe: a Plus switch has no separate “clear fault” action, the fault clears when detection re-runs.

set_pvid(port, vlan, *, force=False)[source]
set_vlan_membership(vlan, port, mode, *, force=False)[source]
create_vlan(vlan, name, *, force=False)[source]
delete_vlan(vlan, *, force=False)[source]
reboot(*, force=False)[source]
set_port_enabled(port, enabled, *, force=False)[source]

Set port port’s admin mode through portsConfiguration.html.

LIVE-VERIFIED 2026-07-30 on ALL FOUR managed switches, each on a link-down, undescribed port, as disable -> re-read -> enable -> re-read: gsm7252ps 10.1.5.22 port 36, gsm7228ps 10.1.5.11 port 12 (1/g12), m4300-24x 10.1.5.13 port 16, m4300-16x 10.1.5.20:49152 port 15. In every case the apply answered err_flag=0 and a full re-read of the table showed the target row’s Admin Mode cell changed and EVERY other cell of every other row byte-identical.

set_mgmt_ip(address, netmask, gateway, *, force=False)[source]

Set the switch’s STATIC management address through its web UI.

The page and field names are per model (see XuiMgmtIpFields), both live-captured 2026-07-30. Disruptive by definition – it moves the address the caller is talking to – so it needs force=True, and the capability is resolved BEFORE the force gate exactly like reboot.

The APPLY on this page is UNVERIFIED against live hardware, and deliberately so. Every other write in this file was proven by doing it to a real switch and reading it back; this one was not, because applying it to any of the four reachable switches would have moved that switch’s management address, dropped the session mid-write and risked stranding a device on a network nobody could reach. What IS verified live on all four: the page exists and is the right one, its field names/values are the device’s own (read back through get_mgmt_ip), and the surrounding machinery – the submit_flag=8 apply flag, the whole-form echo, the button field, the err_flag refusal check – is the same machinery proven by set_port_enabled and set_vlan_membership on these exact pages. The verify-after-write below is therefore real: if the switch refuses, the caller is told.

upload_certificate(cert_pem, key_pem, *, force=False)[source]

Upload an HTTPS SSL server certificate (combined cert+key PEM).

GROUNDED for gsm7228ps/S3300 (multipart form) and gs728tpp (GoAhead XML-API) – see endpoints.py and _cert_upload_xml. A model whose real mechanism is a non-HTTP SCP copy (m4300/gsm7252ps) raises NotImplementedError pointing at upload_certificate_scp; a model with no known mechanism raises UnsupportedCapabilityError. Disruptive (replaces the running certificate), so force=True is required – capability is resolved BEFORE the force gate, mirroring reboot.

set_hostname(name, *, force=False)[source]

Set the host name, where this dialect’s identity page carries one.

Two dialects, and they are nothing alike:

  • GoAhead XML API – DeviceBasicInfo/deviceName IS the host name (MEASURED: it reads byte-for-byte what SNMP reports through sysName).

  • GS110EMX – an ordinary form POST, but the host name shares that form with the MANAGEMENT ADDRESS, so it is a read-modify-write. See _set_gs110emx_hostname.

Every other dialect is refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

set_port_speed(port, speed, *, force=False)[source]

Set a port’s speed/duplex through the ports page’s admin fields.

XML-API only. That page’s Standard802_3List carries autoNegotiationAdminEnabled/speedAdmin/duplexAdminMode, the read side already parses them, and the exact encoding is transcribed from the page’s own submit JS (see goahead.port_speed_body). The FASTPATH XUI port pages have a Speed control too, but its cell id has not been captured, and guessing one would post into an unknown cell.

A rate the page’s own dropdown does not offer is refused by name. That list is device evidence, not a house rule: the slctPortSpeed <option> set is 10/100 half-or-full, 1000 FULL ONLY, and Auto. Note this UI DOES offer a forced 1000 where the FASTPATH CLI does not – which is exactly why that refusal lives in CliWriter and not in PortSpeed.

Disruptive – applying a speed bounces the link – so it honours protected_ports.

set_flow_control(port, enabled, *, force=False)[source]

This backend cannot configure flow control on this UI.

Refused by name, and this one is a MEASURED absence rather than an unsearched one. The GoAhead ports page publishes flowControlAdminType/flowControlOperType but has no control for either: its slct* selects are Admin Mode and Port Speed only, and its submit builder emits no flow-control field at all (see tests/fixtures/http/gs728tpp_ports.xml). Flow control lives on a different page of that UI which has not been captured, and the FASTPATH XUI equivalent has not either.

add_syslog_collector(host, *, port=514, severity=6, force=False)[source]

This UI will not accept a collector ADD – established, not assumed.

The template row is real and reachable: the page renders v_g_2_1_1..``v_g_2_1_7`` in the served HTML, and this library can build the body. What the FIRMWARE does with it, driven live against m4300-24x 10.1.5.13 on 2026-08-05, is refuse – HTTP 200 with Error! Failed to Set '<field>' with '<value>' lines, and the collector table unchanged (checked through the switch’s own CLI, which is an independent witness):

  • the body as first built -> failed on ‘Host Address’ and ‘Port’

  • plus IP Address Type “IPv4” -> those two passed; the two ENUMS then failed (‘IP Address Type’, ‘Severity Filter’)

  • plus enums as INDICES instead of labels (xa_2_1_4 lists Emergency..Debug in standard syslog order, xa_2_1_7 lists Unknown,IPv4,IPv6,DNS, so IPv4 = 1) -> the enums passed and ‘Host Address’ failed again

  • plus row-status “Add” instead of “Active” (the page’s own xeleValue_2_1_5) -> unchanged

So three of the four fields can be made to stick and the address cannot, which says something in the create path is still unaccounted for. Rather than keep guessing at a production switch – the trial-and-error principle 4 exists to stop – this refuses, and the remaining step is ONE capture of a real browser Add submission to diff against.

The DELETE path on the same page DOES work and is live-verified; see remove_syslog_collector. Add over a CLI backend.

remove_syslog_collector(host, *, force=False)[source]

Remove a collector by marking its row-status Delete.

The page’s Delete action array writes "Delete" into the same write-only cell an Add sets to "Active" (xa_4_3_1 -> "2_1_5|g_2_1_5"). The row is addressed by its OWN rendered fields and checkbox, so no index arithmetic is involved – unlike the CLI and SNMP routes, whose sparse table index bit once already.

set_syslog_enabled(enabled, *, force=False)[source]

This backend does not serve a remote-logging toggle.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

class netgear_switch.http_write.AsyncHttpWriter(session, model, *, protected_ports=frozenset())[source]

Bases: object

async set_port_description(port, description, *, force=False)[source]

Async twin of HttpWriter.set_port_description – see it.

async set_poe(port, on, *, force=False)[source]
async cycle_poe(port, *, force=False, timeouts=None)[source]
async clear_poe_fault(port, *, force=False, timeouts=None)[source]

Async twin of HttpWriter.clear_poe_fault (see its docs).

async set_pvid(port, vlan, *, force=False)[source]
async set_vlan_membership(vlan, port, mode, *, force=False)[source]
async create_vlan(vlan, name, *, force=False)[source]
async delete_vlan(vlan, *, force=False)[source]
async reboot(*, force=False)[source]
async set_port_enabled(port, enabled, *, force=False)[source]

Async twin of HttpWriter.set_port_enabled (see its docs).

async set_mgmt_ip(address, netmask, gateway, *, force=False)[source]

Async twin of HttpWriter.set_mgmt_ip – INCLUDING its honesty caveat: the apply is unverified against live hardware, because verifying it would have moved a real switch’s management address. See the sync twin’s docstring for exactly what is and is not proven.

async upload_certificate(cert_pem, key_pem, *, force=False)[source]
async set_hostname(name, *, force=False)[source]

This backend does not serve a host-name write.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

async set_port_speed(port, speed, *, force=False)[source]

Async twin of HttpWriter.set_port_speed – see it.

async set_flow_control(port, enabled, *, force=False)[source]

This backend cannot configure flow control on this UI.

Refused by name, and this one is a MEASURED absence rather than an unsearched one. The GoAhead ports page publishes flowControlAdminType/flowControlOperType but has no control for either: its slct* selects are Admin Mode and Port Speed only, and its submit builder emits no flow-control field at all (see tests/fixtures/http/gs728tpp_ports.xml). Flow control lives on a different page of that UI which has not been captured, and the FASTPATH XUI equivalent has not either.

async add_syslog_collector(host, *, port=514, severity=6, force=False)[source]

This backend cannot add a syslog collector.

Refused by name. The M4300 syslog page DOES declare the mechanism – cell 2_1_5 is a write-only L7_ROW_STATUS_t whose DELETE button writes “Delete” and whose APPLY writes “Active” – but no POST for a FASTPATH-XUI row add has ever been captured, and the existing XUI write path covers only the CSRF-hash dialects. The envelope would have to be invented. The gsm7252ps/gsm7228ps pages do not even declare the cells.

async remove_syslog_collector(host, *, force=False)[source]

This backend cannot remove a syslog collector.

Refused by name. The M4300 syslog page DOES declare the mechanism – cell 2_1_5 is a write-only L7_ROW_STATUS_t whose DELETE button writes “Delete” and whose APPLY writes “Active” – but no POST for a FASTPATH-XUI row add has ever been captured, and the existing XUI write path covers only the CSRF-hash dialects. The envelope would have to be invented. The gsm7252ps/gsm7228ps pages do not even declare the cells.

async set_syslog_enabled(enabled, *, force=False)[source]

This backend does not serve a remote-logging toggle.

Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none.

FASTPATH CLI

Model-driven CLI read operations over a CliSession.

The CLI analogue of http_read.py: dispatches each get_* op to its FASTPATH show command (via protocols.cli.commands.cli_spec) over a CliSession transport, and parses the returned text with the pure functions in protocols.cli.parse. Works unchanged against a real SSH/telnet/console session or the in-process mock face (virtual.faces.cli.VirtualCliFace).

Unlike HttpReader, construction is NOT gated on reads_verified: the reader is always usable directly (mock tests, and a future live-verified flip), and it is the FACADE that refuses live CLI dispatch while reads_verified is False (see _dispatch.cli_reads_supported / SyncSwitch._reader_for).

Ops a FASTPATH model’s CLI genuinely lacks raise UnsupportedCapabilityError honestly rather than fabricating: get_poe on the non-PoE M4300-24X (PoE port count 0) is the only such carve-out – every other show command exists on every FASTPATH switch.

class netgear_switch.cli_read.CliReader(session, model)[source]

Bases: object

Synchronous FASTPATH-CLI read facade over one switch.

get_ports()[source]
Return type:

list[PortStatus]

get_stats()[source]
Return type:

list[PortStats]

get_vlans()[source]
Return type:

list[VLANInfo]

get_pvids()[source]
Return type:

list[tuple[int, int]]

get_macs()[source]
Return type:

list[MacEntry]

get_lldp()[source]
Return type:

list[LLDPNeighbor]

get_poe()[source]
Return type:

list[PoEStatus]

get_sensors()[source]
Return type:

list[Sensor]

get_mgmt_ip()[source]
Return type:

MgmtIpConfig

get_services()[source]

Which management services are enabled, and on which ports.

Three commands, because the switch splits it that way – and the telnet one is show telnetcon, not show telnet. See parse.parse_services for why that distinction matters.

Return type:

list[ServiceStatus]

get_users()[source]

The switch’s local login accounts, from show users.

The access-mode wording differs between firmware images, so SwitchUser.access_mode keeps the raw text and privileged carries the normalised reading – see parse.parse_users.

Return type:

list[SwitchUser]

get_syslog()[source]

Remote-logging configuration, from show logging + its host table.

Two commands because the switch splits it that way: the globals live in show logging and the collectors in show logging hosts. The host table’s column set differs by firmware – see parse.parse_syslog.

Return type:

SyslogConfig

get_hostname()[source]

The switch’s host name, from show hosts.

See parse.parse_hostname for why this command and not show running-config: the two report different values, and only this one agrees with SNMP’s sysName.

Return type:

str

identify()[source]
Return type:

DetectedModel

Model-driven FASTPATH CLI write/control over a CliSession.

Two write paths live here:

  • CliWriter – the VLAN write backend (create/delete a VLAN, set a port’s membership, set a port’s PVID) over the config-mode CLI. The CLI analogue of snmp_write.SnmpWriter/http_write.HttpWriter: every write is followed by a read-back through cli_read.CliReader and raises WriteVerificationError (carrying before/after) on divergence, never a silent success.

  • deploy_certificate_scp – the SSL-certificate deploy over copy scp://, for the Fully Managed FASTPATH line (M4300 / GSM7252PS) whose firmware takes an HTTPS server certificate over SCP rather than an HTTP form.

Both REUSE the library’s existing CLI transport: plain commands go through CliSession.run; the interactive copy and write memory steps go through CliSession.run_scp_copy / run_write_memory (the byte-level interactive driving lives on the shared ShellDriver – no new transport). There is no async twin: all three CLI transports (paramiko SSH, telnet, pyserial console) are synchronous, so AsyncSwitch has no CLI backend at all.

HONESTY, per path:

  • CliWriter is LIVE-VERIFIED on all four CLI models (2026-07-30), each on a link-down, undescribed port and each restored to a byte-identical running-config with its PVID and VLAN membership unchanged and the throwaway VLAN 4001 deleted:

    model

    host

    ops driven through this module

    gsm7252ps

    10.1.5.22

    VLAN create/tag/untag/exclude/delete, PVID, port admin

    m4300-24x

    10.1.5.13

    same (no PoE hardware on this SKU)

    m4300-16x

    10.1.5.20

    same + PoE off/on + cycle_poe

    gsm7228ps

    10.1.5.11

    same + PoE off/on + cycle_poe (telnet 60000, “1/g<n>”)

    set_mgmt_ip and reboot are the two deliberate exceptions: their command syntax is confirmed from each device’s own help output, but neither was executed – a mgmt-IP change drops the very session issuing it and a reboot would interrupt production traffic. Both say so at the method.

  • The cert deploy is GROUNDED in the working certbot-hook FastpathScpUpdater (see tmp/certbot_hook_prior_art.py) and MOCK-TESTED end-to-end, but NOT live-verified – a real run is a production write that needs a staging SCP server to pull the PEM from, which CI has neither the hardware nor the network for. The library only SENDS the copy commands; the CALLER stages the PEM on the SCP source first (per the user’s decision), exactly as the certbot hook’s main does.

netgear_switch.cli_write.scp_source_url(scp_source, remote_dir, filename)[source]

Build the scp:// source URL for one staged PEM.

Mirrors FastpathScpUpdater._source_url: an ABSOLUTE staging path so FASTPATH’s scp client requests the exact path the SCP source’s ForceCommand wrapper authorises (no home-relative ambiguity). scp_source is the user@host[:port] the caller’s staging server answers on.

Return type:

str

netgear_switch.cli_write.deploy_certificate_scp(
session,
*,
scp_source,
scp_password,
remote_dir,
base,
chain,
writemem_stuff,
)[source]

Run the 5-step FASTPATH cert-deploy EXEC sequence over session.

session must already be set up (enable + paging off – the transport does this on connect). The staged PEMs are named <base>-server.pem (and, when chain is set, <base>-root.pem) under remote_dir on the SCP source.

Steps (grounded in FastpathScpUpdater.upload_certificate):

  1. no ip http secure-server – HTTPS must be off to accept the sslpem.

  2. copy scp://<src>/<base>-server.pem nvram:sslpem-server (interactive).

  3. optional copy scp://<src>/<base>-root.pem nvram:sslpem-root (CA chain).

  4. ip http secure-server – re-enable; loads the new cert, no reboot.

  5. write memory – persist, with the per-model confirm (writemem_stuff).

class netgear_switch.cli_write.CliWriter(session, model, *, protected_ports=frozenset())[source]

Bases: object

Synchronous FASTPATH-CLI write facade over one switch.

Offers the SAME write surface as SnmpWriter/HttpWriter – VLAN lifecycle, per-port membership and PVID, PoE admin/cycle/fault-clear, port admin state, the management IP and reboot – because the point of several backends is that the CALLER chooses one (CLAUDE.md principle 2). Every command is carried per model in protocols.cli.commands.CliModelSpec and was confirmed against each reachable switch’s own context-sensitive help on 2026-07-30 (gsm7252ps 10.1.5.22, m4300-24x 10.1.5.13, m4300-16x 10.1.5.20, gsm7228ps/S3300 10.1.5.11), so the S3300’s 1/g<n>/1/xg<n> interface naming, the M4300’s ip management address and the gsm7252ps’s ABSENT switchport mode are each handled as that firmware really behaves.

VLAN command sequences, PROVEN BY HAND on the M4300-24X before being encoded:

  • create – vlan database / vlan <vid> / vlan name <vid> <name> / exit

  • delete – vlan database / no vlan <vid> / exit

  • membership – configure / interface <iface> / switchport mode general then either vlan participation include <vid> plus vlan tagging <vid> (TAGGED) or no vlan tagging <vid> (UNTAGGED), or vlan participation exclude <vid> (EXCLUDED) / exit / exit

  • PVID – configure / interface <iface> / switchport mode general / vlan pvid <vid> / exit / exit

Other ops, per model (all four confirmed from the devices’ own help):

  • PoE – configure / interface <iface> / poe | no poe | poe reset / exit / exit

  • port admin – no shutdown | shutdown in interface config mode

  • management IP – privileged-EXEC network parms <ip> <mask> <gw> (gsm7252ps, gsm7228ps) or global-config ip management address <ip> <mask> + ip default-gateway <gw> (M4300 12.0.x, which has no network parms)

  • reboot – privileged-EXEC reload with its (y/n) confirm

THE SWITCHPORT-MODE FINDING (why switchport mode general is sent first): on real hardware the vlan participation/vlan tagging commands are accepted into running-config while the port is in switchport mode access but stay completely INERT – show vlan <vid> keeps reporting Exclude/Autodetect for that port. They only take effect in switchport mode general. This was proven by before/after show running-config interface 1/0/4 + show vlan 4007 on 10.1.5.13. So every per-port VLAN op ensures general mode first (idempotent: re-issuing it on an already-general port is a no-op). That is a deliberate, unavoidable side effect of a CLI membership write and is why the mock reproduces the inert behaviour too (virtual.faces.cli). The gsm7252ps XE image is the exception – it has no switchport mode command at all, so the step is omitted there rather than being rejected (see _general_mode).

CONSEQUENCE WORTH KNOWING (measured on 10.1.5.20 while live-verifying this backend): a port moved to general mode stops honouring any switchport access vlan / switchport trunk native vlan lines that were sitting INERT in its config, and later sending switchport mode access ACTIVATES them – leaving the port in a different state than it started in (its PVID jumped from 1 to the configured access VLAN 10). The command that genuinely restores the prior behaviour is no switchport mode, verified live: it removes the mode line and the port went back to PVID 1 / VLAN 1 untagged with a byte-identical running-config.

Two further deliberate behaviours:

  • FASTPATH answers an ACCEPTED configuration command with EMPTY output, so any text back (% Invalid input, ERROR: ...) is treated as a failure and raised as CliCommandError – never swallowed.

  • NOTHING is persisted: no write memory is issued, so these writes change the running config only, exactly like this library’s SNMP/HTTP VLAN writes. A caller that wants them to survive a reboot must save separately.

create_vlan(vlan, name, *, force=False)[source]

Create VLAN vlan named name (vlan database sequence).

Creating an EMPTY VLAN adds no port membership, so it is non-disruptive and needs no force; the parameter exists for signature symmetry with delete_vlan (mirroring SnmpWriter.create_vlan). name goes on the wire as a bare token – FASTPATH’s vlan name takes no spaces, and a name it rejects surfaces as CliCommandError rather than being silently mangled.

delete_vlan(vlan, *, force=False)[source]

Delete VLAN vlan (vlan database / no vlan <vid>).

Refuses up front if the VLAN does not exist (a precondition failure, NOT a verification divergence – no command has been sent yet, mirroring SnmpWriter.delete_vlan), and refuses without force when the VLAN still carries a protected port, since deleting it strips that port’s membership.

set_vlan_membership(vlan, port, mode, *, force=False)[source]

Set port’s participation in vlan to mode.

Verifies the TARGET PORT’s participation only, deliberately: unlike the SNMP writer – which SETs whole egress/untagged PortList bitmaps and so must verify both bitmaps in full – these commands address exactly one interface, and forcing general mode can legitimately move that port’s membership in OTHER VLANs (its former access VLAN), which is not this VLAN’s business.

set_port_description(port, description, *, force=False)[source]

Label port (description '<text>'), or clear it with "".

The command form is the firmware’s own: a live GSM7252PS (10.1.5.22, 2026-08-03) renders its 38 labelled ports in show running-config as description 'eth0.rpi5-pmod' – single-quoted – and the negation is the standard no description.

Cosmetic, so no switchport mode general preamble is needed (unlike the VLAN commands, whose effect depends on the port’s mode) and it is not force-gated beyond the protected-port guard.

set_port_speed(port, speed, *, force=False)[source]

Force port’s speed/duplex, or return it to auto-negotiation.

DISRUPTIVE: changing either setting bounces the link, so this honours protected_ports exactly as set_pvid does.

Both command forms were PROVEN BY EXECUTION on gsm7252ps 10.1.5.22 port 1/0/8 (link-down, undescribed, 2026-08-03) and the port restored to a byte-identical running-config afterwards – speed 100 full-duplex moved Physical Mode to 100 Full, speed auto moved it back.

A forced 1000 is refused HERE, before anything is sent, because it is not a rate this grammar has: 1000BASE-T makes auto-negotiation mandatory, and the firmware encodes that by omitting 1000 from the forced rates while keeping it among the advertised ones. Measured, not inferred – speed 1000 full-duplex on the port above answered “% Invalid input detected at ‘^’ marker.” and left Physical Mode untouched. Refusing by name says WHY; letting it through would produce the same failure with none of the explanation.

Every OTHER rate is sent unchecked, deliberately: the forced rates a port offers follow its PHY rather than the firmware (1/0/8 offered 10/100/10G, an m4300-24x 10GBASE-T port offered 100/10G), so the switch is the only authority worth asking. One it rejects comes back as CliCommandError carrying the device’s own words.

set_flow_control(port, enabled, *, force=False)[source]

Turn IEEE 802.3x flow control on or off for port.

flowcontrol / no flowcontrol in interface config mode – bare toggles, and PROVEN as a round trip on gsm7252ps 10.1.5.22 port 1/0/8 (2026-08-03): the first added a flowcontrol line to running-config and moved Flow Mode from Disable to Enable, the second removed it and returned the column, with the port left byte-identical.

Verified against show port all’s Flow Mode column, which is the CONFIGURED setting: it moved on a port whose link was DOWN throughout, so it cannot be reporting a negotiated result.

Disruptive enough to honour protected_ports: enabling pause frames changes how a link behaves under congestion.

set_pvid(port, vlan, *, force=False)[source]

Set port’s ingress PVID to vlan (vlan pvid <vid>).

Changing a port’s PVID re-homes its untagged traffic, so it is disruptive and honours protected_ports (mirroring SnmpWriter.set_pvid). switchport mode general is sent first for the same reason as set_vlan_membership: in access mode the port’s PVID follows its access VLAN, so vlan pvid cannot take effect.

Refuses up front if the VLAN does not exist, exactly as set_vlan_membership does – a precondition failure, so no command is sent. This used to be left to the switch, on the assumption it would reject the command. That assumption does not hold generally: MEASURED on a GS728TPP (10.2.5.10, firmware 6.0.1.30), the equivalent write is ACCEPTED and reads back, leaving the port pointing at a VLAN that is not there – which no amount of verify-after-write can catch.

set_poe(
port,
on,
*,
force=False,
timeouts=None,
sleep=time.sleep,
clock=time.monotonic,
)[source]

Enable/disable PoE on port (poe / no poe).

Verified through show poe port info all, whose Status column reads Disabled for an admin-off port – that command has NO admin column, so admin state is read from Status (see parse.parse_poe).

The read-back POLLS, and that is not defensive padding: Status is a DETECTION state, so it lags the admin write. Measured on the real M4300-16X (10.1.5.20, FASTPATH 12.0.19.15, 2026-07-30): immediately after poe re-enabled port 1/0/1 the table still said Disabled, and the same port read Searching (admin enabled) moments later. A single immediate read therefore reported a WORKING write as a verification failure. Disabling is observable at once, but the same polled check covers both directions. sleep/clock are injectable so tests do not wait.

cycle_poe(
port,
*,
force=False,
timeouts=None,
sleep=time.sleep,
clock=time.monotonic,
)[source]

Power-cycle PoE on port and wait for it to deliver again.

clear_poe_fault(
port,
*,
force=False,
timeouts=None,
sleep=time.sleep,
clock=time.monotonic,
)[source]

Clear a PoE fault on port: re-arm detection, then poll until the port has LEFT the fault state (delivering or searching), exactly the recovery predicate SnmpWriter.clear_poe_fault uses.

set_port_enabled(port, enabled, *, force=False)[source]

Enable/disable port (no shutdown / shutdown).

Command form confirmed on all four CLI models (“shutdown ?” -> <cr>). Verified through show port all’s Admin Mode column.

set_hostname(name, *, force=False)[source]

Set the switch’s host name, via global-config hostname <name>.

Not force-gated: renaming cannot strand the switch and is reversible by writing the old name back, unlike set_mgmt_ip below which drops the session that issues it. force is accepted so the signature matches every other writer.

Verified by re-reading show hosts. That command, rather than show running-config, is deliberate and load-bearing here: the two report different values on real hardware (see protocols.cli.parse.parse_hostname), and show hosts is the one that agrees with SNMP, so a CLI write verified this way is also observable over SNMP.

Nothing is persisted – no write memory – exactly like every other write in this module.

set_mgmt_ip(address, netmask, gateway, *, force=False)[source]

Set the switch’s own management IP.

Per-model dialect (both confirmed against the devices’ own help on 2026-07-30 – see CliModelSpec.mgmt_ip_*):

  • gsm7252ps / gsm7228ps – privileged EXEC network parms <ip> <mask> <gateway>

  • m4300-24x / -16x – global config ip management address <ip> <mask> then ip default-gateway <gw> (these images have no network parms at all)

force=True is required: unlike the SNMP path (whose write OIDs are placeholders), these commands are the switch’s real documented ones – but the op can still strand the switch, and it will normally drop the very CLI session issuing it (the session is reached over the address being changed), so the read-back may itself fail. Deliberately NOT live-tested for that reason.

reboot(*, force=False)[source]

Reboot the switch (reload, privileged EXEC).

Command confirmed on all four models (“reload ?” -> <cr> / <unit>). reload asks a (y/n) confirm, so it is issued through the same confirm-answering transport path write memory uses.

HONESTY: this is the one write here that CANNOT be verified – the switch stops answering by definition, and the CLI session dies with it (a dropped session IS the success signal, which is why CliTransportError is not treated as a failure). It is also the one write deliberately NOT exercised on live hardware: these switches carry production traffic. Callers must re-poll the switch themselves.

set_syslog_enabled(enabled, *, force=False)[source]

Turn remote logging on or off (logging syslog in global config).

The positive form is VERBATIM from every switch’s own show running-config (all four FASTPATH models print the bare line logging syslog, read 2026-08-05). The negation is the standard FASTPATH no and is inferred – see logging_no_syslog_cmd; a wrong form is rejected by the device and raised, never swallowed.

Not force-gated: it changes where log messages go, not how traffic is switched, and is reversible by writing the old value back.

add_syslog_collector(host, *, port=514, severity=6, force=False)[source]

Add a remote syslog collector.

logging host "<address>" <ipv4|ipv6|dns> <port> <severity-word>, which is VERBATIM the line all four FASTPATH models print in their own show running-config (read 2026-08-05 – read-only, no ?).

Refuses up front if a collector for host already exists: FASTPATH would otherwise add a SECOND row for the same address, and the caller who asked for “send logs here” would silently get duplicate delivery. A precondition failure, so no command is sent (mirroring set_pvid).

Not force-gated, for the same reason as set_syslog_enabled.

remove_syslog_collector(host, *, force=False)[source]

Remove the remote syslog collector for host.

logging host remove <index> – a SUBCOMMAND, not a negation, and addressed by the table’s OWN Index column rather than by address. Read from a fresh table immediately before the write, never cached.

THE INDEX IS SPARSE, and it is not the row’s position. Measured on m4300-24x 10.1.5.13 (2026-08-05): after some churn the table held Index 1 and Index 3, with nothing at 2. This method used to enumerate the rows and count – which addressed Index 2, a row that did not exist. The switch ACCEPTED that removal as a no-op and the collector survived, so the bug was silent until the table was read column-wise.

The obvious no logging host <index> is WRONG and was corrected the expensive way – a live gsm7252ps rejected it, and every address spelling too, leaving a throwaway collector on the switch until logging host ? showed remove/reconfigure as subcommands. The verify-after-write did its job (the failure was loud and the state was recoverable), but the lesson is that a FASTPATH negation is not a safe inference.

LIVE-VERIFIED 2026-08-05 on all four CLI models – gsm7252ps 10.1.5.22, m4300-24x 10.1.5.13, m4300-16x 10.1.5.20 and gsm7228ps 10.1.5.11 (over telnet 60000) – each by adding 192.0.2.1 (TEST-NET-1, routes nowhere), reading it back, removing it, and proving show logging hosts byte-identical to its prior state with the production 10.1.5.1 collector untouched throughout. No write memory.

Refuses up front if no such collector exists, rather than sending a removal for a row that is not there.