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:
objectA 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 configuredautocan 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.autonegotiateis the whole of the configuration when it is True:speed_mbpsandfull_duplexare 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, butshow port’s Physical Mode column reports a bareAutofor it – measured on gsm7252ps 10.1.5.22 port 1/0/8, 2026-08-03, wherespeed auto 1000read back identically tospeed 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.
- 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,
Bases:
object- full_duplex: bool | None = None¶
Whether the link negotiated FULL duplex.
Nonewhen 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 allreports “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).Nonewhere 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.Nonewhere 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',
Bases:
object
- 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:
- class netgear_switch.models.VLANInfo(
- vlan_id: 'int',
- name: 'str | None',
- member_ports: 'frozenset[int]',
- tagged_ports: 'frozenset[int]',
- untagged_ports: 'frozenset[int]',
Bases:
object
- 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,
Bases:
object
- class netgear_switch.models.MacEntry(mac: 'str', port: 'int', vlan_id: 'int | None')[source]¶
Bases:
object
- class netgear_switch.models.Sensor(name: 'str', kind: 'str', value: 'float', unit: 'str')[source]¶
Bases:
object
- 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',
Bases:
object
- class netgear_switch.models.MgmtIpConfig(
- mode: 'IpMode',
- address: 'str | None',
- netmask: 'str | None',
- gateway: 'str | None',
- base_mac: 'str | None' = None,
Bases:
object
- class netgear_switch.models.ServiceStatus(name, enabled, port=None)[source]¶
Bases:
objectOne 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,telnetandssh.
- 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 usersPrivilege-15 Read/Write Privilege-1 / Read Only web userManagement.html Super User Read OnlyNote 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_modeis a full-privilege level, orNoneif 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,
Bases:
objectOne local login account on the switch.
- 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_MODESfor the three spellings measured so far.
- privileged: bool | None¶
Whether
access_modeis the full-privilege level, normalised across every measured vocabulary so callers do not have to know which image or which backend they are on.Nonewhen the text is none of them – an unrecognised level is reported honestly rather than guessed.
- class netgear_switch.models.SyslogServer(host, port, severity, active, index=None)[source]¶
Bases:
objectOne remote syslog collector the switch is configured to send to.
- 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 hostsprints “info”.
- 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.
Nonefrom 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 hostsprints “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 hostcommand 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 hostsprints “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_wordfor the CLI’s spelling of the same value.- Return type:
- 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:
- netgear_switch.models.syslog_severity(name)[source]¶
A switch’s severity WORD -> its standard number, case-insensitively.
Raises
ValueErroron 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:
- class netgear_switch.models.SyslogConfig(enabled, local_port, servers)[source]¶
Bases:
objectRemote-logging configuration: whether it is on, and where it sends.
Deliberately narrower than everything
show loggingprints. 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. SeeVendorOids.syslog_*.- local_port: int¶
The source port the switch sends FROM (
Logging Client Local Port), not the collector’s port – that is per-server inservers.
- servers: tuple[SyslogServer, ...]¶
- class netgear_switch.models.DetectedModel(key, sys_descr, sys_object_id)[source]¶
Bases:
objectResult of identifying a switch’s model over SNMP (sysObjectID + sysDescr).
keyis a registry key (seeregistry.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) – orNoneif neither did (an unregistered Netgear model, a non-Netgear device, or an unreadable/absent reply).Noneis NEVER a fabricated guess.sys_descr/sys_object_idare the raw SNMP-reported strings, kept for the caller/logging even when unmatched.sys_object_idIS 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.
- 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,
Bases:
object- ports: tuple[PortStatus, ...] = ()¶
- lldp: tuple[LLDPNeighbor, ...] = ()¶
- 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,
Bases:
object- switch_class: SwitchClass¶
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 (seedocs/_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:
objectThe verdict for one (model, backend, operation) triple.
- reason: str = ''¶
Empty when
supportisSupport.SUPPORTED; otherwise the reason, phrased the way the corresponding reader/writer phrases it.
- class netgear_switch.capabilities.Operation(name, kind, summary, backends=None)[source]¶
Bases:
objectOne facade operation, as exposed by
SyncSwitch/AsyncSwitch.- kind: OperationKind¶
- backends: frozenset[Backend] | None = None¶
Backends that can ever serve this op, for the few that bypass normal backend dispatch (
nsdp_deviceis NSDP-only; certificate upload is HTTP or CLI-over-SCP).Nonemeans “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:
EnumHow 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_backendraises, 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.
- netgear_switch.capabilities.matrix(models=None, operations=OPERATIONS)[source]¶
Every verdict for
modelsx their backends xoperations.Defaults to every registered model. Only backends a model actually has are included, so the result never carries
Support.NO_BACKENDrows.- Return type:
tuple[Capability, …]
- netgear_switch.capabilities.operation(name)[source]¶
Look an
Operationup by facade method name.- Return type:
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]',
Bases:
object- model: SwitchModel¶
Errors¶
Exception hierarchy for netgear_switch.
- exception netgear_switch.errors.NetgearSwitchError[source]¶
Bases:
ExceptionBase class for every error raised by this library.
- exception netgear_switch.errors.ConfigError[source]¶
Bases:
NetgearSwitchErrorThe inventory/config file is malformed or invalid.
- exception netgear_switch.errors.CredentialError[source]¶
Bases:
NetgearSwitchErrorA required secret could not be resolved from any source.
- exception netgear_switch.errors.UnknownModelError[source]¶
Bases:
NetgearSwitchErrorA switch references a model key that is not in the registry.
- exception netgear_switch.errors.UnsupportedCapabilityError[source]¶
Bases:
NetgearSwitchErrorThe requested operation is not available on this model/backend.
- exception netgear_switch.errors.ProtectedPortError[source]¶
Bases:
NetgearSwitchErrorA disruptive write targeted a protected port without force=True.
- exception netgear_switch.errors.WriteVerificationError(message, *, before, after)[source]¶
Bases:
NetgearSwitchErrorA 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:
NetgearSwitchErrorA 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.SnmpErrorfor 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 fromWriteVerificationError, which means the commands WERE accepted but the switch did not read back the intended state, and fromtransport.cli.session.CliTransportError, which means the connection / prompt framing itself failed.
- exception netgear_switch.errors.HttpError[source]¶
Bases:
NetgearSwitchErrorAn HTTP web-UI transport operation failed (connect, HTTP status, page shape).