python-netgear-switch-library 0.0.post154__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. netgear_switch/__init__.py +132 -0
  2. netgear_switch/_dispatch.py +178 -0
  3. netgear_switch/_version.py +24 -0
  4. netgear_switch/aio_api.py +529 -0
  5. netgear_switch/cli/__init__.py +1 -0
  6. netgear_switch/cli/capture.py +131 -0
  7. netgear_switch/cli/context.py +39 -0
  8. netgear_switch/cli/format.py +201 -0
  9. netgear_switch/cli/main.py +484 -0
  10. netgear_switch/cli/resolve.py +108 -0
  11. netgear_switch/cli/safety.py +71 -0
  12. netgear_switch/config.py +184 -0
  13. netgear_switch/errors.py +52 -0
  14. netgear_switch/http_read.py +174 -0
  15. netgear_switch/http_write.py +420 -0
  16. netgear_switch/models.py +156 -0
  17. netgear_switch/nsdp_read.py +221 -0
  18. netgear_switch/nsdp_write.py +315 -0
  19. netgear_switch/protocols/__init__.py +1 -0
  20. netgear_switch/protocols/http/__init__.py +1 -0
  21. netgear_switch/protocols/http/crypt.py +29 -0
  22. netgear_switch/protocols/http/endpoints.py +165 -0
  23. netgear_switch/protocols/http/forms.py +77 -0
  24. netgear_switch/protocols/http/parse.py +238 -0
  25. netgear_switch/protocols/http/session.py +29 -0
  26. netgear_switch/protocols/nsdp/__init__.py +7 -0
  27. netgear_switch/protocols/nsdp/auth.py +33 -0
  28. netgear_switch/protocols/nsdp/client.py +67 -0
  29. netgear_switch/protocols/nsdp/parsers.py +209 -0
  30. netgear_switch/protocols/nsdp/protocol.py +201 -0
  31. netgear_switch/protocols/nsdp/types.py +137 -0
  32. netgear_switch/protocols/nsdp/write.py +98 -0
  33. netgear_switch/protocols/snmp/__init__.py +1 -0
  34. netgear_switch/protocols/snmp/client.py +88 -0
  35. netgear_switch/protocols/snmp/oids.py +125 -0
  36. netgear_switch/protocols/snmp/parse.py +777 -0
  37. netgear_switch/protocols/snmp/write.py +112 -0
  38. netgear_switch/py.typed +0 -0
  39. netgear_switch/registry.py +227 -0
  40. netgear_switch/snmp_read.py +226 -0
  41. netgear_switch/snmp_write.py +625 -0
  42. netgear_switch/sync_api.py +557 -0
  43. netgear_switch/transport/__init__.py +1 -0
  44. netgear_switch/transport/aio/__init__.py +1 -0
  45. netgear_switch/transport/aio/nsdp_udp.py +152 -0
  46. netgear_switch/transport/aio/snmp_pysnmp.py +247 -0
  47. netgear_switch/transport/http/__init__.py +1 -0
  48. netgear_switch/transport/http/client.py +217 -0
  49. netgear_switch/transport/sync/__init__.py +1 -0
  50. netgear_switch/transport/sync/nsdp_udp.py +109 -0
  51. netgear_switch/transport/sync/snmp_netsnmp_cli.py +257 -0
  52. netgear_switch/virtual/__init__.py +8 -0
  53. netgear_switch/virtual/faces/__init__.py +2 -0
  54. netgear_switch/virtual/faces/http.py +164 -0
  55. netgear_switch/virtual/faces/mibview.py +92 -0
  56. netgear_switch/virtual/faces/nsdp.py +124 -0
  57. netgear_switch/virtual/faces/snmp.py +412 -0
  58. netgear_switch/virtual/seed.py +220 -0
  59. netgear_switch/virtual/server.py +106 -0
  60. netgear_switch/virtual/state.py +615 -0
  61. netgear_switch/virtual/web.py +210 -0
  62. python_netgear_switch_library-0.0.post154.dist-info/METADATA +85 -0
  63. python_netgear_switch_library-0.0.post154.dist-info/RECORD +66 -0
  64. python_netgear_switch_library-0.0.post154.dist-info/WHEEL +4 -0
  65. python_netgear_switch_library-0.0.post154.dist-info/entry_points.txt +2 -0
  66. python_netgear_switch_library-0.0.post154.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,112 @@
1
+ """Pure SNMP write encoding: SET varbinds and Q-BRIDGE bitmap read-modify-write.
2
+
3
+ No I/O and transport-agnostic. ``SetVarbind`` carries a net-snmp-style type
4
+ letter (``i`` INTEGER, ``u`` Gauge32/unsigned, ``s`` string, ``x`` hex/octets,
5
+ ``a`` IpAddress) that both transports map onto their own SET call. The bitmap
6
+ helpers do a read-modify-write so only the target port's bit changes, leaving
7
+ trunks and other access ports untouched (design spec §6).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ from ...models import VlanMode
15
+ from .parse import decode_port_bitmap
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Iterable
19
+
20
+ from ...registry import SwitchModel
21
+
22
+ SET_TYPE_LETTERS: frozenset[str] = frozenset({"i", "u", "s", "x", "a"})
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SetVarbind:
27
+ """One SNMP SET varbind: full numeric OID, value, and net-snmp type letter."""
28
+
29
+ oid: str
30
+ value: int | str | bytes
31
+ type_letter: str
32
+
33
+ def __post_init__(self) -> None:
34
+ if self.type_letter not in SET_TYPE_LETTERS:
35
+ raise ValueError(
36
+ f"unknown SET type letter {self.type_letter!r}; "
37
+ f"expected one of {sorted(SET_TYPE_LETTERS)}"
38
+ )
39
+
40
+
41
+ def encode_port_bitmap(ports: Iterable[int], width_bytes: int = 8) -> bytes:
42
+ """Inverse of ``parse.decode_port_bitmap``: a port set -> a wire bitmap.
43
+
44
+ Bit 7 (MSB) of byte 0 is port 1. The buffer grows past ``width_bytes`` if a
45
+ port number needs it, so callers never pre-size for the actual port count.
46
+ """
47
+ data = bytearray(width_bytes)
48
+ for p in ports:
49
+ byte_idx, bit = divmod(p - 1, 8)
50
+ while byte_idx >= len(data):
51
+ data.append(0)
52
+ data[byte_idx] |= 0x80 >> bit
53
+ return bytes(data)
54
+
55
+
56
+ def set_port_bit(
57
+ current: bytes | str, port: int, present: bool, *, width_bytes: int | None = None
58
+ ) -> bytes:
59
+ """Read-modify-write one port's bit in a VLAN bitmap; all others preserved.
60
+
61
+ Preserves the input bitmap's byte width to avoid wire-length mismatches on
62
+ SET for >64-port switches. ``width_bytes``, if given (e.g. a model-derived
63
+ width from ``vlan_bitmap_width``), is honoured too: the result is at least
64
+ 8 bytes, at least as wide as the input, and at least ``width_bytes`` wide.
65
+ """
66
+ # Compute the current bitmap's width in bytes
67
+ if isinstance(current, bytes):
68
+ current_width = len(current)
69
+ else:
70
+ current_width = len(current.encode("latin-1"))
71
+
72
+ ports = set(decode_port_bitmap(current))
73
+ if present:
74
+ ports.add(port)
75
+ else:
76
+ ports.discard(port)
77
+ target_width = max(8, current_width, width_bytes or 0)
78
+ return encode_port_bitmap(ports, width_bytes=target_width)
79
+
80
+
81
+ def membership_bitmaps(
82
+ *,
83
+ mode: VlanMode,
84
+ port: int,
85
+ egress: bytes | str,
86
+ untagged: bytes | str,
87
+ width_bytes: int | None = None,
88
+ ) -> tuple[bytes, bytes]:
89
+ """Compute (new_egress, new_untagged) for one port's VLAN membership change.
90
+
91
+ UNTAGGED -> egress bit on + untagged bit on; TAGGED -> egress on, untagged
92
+ off; EXCLUDED -> both off. Read-modify-write on the current bitmaps, so
93
+ every other port's membership is preserved. ``width_bytes`` is forwarded to
94
+ ``set_port_bit`` for both columns (see ``vlan_bitmap_width``).
95
+ """
96
+ in_egress = mode in (VlanMode.UNTAGGED, VlanMode.TAGGED)
97
+ in_untagged = mode is VlanMode.UNTAGGED
98
+ return (
99
+ set_port_bit(egress, port, in_egress, width_bytes=width_bytes),
100
+ set_port_bit(untagged, port, in_untagged, width_bytes=width_bytes),
101
+ )
102
+
103
+
104
+ def vlan_bitmap_width(model: SwitchModel) -> int:
105
+ """Wire byte-width of ``model``'s dot1q VLAN egress/untagged bitmaps.
106
+
107
+ ``dot1qVlanStaticEgressPorts``/``UntaggedPorts`` are packed 8 ports/byte,
108
+ MSB-first (port 1 = bit 7 of byte 0). The Q-BRIDGE MIB's own default
109
+ PortList width is 8 bytes (64 ports); a model with more ports needs a
110
+ wider bitmap or the SET's wire length won't match what the device expects.
111
+ """
112
+ return max(8, (model.port_count + 7) // 8)
File without changes
@@ -0,0 +1,227 @@
1
+ """Declarative registry of known Netgear switch models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from dataclasses import dataclass
7
+ from types import MappingProxyType
8
+ from typing import TYPE_CHECKING
9
+
10
+ from .errors import UnknownModelError
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Mapping
14
+
15
+ _FM = "1.3.6.1.4.1.4526.10" # Fully Managed vendor subtree (M4300, GSM7252PS)
16
+ _SMP = "1.3.6.1.4.1.4526.11" # Smart Managed Pro vendor subtree (S3300/GSM7228PS)
17
+
18
+
19
+ class Backend(enum.Enum):
20
+ SNMP = "snmp"
21
+ NSDP = "nsdp"
22
+ HTTP = "http"
23
+
24
+
25
+ class SwitchClass(enum.Enum):
26
+ FULLY_MANAGED = "fully_managed"
27
+ SMART_MANAGED_PRO = "smart_managed_pro"
28
+ PLUS = "plus"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class SwitchModel:
33
+ key: str
34
+ display_name: str
35
+ switch_class: SwitchClass
36
+ port_count: int
37
+ poe_port_count: int
38
+ backends: frozenset[Backend]
39
+ snmp_vendor_base: str | None
40
+ # True (the default) for every model with a real device capture or other
41
+ # hardware-validated prior art backing its fields. False marks a model
42
+ # registered from spec sheets/product briefs alone, with NO capture --
43
+ # its port/PoE counts and (for SNMP models) vendor OID family are a
44
+ # best-effort guess, and vendor-specific reads (get_sensors, vendor PoE
45
+ # power, etc.) are UNVERIFIED-pending-capture even though the
46
+ # model-agnostic standard-MIB/CGI reads should still work. See the
47
+ # UNVERIFIED-pending-capture entries below (m7300, xs748t, gs728tpp) for
48
+ # the honesty rationale; do NOT flip this to True without a real capture.
49
+ verified: bool = True
50
+
51
+ @property
52
+ def has_mac_table(self) -> bool:
53
+ # MAC/FDB table is only reachable via SNMP (managed switches).
54
+ return Backend.SNMP in self.backends
55
+
56
+
57
+ def _model(
58
+ key: str,
59
+ display_name: str,
60
+ switch_class: SwitchClass,
61
+ port_count: int,
62
+ poe_port_count: int,
63
+ backends: set[Backend],
64
+ snmp_vendor_base: str | None,
65
+ *,
66
+ verified: bool = True,
67
+ ) -> SwitchModel:
68
+ return SwitchModel(
69
+ key=key,
70
+ display_name=display_name,
71
+ switch_class=switch_class,
72
+ port_count=port_count,
73
+ poe_port_count=poe_port_count,
74
+ backends=frozenset(backends),
75
+ snmp_vendor_base=snmp_vendor_base,
76
+ verified=verified,
77
+ )
78
+
79
+
80
+ _MODELS: dict[str, SwitchModel] = {
81
+ m.key: m
82
+ for m in (
83
+ _model(
84
+ "m4300-24x",
85
+ "M4300-24X (XSM4324CS)",
86
+ SwitchClass.FULLY_MANAGED,
87
+ 28,
88
+ 0,
89
+ {Backend.SNMP},
90
+ _FM,
91
+ ),
92
+ _model(
93
+ "m4300-16x",
94
+ "M4300-16X (XSM4316)",
95
+ SwitchClass.FULLY_MANAGED,
96
+ 16,
97
+ 16,
98
+ {Backend.SNMP},
99
+ _FM,
100
+ ),
101
+ _model(
102
+ "gsm7252ps",
103
+ "GSM7252PS",
104
+ SwitchClass.FULLY_MANAGED,
105
+ 52,
106
+ 48,
107
+ {Backend.SNMP},
108
+ _FM,
109
+ ),
110
+ _model(
111
+ "gsm7228ps",
112
+ "GSM7228PS (S3300)",
113
+ SwitchClass.SMART_MANAGED_PRO,
114
+ 52,
115
+ 48,
116
+ {Backend.SNMP, Backend.HTTP},
117
+ _SMP,
118
+ ),
119
+ _model(
120
+ "gs110emx",
121
+ "GS110EMX",
122
+ SwitchClass.PLUS,
123
+ 10,
124
+ 0,
125
+ {Backend.NSDP, Backend.HTTP},
126
+ None,
127
+ ),
128
+ _model(
129
+ "gs305ep",
130
+ "GS305EP",
131
+ SwitchClass.PLUS,
132
+ 5,
133
+ 4,
134
+ {Backend.NSDP, Backend.HTTP},
135
+ None,
136
+ ),
137
+ # --- UNVERIFIED-pending-capture below: no device capture exists for
138
+ # any of these three models (gdoc2netcfg fleet models with no
139
+ # prior-art fixture). Registered from spec sheets/product briefs only
140
+ # so gdoc2netcfg can construct a SyncSwitch for them; see each
141
+ # entry's comment for what specifically is a guess. The
142
+ # model-agnostic standard-MIB SNMP reads (ports/vlans/lldp/PoE
143
+ # admin/stats/mgmt-IP) should work regardless of the vendor OID
144
+ # family guess below, but get_sensors() and vendor PoE-power
145
+ # readings are UNVERIFIED until a real capture confirms the
146
+ # 4526.10 vs 4526.11 subtree. Do NOT treat any of these three as a
147
+ # source of confirmed behaviour -- confirm via hardware
148
+ # verification before relying on anything beyond the standard MIBs.
149
+ _model(
150
+ "m7300",
151
+ # M7300-24XF (24x SFP+, 0 PoE) picked as the assumed/documented
152
+ # variant -- the M7300 family also ships non-XF and other port
153
+ # counts; which exact SKU gdoc2netcfg's fleet actually runs is
154
+ # UNVERIFIED. Same FASTPATH fully-managed lineage as M4300, so
155
+ # the 4526.10 ("_FM") vendor subtree is the best spec-guess, but
156
+ # that family assignment is itself UNVERIFIED-pending-capture.
157
+ "M7300-24XF",
158
+ SwitchClass.FULLY_MANAGED,
159
+ 24,
160
+ 0,
161
+ {Backend.SNMP},
162
+ _FM,
163
+ verified=False,
164
+ ),
165
+ _model(
166
+ "xs748t",
167
+ # XS748T: 48x 10G copper (+ SFP+ combo), non-PoE per the
168
+ # documented base spec -- UNVERIFIED-pending-capture. HTTP is
169
+ # plausible for a Smart Managed Pro switch but is deliberately
170
+ # OMITTED here (not just unverified): see gsm7228ps for the
171
+ # SNMP+HTTP shape once a login/read flow is actually captured.
172
+ # Until then SNMP-only avoids implying a web-UI integration that
173
+ # does not exist in this codebase.
174
+ "XS748T",
175
+ SwitchClass.SMART_MANAGED_PRO,
176
+ 48,
177
+ 0,
178
+ {Backend.SNMP},
179
+ _SMP,
180
+ verified=False,
181
+ ),
182
+ _model(
183
+ "gs728tpp",
184
+ # GS728TPP: 24x Gigabit PoE+ + 4x SFP combo = 28 total ports,
185
+ # 24 PoE+ -- UNVERIFIED-pending-capture (port split assumed from
186
+ # the product name's "28" port count and Gigabit PoE+ line
187
+ # convention, not a capture).
188
+ #
189
+ # HTTP backend deliberately OMITTED even though this model's
190
+ # web UI is real and reachable: certbot-hook-netgear-switches/
191
+ # netgear-updater.py's GS728TPPUpdater (grounded prior art) shows
192
+ # GS728TPP uses a THIRD, distinct login scheme -- a GET / redirect
193
+ # to a per-session path, then GET {path}/System.xml?action=login&
194
+ # user=...&password=... (not a POST), with userStatus/usernme/
195
+ # sessionID cookies set from the response rather than via a
196
+ # normal Set-Cookie login POST. That is neither MERGE_HASH_CGI,
197
+ # GAMBIT, nor CHEETAH_FORM (registry.protocols.http.endpoints
198
+ # .LoginScheme), and transport/http/client.py's login() only
199
+ # knows how to drive those three. Registering Backend.HTTP here
200
+ # without real scheme/transport support would either (a) fail
201
+ # tests/protocols/http/test_endpoints.py::
202
+ # test_every_http_model_has_a_spec, or (b) force picking an
203
+ # existing (wrong) LoginScheme and have login() attempt a
204
+ # garbage POST against a real switch -- both dishonest. Wiring
205
+ # in a real LoginScheme.XML_API is a dedicated future slice, not
206
+ # a registry-only change. SNMP-only here is sufficient for
207
+ # gdoc2netcfg's SyncSwitch construction; only the SNMP vendor
208
+ # OID family is UNVERIFIED-pending-capture.
209
+ "GS728TPP",
210
+ SwitchClass.SMART_MANAGED_PRO,
211
+ 28,
212
+ 24,
213
+ {Backend.SNMP},
214
+ _SMP,
215
+ verified=False,
216
+ ),
217
+ )
218
+ }
219
+
220
+ MODELS: Mapping[str, SwitchModel] = MappingProxyType(_MODELS)
221
+
222
+
223
+ def get_model(key: str) -> SwitchModel:
224
+ try:
225
+ return _MODELS[key]
226
+ except KeyError:
227
+ raise UnknownModelError(f"unknown switch model: {key!r}") from None
@@ -0,0 +1,226 @@
1
+ # src/netgear_switch/snmp_read.py
2
+ """Model-driven SNMP read operations over a sync or async client."""
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .errors import UnsupportedCapabilityError
8
+ from .models import DetectedModel
9
+ from .protocols.snmp import oids, parse
10
+ from .registry import MODELS, Backend
11
+
12
+ if TYPE_CHECKING:
13
+ # Only used in type annotations (return types / parameter types), never
14
+ # instantiated or referenced at runtime here -- kept behind
15
+ # TYPE_CHECKING so ruff's TC rules stay clean (see oids.py for the same
16
+ # pattern).
17
+ from .models import (
18
+ LLDPNeighbor,
19
+ MacEntry,
20
+ MgmtIpConfig,
21
+ PoEStatus,
22
+ PortStats,
23
+ PortStatus,
24
+ Sensor,
25
+ VLANInfo,
26
+ )
27
+ from .protocols.snmp.client import AsyncSnmpClient, SnmpClient
28
+ from .registry import SwitchModel
29
+
30
+
31
+ def _require_snmp(model: SwitchModel) -> None:
32
+ if Backend.SNMP not in model.backends:
33
+ raise UnsupportedCapabilityError(f"model {model.key!r} has no SNMP backend")
34
+
35
+
36
+ def read_system_info(client: SnmpClient) -> DetectedModel:
37
+ """Identify a switch's model via SNMP sysDescr matching.
38
+
39
+ Deliberately NOT a method on ``SnmpReader``: every other read in this
40
+ module requires a already-known ``SwitchModel`` (``_require_snmp`` gates
41
+ the reader's construction), but model identification exists precisely
42
+ for the case where the caller does NOT yet know/trust the model -- so
43
+ this takes a bare, unbound ``SnmpClient`` instead. See
44
+ ``protocols.snmp.parse.detect_model_from_sysdescr`` for the honesty-
45
+ constrained matching rules (never guesses; ``key=None`` means genuinely
46
+ unidentified -- an unregistered model or a non-Netgear device).
47
+ """
48
+ rows = client.get([oids.SYS_DESCR, oids.SYS_OBJECT_ID])
49
+ sys_descr, sys_object_id = parse.parse_system_info(rows)
50
+ key = parse.detect_model_from_sysdescr(sys_descr, MODELS)
51
+ return DetectedModel(key=key, sys_descr=sys_descr, sys_object_id=sys_object_id)
52
+
53
+
54
+ async def async_read_system_info(client: AsyncSnmpClient) -> DetectedModel:
55
+ """Async twin of ``read_system_info`` -- see there."""
56
+ rows = await client.get([oids.SYS_DESCR, oids.SYS_OBJECT_ID])
57
+ sys_descr, sys_object_id = parse.parse_system_info(rows)
58
+ key = parse.detect_model_from_sysdescr(sys_descr, MODELS)
59
+ return DetectedModel(key=key, sys_descr=sys_descr, sys_object_id=sys_object_id)
60
+
61
+
62
+ class SnmpReader:
63
+ def __init__(self, client: SnmpClient, model: SwitchModel) -> None:
64
+ # _require_snmp is the single capability gate: it raises for any model
65
+ # without an SNMP backend (i.e. Plus). Vendor OIDs are resolved lazily,
66
+ # only in the ops that need the vendor subtree (get_poe/get_sensors/
67
+ # get_mgmt_ip), so constructing a reader never touches vendor_oids.
68
+ _require_snmp(model)
69
+ self.client = client
70
+ self.model = model
71
+
72
+ def get_ports(self) -> list[PortStatus]:
73
+ w = self.client.walk
74
+ return parse.parse_port_status(
75
+ w(oids.IF_ADMIN_STATUS), w(oids.IF_OPER_STATUS),
76
+ w(oids.IF_HIGH_SPEED), w(oids.IF_NAME), w(oids.IF_ALIAS),
77
+ )
78
+
79
+ def get_stats(self) -> list[PortStats]:
80
+ w = self.client.walk
81
+ return parse.parse_port_stats(
82
+ in_octets=w(oids.IF_HC_IN_OCTETS), out_octets=w(oids.IF_HC_OUT_OCTETS),
83
+ in_ucast=w(oids.IF_HC_IN_UCAST), out_ucast=w(oids.IF_HC_OUT_UCAST),
84
+ in_errors=w(oids.IF_IN_ERRORS), out_errors=w(oids.IF_OUT_ERRORS),
85
+ )
86
+
87
+ def get_vlans(self) -> list[VLANInfo]:
88
+ w = self.client.walk
89
+ return parse.parse_vlans(
90
+ w(oids.DOT1Q_VLAN_STATIC_NAME), w(oids.DOT1Q_VLAN_STATIC_EGRESS),
91
+ w(oids.DOT1Q_VLAN_STATIC_UNTAGGED),
92
+ )
93
+
94
+ def get_pvids(self) -> list[tuple[int, int]]:
95
+ return parse.parse_pvids(self.client.walk(oids.DOT1Q_PVID))
96
+
97
+ def get_lldp(self) -> list[LLDPNeighbor]:
98
+ return parse.parse_lldp(self.client.walk(oids.LLDP_REM_TABLE))
99
+
100
+ def get_macs(self) -> list[MacEntry]:
101
+ # No has_mac_table guard here: has_mac_table == (Backend.SNMP in
102
+ # backends), which __init__'s _require_snmp already enforced. (The
103
+ # registry.has_mac_table property stays for external callers.)
104
+ w = self.client.walk
105
+ return parse.parse_macs(
106
+ w(oids.DOT1Q_TP_FDB_PORT), w(oids.DOT1D_BASE_PORT_IF_INDEX)
107
+ )
108
+
109
+ def get_poe(self) -> list[PoEStatus]:
110
+ vendor = oids.vendor_oids(self.model)
111
+ w = self.client.walk
112
+ return parse.parse_poe(
113
+ w(oids.PETH_PSE_PORT_TABLE), w(vendor.poe_power_mw)
114
+ )
115
+
116
+ def get_sensors(self) -> list[Sensor]:
117
+ vendor = oids.vendor_oids(self.model)
118
+ w = self.client.walk
119
+ columns = [
120
+ ("fan", "RPM", w(vendor.box_fan)),
121
+ ("power", "W", w(vendor.box_psu_power)),
122
+ ("temperature", "C", w(vendor.box_temp)),
123
+ ]
124
+ return parse.parse_box_sensors(columns)
125
+
126
+ def get_mgmt_ip(self) -> MgmtIpConfig:
127
+ vendor = oids.vendor_oids(self.model)
128
+ w = self.client.walk
129
+ return parse.parse_mgmt_ip(
130
+ w(oids.IP_ADENT_ADDR), w(oids.IP_ADENT_NETMASK),
131
+ w(oids.IP_ROUTE_DEST), w(oids.IP_ROUTE_NEXTHOP),
132
+ w(vendor.dhcp_mode_unverified), # single named UNVERIFIED OID (Task 4)
133
+ w(oids.DOT1D_BASE_BRIDGE_ADDRESS), # standard BRIDGE-MIB scalar
134
+ )
135
+
136
+ def get_system_info(self) -> DetectedModel:
137
+ """Identify this switch's model via sysDescr (see ``read_system_info``).
138
+
139
+ Reuses this reader's already-connected client. Unlike every other
140
+ method here, the result does NOT depend on ``self.model`` matching
141
+ the real device -- useful to confirm/discover a switch's real model
142
+ via a reader that was (possibly wrongly) constructed against a
143
+ different model key.
144
+ """
145
+ return read_system_info(self.client)
146
+
147
+
148
+ class AsyncSnmpReader:
149
+ def __init__(self, client: AsyncSnmpClient, model: SwitchModel) -> None:
150
+ # Same contract as SnmpReader: _require_snmp gates construction; vendor
151
+ # OIDs resolved lazily in get_poe/get_sensors/get_mgmt_ip only.
152
+ _require_snmp(model)
153
+ self.client = client
154
+ self.model = model
155
+
156
+ async def get_ports(self) -> list[PortStatus]:
157
+ w = self.client.walk
158
+ return parse.parse_port_status(
159
+ await w(oids.IF_ADMIN_STATUS), await w(oids.IF_OPER_STATUS),
160
+ await w(oids.IF_HIGH_SPEED), await w(oids.IF_NAME),
161
+ await w(oids.IF_ALIAS),
162
+ )
163
+
164
+ async def get_stats(self) -> list[PortStats]:
165
+ w = self.client.walk
166
+ return parse.parse_port_stats(
167
+ in_octets=await w(oids.IF_HC_IN_OCTETS),
168
+ out_octets=await w(oids.IF_HC_OUT_OCTETS),
169
+ in_ucast=await w(oids.IF_HC_IN_UCAST),
170
+ out_ucast=await w(oids.IF_HC_OUT_UCAST),
171
+ in_errors=await w(oids.IF_IN_ERRORS),
172
+ out_errors=await w(oids.IF_OUT_ERRORS),
173
+ )
174
+
175
+ async def get_vlans(self) -> list[VLANInfo]:
176
+ w = self.client.walk
177
+ return parse.parse_vlans(
178
+ await w(oids.DOT1Q_VLAN_STATIC_NAME),
179
+ await w(oids.DOT1Q_VLAN_STATIC_EGRESS),
180
+ await w(oids.DOT1Q_VLAN_STATIC_UNTAGGED),
181
+ )
182
+
183
+ async def get_pvids(self) -> list[tuple[int, int]]:
184
+ return parse.parse_pvids(await self.client.walk(oids.DOT1Q_PVID))
185
+
186
+ async def get_lldp(self) -> list[LLDPNeighbor]:
187
+ return parse.parse_lldp(await self.client.walk(oids.LLDP_REM_TABLE))
188
+
189
+ async def get_macs(self) -> list[MacEntry]:
190
+ # No has_mac_table guard: _require_snmp in __init__ already enforced it.
191
+ w = self.client.walk
192
+ return parse.parse_macs(
193
+ await w(oids.DOT1Q_TP_FDB_PORT),
194
+ await w(oids.DOT1D_BASE_PORT_IF_INDEX),
195
+ )
196
+
197
+ async def get_poe(self) -> list[PoEStatus]:
198
+ vendor = oids.vendor_oids(self.model)
199
+ w = self.client.walk
200
+ return parse.parse_poe(
201
+ await w(oids.PETH_PSE_PORT_TABLE), await w(vendor.poe_power_mw)
202
+ )
203
+
204
+ async def get_sensors(self) -> list[Sensor]:
205
+ vendor = oids.vendor_oids(self.model)
206
+ w = self.client.walk
207
+ columns = [
208
+ ("fan", "RPM", await w(vendor.box_fan)),
209
+ ("power", "W", await w(vendor.box_psu_power)),
210
+ ("temperature", "C", await w(vendor.box_temp)),
211
+ ]
212
+ return parse.parse_box_sensors(columns)
213
+
214
+ async def get_mgmt_ip(self) -> MgmtIpConfig:
215
+ vendor = oids.vendor_oids(self.model)
216
+ w = self.client.walk
217
+ return parse.parse_mgmt_ip(
218
+ await w(oids.IP_ADENT_ADDR), await w(oids.IP_ADENT_NETMASK),
219
+ await w(oids.IP_ROUTE_DEST), await w(oids.IP_ROUTE_NEXTHOP),
220
+ await w(vendor.dhcp_mode_unverified), # single named UNVERIFIED OID
221
+ await w(oids.DOT1D_BASE_BRIDGE_ADDRESS), # standard BRIDGE-MIB scalar
222
+ )
223
+
224
+ async def get_system_info(self) -> DetectedModel:
225
+ """Async twin of ``SnmpReader.get_system_info`` -- see there."""
226
+ return await async_read_system_info(self.client)