Source code for netgear_switch.models

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

from __future__ import annotations

import enum
from dataclasses import dataclass
from types import MappingProxyType
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Mapping


[docs] class PoEDetect(enum.Enum): DISABLED = "disabled" SEARCHING = "searching" DELIVERING = "delivering" FAULT = "fault" UNKNOWN = "unknown"
[docs] class VlanMode(enum.Enum): UNTAGGED = "untagged" TAGGED = "tagged" EXCLUDED = "excluded"
[docs] class IpMode(enum.Enum): DHCP = "dhcp" STATIC = "static" UNKNOWN = "unknown"
[docs] @dataclass(frozen=True) class PortSpeed: """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 def __post_init__(self) -> None: if self.autonegotiate: if self.speed_mbps is not None or self.full_duplex is not None: raise ValueError( "an auto-negotiating port has no configured rate or duplex; " "leave speed_mbps and full_duplex as None" ) elif self.speed_mbps is None or self.full_duplex is None: raise ValueError( "a forced port configuration needs BOTH a rate and a duplex " "(the firmware's own grammar requires them together)" )
[docs] @classmethod def auto(cls) -> PortSpeed: """Auto-negotiate (the factory default on every switch measured here).""" return cls(autonegotiate=True)
[docs] @classmethod def forced(cls, speed_mbps: int, *, full_duplex: bool) -> PortSpeed: """Force a fixed rate and duplex, disabling auto-negotiation.""" return cls(autonegotiate=False, speed_mbps=speed_mbps, full_duplex=full_duplex)
def __str__(self) -> str: if self.autonegotiate: return "auto" return f"{self.speed_mbps}M {'full' if self.full_duplex else 'half'}-duplex"
[docs] @dataclass(frozen=True) class PortStatus: port: int name: str | None admin_enabled: bool link_up: bool speed_mbps: int | None # ifAlias (operator-set port description) -- distinct from `name` (ifName). # Defaults to None so existing positional call sites (name-only backends, # older tests) keep constructing without it; a backend that cannot read # ifAlias (NSDP, HTTP) leaves it honestly None rather than fabricating "". description: str | 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. full_duplex: 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. flow_control: bool | 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. speed_config: PortSpeed | None = None
[docs] @dataclass(frozen=True) class PoEStatus: port: int admin_enabled: bool detect: PoEDetect power_mw: int | None @property def delivering(self) -> bool: return self.detect is PoEDetect.DELIVERING
[docs] def poe_cycle_complete(before: PoEStatus | None, now: PoEStatus | None) -> bool: """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. """ if now is None: return False if before is not None and before.delivering: return now.delivering return now.detect in (PoEDetect.DELIVERING, PoEDetect.SEARCHING)
[docs] @dataclass(frozen=True) class VLANInfo: vlan_id: int name: str | None member_ports: frozenset[int] tagged_ports: frozenset[int] untagged_ports: frozenset[int]
[docs] @dataclass(frozen=True) class LLDPNeighbor: local_port: int remote_sys_name: str | None remote_port_desc: str | None remote_chassis_id: str | None # lldpRemPortId (LLDP-MIB column 7): the remote port's IDENTIFIER, distinct # from remote_port_desc (lldpRemPortDesc, column 8) -- e.g. a neighbour can # report port_id "gi24" and port_desc "gi24.uplink" as different values. # Defaults to None so existing positional call sites (older tests, a # backend that cannot read this column) keep constructing without it. remote_port_id: str | None = None
[docs] @dataclass(frozen=True) class MacEntry: mac: str port: int vlan_id: int | None
[docs] @dataclass(frozen=True) class Sensor: name: str kind: str # "temperature" | "fan" | "power" value: float unit: str
[docs] @dataclass(frozen=True) class 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
[docs] @dataclass(frozen=True) class MgmtIpConfig: mode: IpMode address: str | None netmask: str | None gateway: str | None # dot1dBaseBridgeAddress (BRIDGE-MIB) / the NSDP identity MAC / the HTTP # sysInfo.html "MAC Address" row: the switch's own base MAC, formatted # "XX:XX:XX:XX:XX:XX" (uppercase) by every backend that reads it (SNMP's # parse_base_mac, NSDP's .upper(), HTTP's _mgmt_ip_from_sysinfo -- the # real captured HTTP page text is lowercase and gets normalized). Defaults # to None so existing positional call sites keep constructing without it; # a backend/model whose web UI has no such page at all (gs305ep, # gsm7228ps) leaves it honestly None via UnsupportedCapabilityError # rather than fabricating a value. base_mac: str | None = None
[docs] @dataclass(frozen=True) class ServiceStatus: """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``. """ #: One of ``http``, ``https``, ``telnet``, ``ssh``. name: str enabled: bool #: 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. port: int | None = None
#: 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. PRIVILEGED_ACCESS_MODES: frozenset[str] = frozenset( {"privilege-15", "read/write", "super user"} ) UNPRIVILEGED_ACCESS_MODES: frozenset[str] = frozenset( {"privilege-1", "read only", "no access"} )
[docs] def privileged_access(access_mode: str) -> bool | None: """Whether ``access_mode`` is a full-privilege level, or ``None`` if the word is one this library has not measured on a device.""" text = access_mode.strip().lower() if text in PRIVILEGED_ACCESS_MODES: return True if text in UNPRIVILEGED_ACCESS_MODES: return False return None
[docs] @dataclass(frozen=True) class SwitchUser: """One local login account on the switch.""" name: 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 #: :data:`PRIVILEGED_ACCESS_MODES` for the three spellings measured so far. access_mode: str #: 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. privileged: bool | None #: The three SNMPv3 columns the same table carries. ``None`` where the #: firmware prints nothing. snmpv3_access: str | None = None snmpv3_auth: str | None = None snmpv3_encryption: str | None = None
[docs] @dataclass(frozen=True) class SyslogServer: """One remote syslog collector the switch is configured to send to.""" host: str port: 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". severity: int #: The switch's own word for the row's state, "Active" in the CLI table. active: bool #: 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. index: int | None = None
#: 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. SYSLOG_SEVERITY_NAMES: Mapping[str, int] = MappingProxyType( { "emergency": 0, "alert": 1, "critical": 2, "error": 3, "warning": 4, "notice": 5, "info": 6, "informational": 6, "debug": 7, } ) #: 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). SYSLOG_SEVERITY_WORDS: Mapping[int, str] = MappingProxyType( { 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. SYSLOG_SEVERITY_LABELS: Mapping[int, str] = MappingProxyType( { 0: "Emergency", 1: "Alert", 2: "Critical", 3: "Error", 4: "Warning", 5: "Notice", 6: "Info", 7: "Debug", } )
[docs] def syslog_severity_label(level: int) -> str: """A severity NUMBER -> the word the WEB UI's enum carries. See ``syslog_severity_word`` for the CLI's spelling of the same value. """ try: return SYSLOG_SEVERITY_LABELS[level] except KeyError: raise ValueError( f"syslog severity {level!r} is outside the standard range 0-7" ) from None
[docs] def syslog_severity_word(level: int) -> str: """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. """ try: return SYSLOG_SEVERITY_WORDS[level] except KeyError: raise ValueError( f"syslog severity {level!r} is outside the standard range 0-7" ) from None
[docs] def syslog_severity(name: str) -> int: """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. """ try: return SYSLOG_SEVERITY_NAMES[name.strip().lower()] except KeyError: raise ValueError( f"unknown syslog severity {name!r}; measured names are " f"{sorted(SYSLOG_SEVERITY_NAMES)}" ) from None
[docs] @dataclass(frozen=True) class SyslogConfig: """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 #: The source port the switch sends FROM (``Logging Client Local Port``), #: not the collector's port -- that is per-server in ``servers``. local_port: int servers: tuple[SyslogServer, ...]
[docs] @dataclass(frozen=True) class DetectedModel: """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 def matched(self) -> bool: return self.key is not None
[docs] @dataclass(frozen=True) class 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