Core types

The data model, the model registry, the capability oracle, configuration loading, and the exception hierarchy. Every other module depends on these; none of them depends on a backend.

Device data

Public device-data model: frozen dataclasses returned by both APIs.

class netgear_switch.models.PoEDetect(*values)[source]

Bases: Enum

DISABLED = 'disabled'
SEARCHING = 'searching'
DELIVERING = 'delivering'
FAULT = 'fault'
UNKNOWN = 'unknown'
class netgear_switch.models.VlanMode(*values)[source]

Bases: Enum

UNTAGGED = 'untagged'
TAGGED = 'tagged'
EXCLUDED = 'excluded'
class netgear_switch.models.IpMode(*values)[source]

Bases: Enum

DHCP = 'dhcp'
STATIC = 'static'
UNKNOWN = 'unknown'
class netgear_switch.models.PortSpeed(autonegotiate, speed_mbps=None, full_duplex=None)[source]

Bases: object

A port’s CONFIGURED speed/duplex – what it is SET to, not what it got.

Deliberately NOT folded into PortStatus.speed_mbps, which is the OPERATIONAL rate the link actually came up at. The two answer different questions and neither substitutes for the other: a port configured auto can be running at 100 Mbit/s, and a port forced to 100 Mbit/s full-duplex still reports no operational rate at all while its link is down. Overloading one field would have made “what did I configure?” unanswerable exactly when it matters most – on a down port, which is the only kind this library is allowed to reconfigure.

autonegotiate is the whole of the configuration when it is True: speed_mbps and full_duplex are then None, because an auto port has no configured rate – it has whatever it negotiates. A FORCED configuration carries both, because every firmware measured here requires the duplex to be named alongside the rate (speed 100 full-duplex).

NOT representable, deliberately: auto-negotiation with a restricted ADVERTISED rate list (FASTPATH’s speed auto [10] [100] [1000] [10G]). The grammar accepts it, but show port’s Physical Mode column reports a bare Auto for it – measured on gsm7252ps 10.1.5.22 port 1/0/8, 2026-08-03, where speed auto 1000 read back identically to speed auto. Offering it would mean offering a write this library cannot verify it made, so it is left out until a read that can distinguish the two (the running-config line) is built.

autonegotiate: bool
speed_mbps: int | None = None
full_duplex: bool | None = None
classmethod auto()[source]

Auto-negotiate (the factory default on every switch measured here).

Return type:

PortSpeed

classmethod forced(speed_mbps, *, full_duplex)[source]

Force a fixed rate and duplex, disabling auto-negotiation.

Return type:

PortSpeed

class netgear_switch.models.PortStatus(
port: 'int',
name: 'str | None',
admin_enabled: 'bool',
link_up: 'bool',
speed_mbps: 'int | None',
description: 'str | None' = None,
full_duplex: 'bool | None' = None,
flow_control: 'bool | None' = None,
speed_config: 'PortSpeed | None' = None,
)[source]

Bases: object

port: int
name: str | None
admin_enabled: bool
speed_mbps: int | None
description: str | None = None
full_duplex: bool | None = None

Whether the link negotiated FULL duplex. None when the backend cannot tell – a down port has no negotiated duplex, and not every backend reports it at all. Measured 2026-08-02: the M4300’s EtherLike-MIB dot3 table exposes only error counters, NOT dot3StatsDuplexStatus (column 19 is absent), so SNMP cannot answer this and leaves it None. The FASTPATH CLI does: show port all reports “1000 Full” in its Physical Status column, carrying speed and duplex together.

flow_control: bool | None = None

Whether IEEE 802.3x flow control is enabled on the port (“Flow Mode” in show port all). None where the backend does not report it.

speed_config: PortSpeed | None = None

The port’s CONFIGURED speed/duplex – show port’s “Physical Mode” column, as opposed to the “Physical Status” column the three fields above come from. None where the backend cannot tell, which is every backend but the CLI so far: SNMP’s ifSpeed and the Plus models’ NSDP port record both report the NEGOTIATED rate only, and no vendor column carrying the admin setting has been located on any of them.

class netgear_switch.models.PoEStatus(
port: 'int',
admin_enabled: 'bool',
detect: 'PoEDetect',
power_mw: 'int | None',
)[source]

Bases: object

port: int
admin_enabled: bool
detect: PoEDetect
power_mw: int | None
property delivering: bool
netgear_switch.models.poe_cycle_complete(before, now)[source]

Has a power-cycled port finished coming back?

Shared by every backend that power-cycles a port, because “back” is a property of the PORT, not of the protocol used to ask.

What “back” means depends on what the port was doing, and getting it wrong turns a successful cycle into a reported failure. LIVE-PROVEN on sw-netgear-gs728tpp.monarto.mithis.com (10.2.5.10, firmware 6.0.1.30) 2026-08-03: cycling port 17 – link-down with NOTHING attached – performed the off/on correctly and left the port SEARCHING, and a predicate demanding DELIVERING unconditionally polled the full 60s and then raised on a cycle that had worked. A port with no powered device can never reach DELIVERING.

  • powering a device before the cycle -> success is powering it again, which is the entire point of cycling it and the strict check worth keeping;

  • powering nothing before -> success is detection running again.

Return type:

bool

class netgear_switch.models.VLANInfo(
vlan_id: 'int',
name: 'str | None',
member_ports: 'frozenset[int]',
tagged_ports: 'frozenset[int]',
untagged_ports: 'frozenset[int]',
)[source]

Bases: object

vlan_id: int
name: str | None
member_ports: frozenset[int]
tagged_ports: frozenset[int]
untagged_ports: frozenset[int]
class netgear_switch.models.LLDPNeighbor(
local_port: 'int',
remote_sys_name: 'str | None',
remote_port_desc: 'str | None',
remote_chassis_id: 'str | None',
remote_port_id: 'str | None' = None,
)[source]

Bases: object

local_port: int
remote_sys_name: str | None
remote_port_desc: str | None
remote_chassis_id: str | None
remote_port_id: str | None = None
class netgear_switch.models.MacEntry(mac: 'str', port: 'int', vlan_id: 'int | None')[source]

Bases: object

mac: str
port: int
vlan_id: int | None
class netgear_switch.models.Sensor(name: 'str', kind: 'str', value: 'float', unit: 'str')[source]

Bases: object

name: str
kind: str
value: float
unit: str
class netgear_switch.models.PortStats(
port: 'int',
rx_bytes: 'int | None',
tx_bytes: 'int | None',
rx_packets: 'int | None',
tx_packets: 'int | None',
rx_errors: 'int | None',
tx_errors: 'int | None',
)[source]

Bases: object

port: int
rx_bytes: int | None
tx_bytes: int | None
rx_packets: int | None
tx_packets: int | None
rx_errors: int | None
tx_errors: int | None
class netgear_switch.models.MgmtIpConfig(
mode: 'IpMode',
address: 'str | None',
netmask: 'str | None',
gateway: 'str | None',
base_mac: 'str | None' = None,
)[source]

Bases: object

mode: IpMode
address: str | None
netmask: str | None
gateway: str | None
base_mac: str | None = None
class netgear_switch.models.ServiceStatus(name, enabled, port=None)[source]

Bases: object

One management service the switch offers, and whether it is on.

Covers the four protocols an operator turns on or off to control how the switch itself can be reached: http, https, telnet and ssh.

name: str

One of http, https, telnet, ssh.

enabled: bool
port: int | None = None

The TCP port the service listens on, or None where the firmware does not report one. Measured: gsm7252ps omits the SSH Port line that m4300-24x prints, so this is genuinely absent rather than defaulted to 22.

netgear_switch.models.PRIVILEGED_ACCESS_MODES: frozenset[str] = frozenset({'privilege-15', 'read/write', 'super user'})

Access-mode text meaning FULL privilege, in every vocabulary measured so far. There are three, and they do not agree – the same two accounts on the same two switches read differently depending on which face you ask (2026-08-02 / 2026-08-03):

backend m4300-24x admin gsm7252ps admin guest (both) CLI show users Privilege-15 Read/Write Privilege-1 / Read Only web userManagement.html Super User Read Only

Note the web UI is the CONSISTENT one: it says “Super User”/”Read Only” on both switches, where the CLI’s wording splits by firmware family. A parser taught only one vocabulary would silently mis-report the others, so this table is shared by every backend rather than living in one parser.

netgear_switch.models.privileged_access(access_mode)[source]

Whether access_mode is a full-privilege level, or None if the word is one this library has not measured on a device.

Return type:

bool | None

class netgear_switch.models.SwitchUser(
name,
access_mode,
privileged,
snmpv3_access=None,
snmpv3_auth=None,
snmpv3_encryption=None,
)[source]

Bases: object

One local login account on the switch.

name: str
access_mode: str

The access mode exactly as this firmware words it, on the FACE that was asked. Kept verbatim because the vocabulary is not shared – see PRIVILEGED_ACCESS_MODES for the three spellings measured so far.

privileged: bool | None

Whether access_mode is the full-privilege level, normalised across every measured vocabulary so callers do not have to know which image or which backend they are on. None when the text is none of them – an unrecognised level is reported honestly rather than guessed.

snmpv3_access: str | None = None

The three SNMPv3 columns the same table carries. None where the firmware prints nothing.

snmpv3_auth: str | None = None
snmpv3_encryption: str | None = None
class netgear_switch.models.SyslogServer(host, port, severity, active, index=None)[source]

Bases: object

One remote syslog collector the switch is configured to send to.

host: str
port: int
severity: int

Standard syslog severity, 0 (emergency) to 7 (debug). The switch sends messages AT OR ABOVE this level. Cross-checked on m4300-24x: the SNMP column reads 6 where show logging hosts prints “info”.

active: bool

The switch’s own word for the row’s state, “Active” in the CLI table.

index: int | None = None

The row’s index in the switch’s own host table, where the backend reports one – the handle a removal addresses. None from backends that do not expose it (SNMP walks it as the OID instance, HTTP’s page does not print it).

It is SPARSE, and that is not a detail: measured on m4300-24x (10.1.5.13, 2026-08-05) the table held Index 1 and Index 3 with nothing at 2. Deriving it from a row’s POSITION – which is the obvious thing to do, and what this library did until that measurement – addresses the wrong row as soon as anything has ever been removed.

netgear_switch.models.SYSLOG_SEVERITY_NAMES: Mapping[str, int] = mappingproxy({'emergency': 0, 'alert': 1, 'critical': 2, 'error': 3, 'warning': 4, 'notice': 5, 'info': 6, 'informational': 6, 'debug': 7})

Syslog severity names as the switches PRINT them, mapped to the standard numbers the SNMP columns carry. Netgear spells the same value differently depending on which face you ask, so this is shared rather than per-backend: the FASTPATH CLI’s show logging hosts prints “info” (lowercase) while the web UI’s Severity Filter column prints “Info” – MEASURED on the same collector row of the same switch (m4300-24x 10.1.5.13, 2026-08-03), where the SNMP severity column reads 6.

“informational” is listed beside “info” because it is the word FASTPATH’s own logging host command accepts; both are severity 6.

netgear_switch.models.SYSLOG_SEVERITY_WORDS: Mapping[int, str] = mappingproxy({0: 'emergency', 1: 'alert', 2: 'critical', 3: 'error', 4: 'warning', 5: 'notice', 6: 'info', 7: 'debug'})

The canonical WORD each severity number is written back as. The inverse of SYSLOG_SEVERITY_NAMES is not a function – 6 has two spellings there – so the one a command may carry is pinned rather than derived. “info” is the spelling every switch’s own running-config uses (logging host "10.1.5.1" ipv4 514 info, read off all four FASTPATH models 2026-08-05).

netgear_switch.models.SYSLOG_SEVERITY_LABELS: Mapping[int, str] = mappingproxy({0: 'Emergency', 1: 'Alert', 2: 'Critical', 3: 'Error', 4: 'Warning', 5: 'Notice', 6: 'Info', 7: 'Debug'})

The same severities as the WEB UI spells them. Title-case, not the CLI’s lowercase – both read off real output on the same switch (m4300-24x 10.1.5.13): show logging hosts prints “info” while syslogConfiguration.html’s Severity Filter enum offers “Info”. Pinned rather than derived with .capitalize(), because a formula that happens to agree today is exactly what stops being checked.

netgear_switch.models.syslog_severity_label(level)[source]

A severity NUMBER -> the word the WEB UI’s enum carries.

See syslog_severity_word for the CLI’s spelling of the same value.

Return type:

str

netgear_switch.models.syslog_severity_word(level)[source]

A severity NUMBER -> the word a switch command carries.

Raises on anything outside 0-7 rather than emitting the integer: syslog severities are a closed set, and a command built from an out-of-range value would be rejected by the device with a message that names the command rather than the caller’s mistake.

Return type:

str

netgear_switch.models.syslog_severity(name)[source]

A switch’s severity WORD -> its standard number, case-insensitively.

Raises ValueError on a word this library has not measured. That is deliberate: the obvious alternative – defaulting to 0 – reports the switch as forwarding EMERGENCIES ONLY, which is both wrong and invisible, and 0 is indistinguishable from a genuine “emergency” row. An unrecognised word means a firmware spells a level differently than any device measured here, and the caller should see that rather than a plausible number.

Return type:

int

class netgear_switch.models.SyslogConfig(enabled, local_port, servers)[source]

Bases: object

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

Deliberately narrower than everything show logging prints. The console and buffered-logging columns are in the same vendor subtree, but only the console pair could be decoded against captured CLI output; the buffered severity did not match any column read, so it is left out rather than guessed at. See VendorOids.syslog_*.

enabled: bool
local_port: int

The source port the switch sends FROM (Logging Client Local Port), not the collector’s port – that is per-server in servers.

servers: tuple[SyslogServer, ...]
class netgear_switch.models.DetectedModel(key, sys_descr, sys_object_id)[source]

Bases: object

Result of identifying a switch’s model over SNMP (sysObjectID + sysDescr).

key is a registry key (see registry.get_model) if the switch was confidently identified – either by a real-capture-confirmed sysObjectID (parse.detect_model_from_sysobjectid, tried first) or, failing that, by its sysDescr matching exactly one registered model’s name (parse.detect_model_from_sysdescr) – or None if neither did (an unregistered Netgear model, a non-Netgear device, or an unreadable/absent reply). None is NEVER a fabricated guess.

sys_descr/sys_object_id are the raw SNMP-reported strings, kept for the caller/logging even when unmatched. sys_object_id IS the PREFERRED matching signal: an unambiguous manufacturer product identifier, so it can distinguish SKUs whose sysDescr text is indistinguishable (the S3300-52X vs the unregistered S3300-28X). Its map (SYSOBJECTID_MODELS) holds ONLY OIDs proven by a live capture, never a spec-sheet guess, so a model with no committed capture is still identified purely by sysDescr.

key: str | None
sys_descr: str | None
sys_object_id: str | None
property matched: bool
class netgear_switch.models.SwitchData(
model: 'str',
host: 'str',
ports: 'tuple[PortStatus, ...]' = (),
poe: 'tuple[PoEStatus, ...]' = (),
vlans: 'tuple[VLANInfo, ...]' = (),
pvids: 'tuple[tuple[int, int], ...]' = (),
lldp: 'tuple[LLDPNeighbor, ...]' = (),
macs: 'tuple[MacEntry, ...]' = (),
sensors: 'tuple[Sensor, ...]' = (),
stats: 'tuple[PortStats, ...]' = (),
mgmt_ip: 'MgmtIpConfig | None' = None,
)[source]

Bases: object

model: str
host: str
ports: tuple[PortStatus, ...] = ()
poe: tuple[PoEStatus, ...] = ()
vlans: tuple[VLANInfo, ...] = ()
pvids: tuple[tuple[int, int], ...] = ()
lldp: tuple[LLDPNeighbor, ...] = ()
macs: tuple[MacEntry, ...] = ()
sensors: tuple[Sensor, ...] = ()
stats: tuple[PortStats, ...] = ()
mgmt_ip: MgmtIpConfig | None = None

Model registry

Declarative registry of known Netgear switch models.

class netgear_switch.registry.Backend(*values)[source]

Bases: Enum

SNMP = 'snmp'
NSDP = 'nsdp'
HTTP = 'http'
SSH = 'ssh'
TELNET = 'telnet'
CONSOLE = 'console'
class netgear_switch.registry.SwitchClass(*values)[source]

Bases: Enum

FULLY_MANAGED = 'fully_managed'
SMART_MANAGED_PRO = 'smart_managed_pro'
PLUS = 'plus'
class netgear_switch.registry.SwitchModel(
key: 'str',
display_name: 'str',
switch_class: 'SwitchClass',
port_count: 'int',
poe_port_count: 'int',
backends: 'frozenset[Backend]',
snmp_vendor_base: 'str | None',
verified: 'bool' = True,
snmp_vlan_write: 'str' = 'qbridge',
snmp_vlan_split_membership_writes: 'bool' = False,
snmp_can_create_vlan: 'bool' = True,
)[source]

Bases: object

key: str
display_name: str
switch_class: SwitchClass
port_count: int
poe_port_count: int
backends: frozenset[Backend]
snmp_vendor_base: str | None
verified: bool = True
snmp_vlan_write: str = 'qbridge'
snmp_vlan_split_membership_writes: bool = False
snmp_can_create_vlan: bool = True
property has_mac_table: bool
netgear_switch.registry.get_model(key)[source]
Return type:

SwitchModel

Capabilities

Which operation each model can perform over each backend, and why not.

This module answers one question – can model M do operation O over backend B? – without touching a switch. It exists because that question has three consumers who must never disagree:

  • the documentation, whose model/backend support tables are generated from matrix() at build time (see docs/_ext/support_tables.py) rather than hand-maintained, so a table can never quietly drift from the code;

  • callers, who can ask ahead of a run whether an op is worth attempting (e.g. an inventory sweep that skips PoE on non-PSE models instead of collecting exceptions);

  • the test suite, which pins every verdict here against what actually happens when the op is driven for real against the virtual switch (see tests/test_capabilities.py).

It derives, it does not duplicate. Every verdict is read out of the same objects the dispatch path consults – SwitchModel fields, HTTP_SPECS endpoint paths, CLI_SPECS verification flags, scp_cert_profile – and several refusal reasons are the literal message constants the readers raise (nsdp_read._NO_POE and friends). Re-deriving support with a parallel hand-written rule set would recreate exactly the failure mode principle 5 warns about: two things that agree with each other while both disagree with the device.

The verdicts mirror the facade’s own resolution order (see _dispatch.resolve_backend): a backend the model does not have is Support.NO_BACKEND before any per-operation question is asked, because that is the error SyncSwitch raises first.

class netgear_switch.capabilities.Capability(model_key, backend, operation, support, reason='')[source]

Bases: object

The verdict for one (model, backend, operation) triple.

model_key: str
backend: Backend
operation: Operation
support: Support
reason: str = ''

Empty when support is Support.SUPPORTED; otherwise the reason, phrased the way the corresponding reader/writer phrases it.

property supported: bool
class netgear_switch.capabilities.Operation(name, kind, summary, backends=None)[source]

Bases: object

One facade operation, as exposed by SyncSwitch/AsyncSwitch.

name: str

The method name on the facade, e.g. "get_ports".

kind: OperationKind
summary: str

One-line description, reused as the table row label in the docs.

backends: frozenset[Backend] | None = None

Backends that can ever serve this op, for the few that bypass normal backend dispatch (nsdp_device is NSDP-only; certificate upload is HTTP or CLI-over-SCP). None means “any backend the model has”.

class netgear_switch.capabilities.OperationKind(*values)[source]

Bases: Enum

READ = 'read'
WRITE = 'write'
class netgear_switch.capabilities.Support(*values)[source]

Bases: Enum

How a (model, backend, operation) triple is served – or refused.

SUPPORTED = 'supported'

The backend implements this operation for this model.

NO_BACKEND = 'no-backend'

The model does not have this backend at all. This is what _dispatch.resolve_backend raises, before the operation is considered.

UNSUPPORTED = 'unsupported'

The model has the backend, but that backend cannot serve this operation – either the protocol has no such notion (NSDP has no PoE tag) or the device genuinely lacks the hardware (no PSE ports). Never a stand-in for “not implemented yet”: see principle 2 in CLAUDE.md.

UNVERIFIED = 'unverified'

Implemented, but gated off because the backend’s per-model spec is not yet cross-verified against live hardware (HttpModelSpec.reads_verified / CliModelSpec.reads_verified / writes_verified). The facade refuses to dispatch rather than return output nobody has checked.

netgear_switch.capabilities.backends_for(model)[source]

The model’s backends in the facade’s default-preference order.

Return type:

tuple[Backend, …]

netgear_switch.capabilities.matrix(models=None, operations=OPERATIONS)[source]

Every verdict for models x their backends x operations.

Defaults to every registered model. Only backends a model actually has are included, so the result never carries Support.NO_BACKEND rows.

Return type:

tuple[Capability, …]

netgear_switch.capabilities.operation(name)[source]

Look an Operation up by facade method name.

Return type:

Operation

netgear_switch.capabilities.support(model, backend, op)[source]

The verdict for one triple. model/op accept keys or objects.

Return type:

Capability

Configuration

TOML inventory loading and credential resolution.

netgear_switch.config.resolve_secret(spec, *, env, runner=subprocess.run)[source]

Resolve one secret spec to its value (or None).

Return type:

str | None

netgear_switch.config.ensure_secure_file(path)[source]

Raise if the file is readable/writable by group or other.

class netgear_switch.config.SwitchConfig(
name: 'str',
model: 'SwitchModel',
host: 'str',
snmp_community: 'str | None',
snmp_write_community_spec: 'str | None',
http_password_spec: 'str | None',
nsdp_interface: 'str | None',
protected_ports: 'frozenset[int]',
)[source]

Bases: object

name: str
model: SwitchModel
host: str
snmp_community: str | None
snmp_write_community_spec: str | None
http_password_spec: str | None
nsdp_interface: str | None
protected_ports: frozenset[int]
snmp_write_community(*, env, runner=subprocess.run)[source]
Return type:

str | None

http_password(*, env, runner=subprocess.run)[source]
Return type:

str | None

netgear_switch.config.load_inventory(path, *, env=None)[source]

Load a TOML inventory into a {name: SwitchConfig} dict.

Return type:

dict[str, SwitchConfig]

Errors

Exception hierarchy for netgear_switch.

exception netgear_switch.errors.NetgearSwitchError[source]

Bases: Exception

Base class for every error raised by this library.

exception netgear_switch.errors.ConfigError[source]

Bases: NetgearSwitchError

The inventory/config file is malformed or invalid.

exception netgear_switch.errors.CredentialError[source]

Bases: NetgearSwitchError

A required secret could not be resolved from any source.

exception netgear_switch.errors.UnknownModelError[source]

Bases: NetgearSwitchError

A switch references a model key that is not in the registry.

exception netgear_switch.errors.UnsupportedCapabilityError[source]

Bases: NetgearSwitchError

The requested operation is not available on this model/backend.

exception netgear_switch.errors.ProtectedPortError[source]

Bases: NetgearSwitchError

A disruptive write targeted a protected port without force=True.

exception netgear_switch.errors.WriteVerificationError(message, *, before, after)[source]

Bases: NetgearSwitchError

A write did not read back as expected.

Carries the observed state before and after the write attempt so callers can report exactly what diverged.

exception netgear_switch.errors.CliCommandError[source]

Bases: NetgearSwitchError

A FASTPATH CLI command was REJECTED by the device, or a write precondition failed before any command was sent.

The CLI analogue of protocols.snmp.client.SnmpError for the write path: FASTPATH answers an accepted configuration command with EMPTY output, so any text back (% Invalid input, ERROR: ...) means the command did not apply. Distinct from WriteVerificationError, which means the commands WERE accepted but the switch did not read back the intended state, and from transport.cli.session.CliTransportError, which means the connection / prompt framing itself failed.

exception netgear_switch.errors.HttpError[source]

Bases: NetgearSwitchError

An HTTP web-UI transport operation failed (connect, HTTP status, page shape).

exception netgear_switch.errors.HttpAuthError[source]

Bases: HttpError

Web-UI login was rejected, or an authenticated session was lost.

exception netgear_switch.errors.HttpUnexpectedPageError[source]

Bases: HttpError

A web-UI page or token could not be parsed into the expected shape.