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,777 @@
1
+ # src/netgear_switch/protocols/snmp/parse.py
2
+ """Pure SNMP-row -> models.py parsers. No I/O."""
3
+ from __future__ import annotations
4
+
5
+ import string
6
+ from typing import TYPE_CHECKING
7
+
8
+ from ...models import (
9
+ IpMode,
10
+ LLDPNeighbor,
11
+ MacEntry,
12
+ MgmtIpConfig,
13
+ PoEDetect,
14
+ PoEStatus,
15
+ PortStats,
16
+ PortStatus,
17
+ Sensor,
18
+ VLANInfo,
19
+ )
20
+ from .client import SnmpError, SnmpRow
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Mapping, Sequence
24
+
25
+ from ...registry import SwitchModel
26
+
27
+
28
+ def _suffix(row: SnmpRow, base: str) -> str | None:
29
+ prefix = base + "."
30
+ if not row.oid.startswith(prefix):
31
+ return None
32
+ return row.oid[len(prefix):]
33
+
34
+
35
+ def index_int_column(rows: Sequence[SnmpRow], base_oid: str) -> dict[int, int]:
36
+ """Map a single-int-index column walk to {index: int_value}.
37
+
38
+ Raises SnmpError on a value that is not an integer under base_oid: the
39
+ walk is pinned to one column, so a non-integer means the table drifted.
40
+ """
41
+ out: dict[int, int] = {}
42
+ for row in rows:
43
+ suffix = _suffix(row, base_oid)
44
+ if suffix is None or "." in suffix:
45
+ continue
46
+ try:
47
+ idx = int(suffix)
48
+ except ValueError as exc:
49
+ raise SnmpError(
50
+ f"malformed index {suffix!r} at {row.oid}"
51
+ ) from exc
52
+ try:
53
+ value = int(row.value)
54
+ except ValueError as exc:
55
+ raise SnmpError(
56
+ f"non-integer value {row.value!r} at {row.oid}"
57
+ ) from exc
58
+ out[idx] = value
59
+ return out
60
+
61
+
62
+ def index_str_column(rows: Sequence[SnmpRow], base_oid: str) -> dict[int, str]:
63
+ """Map a single-index column walk to {index: str_value}.
64
+
65
+ An absent column (no rows under base_oid) yields an empty dict. But a row
66
+ that IS present under base_oid with a single, non-integer index component is
67
+ table drift, not absence, and raises SnmpError naming the offending OID —
68
+ consistent with index_int_column. (A multi-component suffix belongs to a
69
+ different, deeper column and is skipped.)
70
+
71
+ A text-name OCTET STRING (ifName/ifAlias/dot1qVlanStaticName) can
72
+ legitimately arrive as ``str`` from the CLI transport or ``bytes`` from
73
+ the pysnmp transport (its non-printable heuristic picks Hex-STRING for
74
+ values with any byte outside the printable-ASCII range, e.g. a name with
75
+ a trailing NUL). Both are valid text here: ``bytes`` is decoded to
76
+ ``str`` (utf-8, replacing undecodable bytes) so the two transports yield
77
+ the same model value. Any other type (e.g. an int) is a genuine wrong-type
78
+ reply and still raises.
79
+ """
80
+ out: dict[int, str] = {}
81
+ for row in rows:
82
+ suffix = _suffix(row, base_oid)
83
+ if suffix is None or "." in suffix:
84
+ continue
85
+ try:
86
+ idx = int(suffix)
87
+ except ValueError as exc:
88
+ raise SnmpError(
89
+ f"non-integer index {suffix!r} at {row.oid}"
90
+ ) from exc
91
+ value = row.value
92
+ if isinstance(value, bytes):
93
+ value = value.decode("utf-8", "replace")
94
+ elif not isinstance(value, str):
95
+ raise SnmpError(f"non-string value {value!r} at {row.oid}")
96
+ out[idx] = value
97
+ return out
98
+
99
+
100
+ def parse_port_status(
101
+ admin: Sequence[SnmpRow],
102
+ oper: Sequence[SnmpRow],
103
+ speed: Sequence[SnmpRow],
104
+ names: Sequence[SnmpRow],
105
+ aliases: Sequence[SnmpRow],
106
+ ) -> list[PortStatus]:
107
+ from . import oids
108
+
109
+ admin_map = index_int_column(admin, oids.IF_ADMIN_STATUS)
110
+ oper_map = index_int_column(oper, oids.IF_OPER_STATUS)
111
+ speed_map = index_int_column(speed, oids.IF_HIGH_SPEED)
112
+ name_map = index_str_column(names, oids.IF_NAME)
113
+ # ifAlias (operator-set description): distinct column from ifName above.
114
+ # An absent row for a port (or an empty-string alias) both mean "no
115
+ # description set" -> honest None, never a fabricated "".
116
+ alias_map = index_str_column(aliases, oids.IF_ALIAS)
117
+
118
+ ports = sorted(set(admin_map) | set(oper_map))
119
+ result: list[PortStatus] = []
120
+ for p in ports:
121
+ mbps = speed_map.get(p)
122
+ result.append(
123
+ PortStatus(
124
+ port=p,
125
+ name=name_map.get(p) or None,
126
+ admin_enabled=admin_map.get(p) == 1,
127
+ link_up=oper_map.get(p) == 1,
128
+ # ifHighSpeed 0 (link down) intentionally maps to None, not 0.
129
+ speed_mbps=mbps if mbps else None,
130
+ description=alias_map.get(p) or None,
131
+ )
132
+ )
133
+ return result
134
+
135
+
136
+ def parse_port_stats(
137
+ *,
138
+ in_octets: Sequence[SnmpRow],
139
+ out_octets: Sequence[SnmpRow],
140
+ in_ucast: Sequence[SnmpRow],
141
+ out_ucast: Sequence[SnmpRow],
142
+ in_errors: Sequence[SnmpRow],
143
+ out_errors: Sequence[SnmpRow],
144
+ ) -> list[PortStats]:
145
+ from . import oids
146
+
147
+ rx_b = index_int_column(in_octets, oids.IF_HC_IN_OCTETS)
148
+ tx_b = index_int_column(out_octets, oids.IF_HC_OUT_OCTETS)
149
+ rx_p = index_int_column(in_ucast, oids.IF_HC_IN_UCAST)
150
+ tx_p = index_int_column(out_ucast, oids.IF_HC_OUT_UCAST)
151
+ rx_e = index_int_column(in_errors, oids.IF_IN_ERRORS)
152
+ tx_e = index_int_column(out_errors, oids.IF_OUT_ERRORS)
153
+
154
+ ports = sorted(set(rx_b) | set(tx_b) | set(rx_p) | set(tx_p)
155
+ | set(rx_e) | set(tx_e))
156
+ return [
157
+ PortStats(
158
+ port=p,
159
+ rx_bytes=rx_b.get(p),
160
+ tx_bytes=tx_b.get(p),
161
+ rx_packets=rx_p.get(p),
162
+ tx_packets=tx_p.get(p),
163
+ rx_errors=rx_e.get(p),
164
+ tx_errors=tx_e.get(p),
165
+ )
166
+ for p in ports
167
+ ]
168
+
169
+
170
+ def decode_port_bitmap(bitmap: bytes | str) -> frozenset[int]:
171
+ """Decode an SNMP VLAN port bitmap. Bit 7 of byte 0 = port 1.
172
+
173
+ An empty value is a legitimately absent bitmap -> no ports. Both transports
174
+ normalize the non-printable OCTET STRING onto the wire to ``bytes``, so
175
+ that is the expected form and is used directly (MSB-first). If a bitmap
176
+ ever arrives as a printable ``str`` it is latin-1 encoded first; a str that
177
+ cannot round-trip through latin-1 is malformed and raises SnmpError naming
178
+ the value.
179
+ """
180
+ if not bitmap:
181
+ return frozenset()
182
+ if isinstance(bitmap, bytes):
183
+ data = bitmap
184
+ else:
185
+ try:
186
+ data = bitmap.encode("latin-1")
187
+ except UnicodeEncodeError as exc:
188
+ raise SnmpError(f"malformed VLAN port bitmap {bitmap!r}") from exc
189
+ ports: set[int] = set()
190
+ for byte_idx, byte_val in enumerate(data):
191
+ for bit in range(8):
192
+ if byte_val & (0x80 >> bit):
193
+ ports.add(byte_idx * 8 + bit + 1)
194
+ return frozenset(ports)
195
+
196
+
197
+ def _vlan_bitmap_map(rows: Sequence[SnmpRow], base_oid: str) -> dict[int, bytes | str]:
198
+ """{vlan_id: bitmap_value} for a VLAN bitmap column.
199
+
200
+ A row absent from the column is skipped; a row present under base_oid
201
+ whose VLAN index is non-numeric, or whose value is neither bytes nor str
202
+ (wrong SNMP type on the wire), is drift and raises SnmpError naming the
203
+ offending OID rather than silently dropping present-but-malformed data.
204
+ """
205
+ out: dict[int, bytes | str] = {}
206
+ for row in rows:
207
+ s = _suffix(row, base_oid)
208
+ if s is None:
209
+ continue
210
+ if not s.isdigit():
211
+ raise SnmpError(f"malformed VLAN index {s!r} at {row.oid}")
212
+ if not isinstance(row.value, (bytes, str)):
213
+ raise SnmpError(f"malformed VLAN port bitmap type at {row.oid}")
214
+ out[int(s)] = row.value
215
+ return out
216
+
217
+
218
+ def parse_vlans(
219
+ names: Sequence[SnmpRow],
220
+ egress: Sequence[SnmpRow],
221
+ untagged: Sequence[SnmpRow],
222
+ ) -> list[VLANInfo]:
223
+ from . import oids
224
+
225
+ name_map = index_str_column(names, oids.DOT1Q_VLAN_STATIC_NAME)
226
+ egress_map = _vlan_bitmap_map(egress, oids.DOT1Q_VLAN_STATIC_EGRESS)
227
+ untag_map = _vlan_bitmap_map(untagged, oids.DOT1Q_VLAN_STATIC_UNTAGGED)
228
+ result: list[VLANInfo] = []
229
+ for vid in sorted(name_map):
230
+ member = decode_port_bitmap(egress_map.get(vid, ""))
231
+ untag = decode_port_bitmap(untag_map.get(vid, ""))
232
+ result.append(
233
+ VLANInfo(
234
+ vlan_id=vid,
235
+ name=name_map.get(vid) or None,
236
+ member_ports=member,
237
+ tagged_ports=member - untag,
238
+ untagged_ports=untag,
239
+ )
240
+ )
241
+ return result
242
+
243
+
244
+ def parse_pvids(rows: Sequence[SnmpRow]) -> list[tuple[int, int]]:
245
+ from . import oids
246
+
247
+ pvids = index_int_column(rows, oids.DOT1Q_PVID)
248
+ return sorted(pvids.items())
249
+
250
+
251
+ def _format_mac_bytes(byte_strs: Sequence[str]) -> str:
252
+ return ":".join(f"{int(b):02X}" for b in byte_strs)
253
+
254
+
255
+ def _format_mac_octetstring(value: int | str | bytes) -> str | None:
256
+ """Format a raw 6-byte MAC-shaped OCTET STRING as ``XX:XX:XX:XX:XX:XX``.
257
+
258
+ The value arrives as ``bytes`` from a Hex-STRING varbind, or (for a
259
+ transport that normalizes octet strings to latin-1 text) a 6-character
260
+ ``str``. Returns ``None`` when ``value`` isn't a 6-byte/6-char octet
261
+ string -- the caller decides whether that's absence or malformed drift.
262
+ """
263
+ if isinstance(value, bytes) and len(value) == 6:
264
+ return ":".join(f"{b:02X}" for b in value)
265
+ if isinstance(value, str) and len(value) == 6:
266
+ return ":".join(f"{ord(c):02X}" for c in value)
267
+ return None
268
+
269
+
270
+ def _format_chassis_id(value: int | str | bytes) -> str:
271
+ """Format an lldpRemChassisId value.
272
+
273
+ The MAC-address chassis subtype formats as ``XX:XX:XX:XX:XX:XX`` (see
274
+ ``_format_mac_octetstring``). Any other chassis-id subtype (e.g. a chassis
275
+ component name) is returned as plain text.
276
+ """
277
+ mac = _format_mac_octetstring(value)
278
+ if mac is not None:
279
+ return mac
280
+ return value if isinstance(value, str) else str(value)
281
+
282
+
283
+ def parse_base_mac(rows: Sequence[SnmpRow]) -> str | None:
284
+ """Parse dot1dBaseBridgeAddress (BRIDGE-MIB scalar, standard MIB-II) into
285
+ a colon-separated MAC string.
286
+
287
+ An absent scalar (no row under the OID at all) is honestly ``None`` --
288
+ not every device necessarily answers this instance. A row that IS present
289
+ but isn't a 6-byte/6-char OCTET STRING is drift, not absence, and raises
290
+ SnmpError naming the offending OID, consistent with the other column
291
+ parsers in this module.
292
+ """
293
+ from . import oids
294
+
295
+ prefix = oids.DOT1D_BASE_BRIDGE_ADDRESS + "."
296
+ for row in rows:
297
+ if not row.oid.startswith(prefix):
298
+ continue
299
+ mac = _format_mac_octetstring(row.value)
300
+ if mac is None:
301
+ raise SnmpError(f"malformed base MAC {row.value!r} at {row.oid}")
302
+ return mac
303
+ return None
304
+
305
+
306
+ def _column_text(value: int | str | bytes) -> str:
307
+ """Render a non-chassis LLDP column (portDesc/sysName) as text."""
308
+ if isinstance(value, bytes):
309
+ return value.decode("utf-8", errors="replace")
310
+ return value if isinstance(value, str) else str(value)
311
+
312
+
313
+ def _format_port_id(value: int | str | bytes) -> str:
314
+ """Format an lldpRemPortId value.
315
+
316
+ Consistent with ``_format_chassis_id``: a MAC-address port-id subtype
317
+ (lldpPortIdSubtype 3) is raw binary and formats as
318
+ ``XX:XX:XX:XX:XX:XX`` via ``_format_mac_octetstring``, instead of being
319
+ UTF-8-decoded (with ``errors="replace"``) into garbled U+FFFD text --
320
+ that mismatch, versus chassis-id's correct MAC handling, was the bug.
321
+
322
+ A genuinely binary portId always arrives as ``bytes`` (the transport's
323
+ own printable-ASCII heuristic -- see ``index_str_column``'s docstring --
324
+ only emits ``str`` for values that decode cleanly as text), so the
325
+ ``bytes``-and-6-long check below is the reliable MAC signal. A ``str`` is
326
+ additionally treated as raw MAC bytes only when it is NOT printable text
327
+ (mirroring the latin-1-normalizing-transport case exercised for
328
+ ``parse_base_mac``): this guards against a real, everyday ASCII
329
+ interface-name portId that happens to be exactly 6 characters (e.g.
330
+ ``"1/xg51"``) being mistaken for a MAC and corrupted into hex -- unlike
331
+ chassis-id, port-id routinely carries short human-readable interface
332
+ names, so a bare length-6 check on ``str`` is unsafe here. Any other
333
+ value (including a printable 6-char ``str`` or any non-MAC-shaped
334
+ value) is plain text, per ``_column_text``.
335
+ """
336
+ if isinstance(value, bytes) and len(value) == 6:
337
+ mac = _format_mac_octetstring(value)
338
+ if mac is not None:
339
+ return mac
340
+ if isinstance(value, str) and len(value) == 6 and not value.isprintable():
341
+ mac = _format_mac_octetstring(value)
342
+ if mac is not None:
343
+ return mac
344
+ return _column_text(value)
345
+
346
+
347
+ def parse_lldp(rows: Sequence[SnmpRow]) -> list[LLDPNeighbor]:
348
+ """Group lldpRemTable rows by local port into LLDPNeighbor entries.
349
+
350
+ The instance suffix is ``<column>.<timeMark>.<localPortNum>.<remIndex>``;
351
+ the middle component is the local port. A row present under the table
352
+ prefix but with fewer than 4 suffix components, or a non-integer column
353
+ or local-port component, is drift (not absence) and raises SnmpError
354
+ naming the offending OID. A fully-empty neighbour group (every tracked
355
+ column absent) carries no data and is skipped.
356
+ """
357
+ from . import oids
358
+
359
+ prefix = oids.LLDP_REM_TABLE + ".1."
360
+ grouped: dict[tuple[str, str, str], dict[int, int | str | bytes]] = {}
361
+ for row in rows:
362
+ if not row.oid.startswith(prefix):
363
+ continue
364
+ parts = row.oid[len(prefix):].split(".")
365
+ if len(parts) != 4:
366
+ raise SnmpError(f"malformed LLDP index at {row.oid}")
367
+ try:
368
+ column = int(parts[0])
369
+ except ValueError as exc:
370
+ raise SnmpError(
371
+ f"non-integer LLDP column {parts[0]!r} at {row.oid}"
372
+ ) from exc
373
+ key = (parts[1], parts[2], parts[3]) # timeMark, localPort, remIdx
374
+ grouped.setdefault(key, {})[column] = row.value
375
+
376
+ result: list[LLDPNeighbor] = []
377
+ for (_tm, local_port, _rem), cols in grouped.items():
378
+ chassis = cols.get(5, "")
379
+ port_id = cols.get(7, "")
380
+ port_desc = cols.get(8, "")
381
+ sys_name = cols.get(9, "")
382
+ # A neighbour row group with every column empty carries no data
383
+ # (absent); skip it. A present-but-non-integer local-port index is
384
+ # drift -> raise.
385
+ if not (chassis or port_id or port_desc or sys_name):
386
+ continue
387
+ try:
388
+ lp = int(local_port)
389
+ except ValueError as exc:
390
+ raise SnmpError(
391
+ f"non-integer LLDP local port {local_port!r} at {prefix}...{local_port}"
392
+ ) from exc
393
+ result.append(
394
+ LLDPNeighbor(
395
+ local_port=lp,
396
+ remote_sys_name=_column_text(sys_name) or None,
397
+ remote_port_desc=_column_text(port_desc) or None,
398
+ remote_chassis_id=_format_chassis_id(chassis) or None,
399
+ remote_port_id=_format_port_id(port_id) or None,
400
+ )
401
+ )
402
+ return sorted(result, key=lambda n: n.local_port)
403
+
404
+
405
+ def parse_macs(
406
+ fdb: Sequence[SnmpRow], bridge_ports: Sequence[SnmpRow]
407
+ ) -> list[MacEntry]:
408
+ """Build the MAC/FDB table from dot1qTpFdbPort + dot1dBasePortIfIndex.
409
+
410
+ ``dot1qTpFdbPort`` gives the bridge PORT number keyed by
411
+ ``<vlan>.<mac-as-6-oid-octets>``; ``dot1dBasePortIfIndex`` maps that
412
+ bridge port to an ifIndex (falling back to the bridge port number itself
413
+ when unmapped). A bridge-port value that is present but not an integer is
414
+ table drift and raises SnmpError naming the offending OID.
415
+ """
416
+ from . import oids
417
+
418
+ bridge_to_if = index_int_column(bridge_ports, oids.DOT1D_BASE_PORT_IF_INDEX)
419
+ prefix = oids.DOT1Q_TP_FDB_PORT + "."
420
+ result: list[MacEntry] = []
421
+ for row in fdb:
422
+ if not row.oid.startswith(prefix):
423
+ continue
424
+ parts = row.oid[len(prefix):].split(".")
425
+ if len(parts) != 7: # <vlan>.<6 MAC bytes>
426
+ raise SnmpError(f"malformed FDB index at {row.oid}")
427
+ try:
428
+ vlan_id = int(parts[0])
429
+ except ValueError as exc:
430
+ raise SnmpError(
431
+ f"non-integer VLAN index {parts[0]!r} at {row.oid}"
432
+ ) from exc
433
+ try:
434
+ bridge_port = int(row.value)
435
+ except ValueError as exc:
436
+ raise SnmpError(
437
+ f"non-integer bridge port {row.value!r} at {row.oid}"
438
+ ) from exc
439
+ port = bridge_to_if.get(bridge_port, bridge_port)
440
+ result.append(
441
+ MacEntry(mac=_format_mac_bytes(parts[1:7]), port=port, vlan_id=vlan_id)
442
+ )
443
+ return sorted(result, key=lambda m: (m.port, m.mac))
444
+
445
+
446
+ DETECT_MAP: dict[int, PoEDetect] = {
447
+ 1: PoEDetect.DISABLED,
448
+ 2: PoEDetect.SEARCHING,
449
+ 3: PoEDetect.DELIVERING,
450
+ 4: PoEDetect.FAULT,
451
+ }
452
+
453
+
454
+ def parse_poe(
455
+ status: Sequence[SnmpRow], power_mw: Sequence[SnmpRow]
456
+ ) -> list[PoEStatus]:
457
+ """Build PoE port status from RFC3621 pethPsePortTable + vendor mW.
458
+
459
+ ``status`` is a walk of pethPsePortTable; only columns 3 (admin) and 6
460
+ (detect) are honoured (the hard-won fix: never column 1). Rows are
461
+ grouped by ``(group, port)`` from the ``<col>.<group>.<port>`` instance
462
+ suffix. A port present in the walk but missing either tracked column is
463
+ drift (not absence) and raises SnmpError naming the offending port.
464
+ ``power_mw`` is the vendor per-port power walk, matched to a port by the
465
+ final OID suffix component; a port without a vendor mW row gets
466
+ ``power_mw=None``.
467
+ """
468
+ from . import oids
469
+
470
+ prefix = oids.PETH_PSE_PORT_TABLE + "."
471
+ cols: dict[tuple[int, int], dict[int, int]] = {}
472
+ for row in status:
473
+ if not row.oid.startswith(prefix):
474
+ continue
475
+ parts = row.oid[len(prefix):].split(".")
476
+ if len(parts) != 3:
477
+ continue
478
+ column = int(parts[0])
479
+ if column not in (3, 6):
480
+ continue
481
+ try:
482
+ key = (int(parts[1]), int(parts[2]))
483
+ cols.setdefault(key, {})[column] = int(row.value)
484
+ except ValueError as exc:
485
+ raise SnmpError(
486
+ f"non-integer PoE value {row.value!r} at {row.oid}"
487
+ ) from exc
488
+
489
+ # vendor mW keyed by port index (2nd suffix component)
490
+ mw: dict[int, int] = {}
491
+ for row in power_mw:
492
+ parts = row.oid.split(".")
493
+ try:
494
+ mw[int(parts[-1])] = int(row.value)
495
+ except ValueError:
496
+ continue
497
+
498
+ result: list[PoEStatus] = []
499
+ for (_group, port), c in sorted(cols.items()):
500
+ if 3 not in c:
501
+ raise SnmpError(f"PoE port {port} missing admin (col 3)")
502
+ if 6 not in c:
503
+ raise SnmpError(f"PoE port {port} missing detect (col 6)")
504
+ result.append(
505
+ PoEStatus(
506
+ port=port,
507
+ admin_enabled=c[3] == 1,
508
+ detect=DETECT_MAP.get(c[6], PoEDetect.UNKNOWN),
509
+ power_mw=mw.get(port),
510
+ )
511
+ )
512
+ return result
513
+
514
+
515
+ def parse_box_sensors(
516
+ rows_by_kind: Sequence[tuple[str, str, Sequence[SnmpRow]]],
517
+ ) -> list[Sensor]:
518
+ """Build box sensors from walk-discovered Netgear vendor columns.
519
+
520
+ Each tuple is ``(kind, unit, rows)`` for one vendor column walk (e.g.
521
+ fan RPM, PSU power, temperature). Sensor indices are walk-discovered
522
+ (they differ per model), not hardcoded. The literal string
523
+ ``"Not Supported"`` is Netgear's placeholder for an unpopulated slot and
524
+ is skipped, not an error; any other non-integer value is present-but-
525
+ malformed and raises SnmpError naming the offending OID.
526
+ """
527
+ result: list[Sensor] = []
528
+ for kind, unit, rows in rows_by_kind:
529
+ for row in rows:
530
+ parts = row.oid.split(".")
531
+ instance = parts[-1]
532
+ if row.value == "Not Supported":
533
+ continue
534
+ try:
535
+ value = int(row.value)
536
+ except ValueError as exc:
537
+ raise SnmpError(
538
+ f"non-integer {kind} reading {row.value!r} at {row.oid}"
539
+ ) from exc
540
+ result.append(
541
+ Sensor(name=f"{kind}{instance}", kind=kind,
542
+ value=float(value), unit=unit)
543
+ )
544
+ return result
545
+
546
+
547
+ def _ip_str(row: SnmpRow) -> str:
548
+ """Return an IP-valued row's value as ``str``.
549
+
550
+ Both transports normalize IpAddress varbinds to ``str`` (see SnmpRow's
551
+ docstring); a row present under an address/netmask/gateway column whose
552
+ value is NOT a str is table drift / a malformed reply, not absence, and
553
+ raises SnmpError naming the offending OID.
554
+ """
555
+ if not isinstance(row.value, str):
556
+ raise SnmpError(f"non-IP value {row.value!r} at {row.oid}")
557
+ return row.value
558
+
559
+
560
+ def parse_mgmt_ip(
561
+ addr: Sequence[SnmpRow],
562
+ netmask: Sequence[SnmpRow],
563
+ route_dest: Sequence[SnmpRow],
564
+ route_nexthop: Sequence[SnmpRow],
565
+ dhcp_mode: Sequence[SnmpRow],
566
+ base_mac: Sequence[SnmpRow],
567
+ ) -> MgmtIpConfig:
568
+ """Build the management-IP config from ipAddrTable/ipRouteTable + vendor mode.
569
+
570
+ Address/netmask/gateway come from the standard MIBs (ipAddrTable,
571
+ ipRouteTable) and are trustworthy. The DHCP-vs-static mode is UNVERIFIED
572
+ (see oids.VendorOids.dhcp_mode_unverified): it is read best-effort and
573
+ ``IpMode.UNKNOWN`` is returned whenever the mode OID is absent/unset —
574
+ never a guessed dhcp/static. The mode OID is INTEGER-typed, so both
575
+ transports normalize its value to a Python ``int`` (see ``SnmpRow``'s
576
+ docstring); only a recognized present value (``1``/``2``) maps to
577
+ DHCP/STATIC, any other present value -- including one that cannot be
578
+ coerced to ``int`` at all -- also yields UNKNOWN rather than raising,
579
+ since this OID is explicitly best-effort. ``base_mac`` is the standard
580
+ (non-UNVERIFIED) dot1dBaseBridgeAddress scalar walk -- see
581
+ ``parse_base_mac``; an empty walk (OID absent) yields ``base_mac=None``.
582
+ """
583
+ from . import oids
584
+
585
+ ip: str | None = None
586
+ ip_index: str | None = None
587
+ aprefix = oids.IP_ADENT_ADDR + "."
588
+ for row in addr:
589
+ if not row.oid.startswith(aprefix):
590
+ continue
591
+ if row.value == "127.0.0.1":
592
+ continue
593
+ ip = _ip_str(row)
594
+ ip_index = row.oid[len(aprefix):]
595
+ break
596
+
597
+ mask: str | None = None
598
+ if ip_index is not None:
599
+ want = oids.IP_ADENT_NETMASK + "." + ip_index
600
+ for r in netmask:
601
+ if r.oid == want:
602
+ mask = _ip_str(r)
603
+ break
604
+
605
+ dest_rows = {
606
+ r.oid[len(oids.IP_ROUTE_DEST) + 1:]: r.value for r in route_dest
607
+ }
608
+ gateway: str | None = None
609
+ nprefix = oids.IP_ROUTE_NEXTHOP + "."
610
+ for row in route_nexthop:
611
+ if not row.oid.startswith(nprefix):
612
+ continue
613
+ idx = row.oid[len(nprefix):]
614
+ if dest_rows.get(idx) == "0.0.0.0":
615
+ gateway = _ip_str(row)
616
+ break
617
+
618
+ mode = IpMode.UNKNOWN
619
+ for row in dhcp_mode:
620
+ try:
621
+ raw_mode = int(row.value)
622
+ except (TypeError, ValueError):
623
+ break
624
+ if raw_mode == 1:
625
+ mode = IpMode.DHCP
626
+ elif raw_mode == 2:
627
+ mode = IpMode.STATIC
628
+ break
629
+
630
+ return MgmtIpConfig(
631
+ mode=mode, address=ip, netmask=mask, gateway=gateway,
632
+ base_mac=parse_base_mac(base_mac),
633
+ )
634
+
635
+
636
+ def _scalar_text(rows: Sequence[SnmpRow], oid: str) -> str | None:
637
+ """Extract one scalar exact-OID GET result's value as text, or None.
638
+
639
+ Unlike the walk-based column parsers above (matched by base-OID
640
+ *prefix*), ``sysDescr``/``sysObjectID`` are fetched with a plain exact-OID
641
+ GET (see ``snmp_read.read_system_info``), so ``rows`` is the combined
642
+ result of one ``client.get([...])`` call and this matches by exact OID
643
+ equality. An absent scalar (no row with this exact OID at all) is
644
+ honestly ``None`` -- not every device necessarily answers, and a caller
645
+ must never fabricate a value. A row that IS present but isn't decodable
646
+ to text is drift, not absence, and raises SnmpError naming the offending
647
+ OID, consistent with every other parser in this module.
648
+ """
649
+ for row in rows:
650
+ if row.oid != oid:
651
+ continue
652
+ value = row.value
653
+ if isinstance(value, bytes):
654
+ return value.decode("utf-8", "replace")
655
+ if isinstance(value, str):
656
+ return value
657
+ raise SnmpError(f"non-string value {value!r} at {row.oid}")
658
+ return None
659
+
660
+
661
+ def parse_system_info(rows: Sequence[SnmpRow]) -> tuple[str | None, str | None]:
662
+ """Extract the raw sysDescr/sysObjectID scalar text from one combined GET.
663
+
664
+ Pure row -> ``(sys_descr, sys_object_id)`` extraction ONLY -- no model
665
+ matching happens here. Kept strictly separate from
666
+ ``detect_model_from_sysdescr`` so the matching heuristic is unit-testable
667
+ against plain strings, with no SnmpRow/client machinery involved at all.
668
+ """
669
+ from . import oids
670
+
671
+ sys_descr = _scalar_text(rows, oids.SYS_DESCR)
672
+ sys_object_id = _scalar_text(rows, oids.SYS_OBJECT_ID)
673
+ return sys_descr, sys_object_id
674
+
675
+
676
+ def _model_match_tokens(model: SwitchModel) -> tuple[str, ...]:
677
+ """Name tokens to search for (uppercased) in a sysDescr string.
678
+
679
+ Built ONLY from the registry's own ``key``/``display_name`` -- there is
680
+ NO hand-invented per-model sysDescr/sysObjectID table anywhere (no MIBs,
681
+ no captures, no prior-art map exist for one; see
682
+ ``detect_model_from_sysdescr``'s docstring). ``display_name`` sometimes
683
+ carries a parenthesized alias (e.g. ``"GSM7228PS (S3300)"`` or
684
+ ``"M4300-24X (XSM4324CS)"``); both the main name and the alias are valid
685
+ tokens, since a real switch's sysDescr text could plausibly use either.
686
+ """
687
+ tokens = [model.key.upper()]
688
+ name = model.display_name
689
+ if "(" in name and name.endswith(")"):
690
+ main, _, alias = name.partition("(")
691
+ tokens.append(main.strip())
692
+ tokens.append(alias[:-1].strip())
693
+ else:
694
+ tokens.append(name.strip())
695
+ return tuple(t for t in tokens if t)
696
+
697
+
698
+ # Punctuation stripped from the edges of a whitespace-delimited sysDescr
699
+ # word before comparing it to a registered token. Hyphens are deliberately
700
+ # EXCLUDED: they are meaningful inside a model identifier itself (e.g.
701
+ # "M4300-24X"), so stripping them would merge distinct SKUs together.
702
+ _WORD_STRIP_CHARS = string.punctuation.replace("-", "")
703
+
704
+
705
+ def _candidate_tokens(sys_descr: str) -> frozenset[str]:
706
+ """Whitespace-delimited "words" of a sysDescr string, as whole-token
707
+ candidates for exact (uppercased) comparison against a registered
708
+ model's match tokens.
709
+
710
+ Only edge punctuation is stripped (e.g. the trailing comma in
711
+ ``"M4300-24X,"``) -- internal structure, in particular hyphens, is left
712
+ intact. This is what makes the comparison a WHOLE-IDENTIFIER match: a
713
+ registered token must equal an entire sysDescr word, not merely appear
714
+ as a prefix/substring of it. That is the crux of the fix for the
715
+ false-positive bug this function exists to prevent (see
716
+ ``detect_model_from_sysdescr``'s docstring) -- e.g. the single sysDescr
717
+ word ``"GS305EPP"`` never equals the registered token ``"GS305EP"``, and
718
+ the single word ``"S3300-28X"`` never equals the registered alias
719
+ token ``"S3300"``, no matter what non-alphanumeric character (or none)
720
+ immediately follows the registered token's text.
721
+ """
722
+ return frozenset(
723
+ word.strip(_WORD_STRIP_CHARS).upper() for word in sys_descr.split()
724
+ )
725
+
726
+
727
+ def detect_model_from_sysdescr(
728
+ sys_descr: str | None, models: Mapping[str, SwitchModel]
729
+ ) -> str | None:
730
+ """Match a switch's sysDescr text against registered models' names.
731
+
732
+ HONESTY CONSTRAINT: there is no ground-truth sysObjectID -> model table
733
+ (see ``oids.SYS_OBJECT_ID`` -- it is read as a raw signal but never used
734
+ here). Matching is EXACT (case-insensitive) whole-word matching: the
735
+ sysDescr string is split into whitespace-delimited candidate tokens
736
+ (``_candidate_tokens``) and a registered model matches only when one of
737
+ its own key/display_name/alias tokens (``_model_match_tokens``) equals
738
+ one of those candidates in full -- NEVER a bare substring/prefix check,
739
+ and NEVER a guess:
740
+
741
+ * A sysDescr containing an unregistered Netgear model name (e.g.
742
+ ``"GS752TP"``, not in ``models``) matches no token and correctly
743
+ returns ``None`` -- it is NEVER coerced onto some other, wrong,
744
+ registered model just because it looks Netgear-ish.
745
+ * A non-Netgear/garbage string matches nothing and also returns ``None``.
746
+ * CRITICAL (regression that motivated the switch away from substring
747
+ matching): a real, unregistered Netgear model whose name EXTENDS a
748
+ registered token must also return ``None``, never the shorter
749
+ registered model. Bare substring matching used to fail this both when
750
+ the extension has no separator (``"GS305EPP"`` used to wrongly match
751
+ the registered ``"GS305EP"``, a distinct 123W model vs. the registered
752
+ 63W one) AND when it has one (``"S3300-28X"`` / ``"S3300-28X-PoE+"``
753
+ used to wrongly match the registered alias ``"S3300"`` for
754
+ ``gsm7228ps``, a distinct S3300 SKU). Whole-word equality rejects both:
755
+ neither ``"GS305EPP"`` nor ``"S3300-28X"`` is ever *equal* to the
756
+ shorter registered token, regardless of what character (alphanumeric
757
+ or not) follows it in the original text.
758
+ * A sysDescr matching MORE THAN ONE registered model's tokens (meaning
759
+ two registered models' names collide and can't be disambiguated by
760
+ this heuristic) ALSO returns ``None`` rather than guessing between
761
+ them. This never happens for the current registry (verified: no
762
+ model's match tokens equal another's -- e.g. "M4300-24X" vs
763
+ "M4300-16X", "GSM7252PS" vs "GSM7228PS"/"S3300" are all mutually
764
+ exclusive), but the fallback is kept as a permanent safety net against
765
+ a future registry addition introducing a collision.
766
+ """
767
+ if not sys_descr:
768
+ return None
769
+ candidates = _candidate_tokens(sys_descr)
770
+ matches = {
771
+ model.key
772
+ for model in models.values()
773
+ if any(token.upper() in candidates for token in _model_match_tokens(model))
774
+ }
775
+ if len(matches) == 1:
776
+ return next(iter(matches))
777
+ return None