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.
- netgear_switch/__init__.py +132 -0
- netgear_switch/_dispatch.py +178 -0
- netgear_switch/_version.py +24 -0
- netgear_switch/aio_api.py +529 -0
- netgear_switch/cli/__init__.py +1 -0
- netgear_switch/cli/capture.py +131 -0
- netgear_switch/cli/context.py +39 -0
- netgear_switch/cli/format.py +201 -0
- netgear_switch/cli/main.py +484 -0
- netgear_switch/cli/resolve.py +108 -0
- netgear_switch/cli/safety.py +71 -0
- netgear_switch/config.py +184 -0
- netgear_switch/errors.py +52 -0
- netgear_switch/http_read.py +174 -0
- netgear_switch/http_write.py +420 -0
- netgear_switch/models.py +156 -0
- netgear_switch/nsdp_read.py +221 -0
- netgear_switch/nsdp_write.py +315 -0
- netgear_switch/protocols/__init__.py +1 -0
- netgear_switch/protocols/http/__init__.py +1 -0
- netgear_switch/protocols/http/crypt.py +29 -0
- netgear_switch/protocols/http/endpoints.py +165 -0
- netgear_switch/protocols/http/forms.py +77 -0
- netgear_switch/protocols/http/parse.py +238 -0
- netgear_switch/protocols/http/session.py +29 -0
- netgear_switch/protocols/nsdp/__init__.py +7 -0
- netgear_switch/protocols/nsdp/auth.py +33 -0
- netgear_switch/protocols/nsdp/client.py +67 -0
- netgear_switch/protocols/nsdp/parsers.py +209 -0
- netgear_switch/protocols/nsdp/protocol.py +201 -0
- netgear_switch/protocols/nsdp/types.py +137 -0
- netgear_switch/protocols/nsdp/write.py +98 -0
- netgear_switch/protocols/snmp/__init__.py +1 -0
- netgear_switch/protocols/snmp/client.py +88 -0
- netgear_switch/protocols/snmp/oids.py +125 -0
- netgear_switch/protocols/snmp/parse.py +777 -0
- netgear_switch/protocols/snmp/write.py +112 -0
- netgear_switch/py.typed +0 -0
- netgear_switch/registry.py +227 -0
- netgear_switch/snmp_read.py +226 -0
- netgear_switch/snmp_write.py +625 -0
- netgear_switch/sync_api.py +557 -0
- netgear_switch/transport/__init__.py +1 -0
- netgear_switch/transport/aio/__init__.py +1 -0
- netgear_switch/transport/aio/nsdp_udp.py +152 -0
- netgear_switch/transport/aio/snmp_pysnmp.py +247 -0
- netgear_switch/transport/http/__init__.py +1 -0
- netgear_switch/transport/http/client.py +217 -0
- netgear_switch/transport/sync/__init__.py +1 -0
- netgear_switch/transport/sync/nsdp_udp.py +109 -0
- netgear_switch/transport/sync/snmp_netsnmp_cli.py +257 -0
- netgear_switch/virtual/__init__.py +8 -0
- netgear_switch/virtual/faces/__init__.py +2 -0
- netgear_switch/virtual/faces/http.py +164 -0
- netgear_switch/virtual/faces/mibview.py +92 -0
- netgear_switch/virtual/faces/nsdp.py +124 -0
- netgear_switch/virtual/faces/snmp.py +412 -0
- netgear_switch/virtual/seed.py +220 -0
- netgear_switch/virtual/server.py +106 -0
- netgear_switch/virtual/state.py +615 -0
- netgear_switch/virtual/web.py +210 -0
- python_netgear_switch_library-0.0.post154.dist-info/METADATA +85 -0
- python_netgear_switch_library-0.0.post154.dist-info/RECORD +66 -0
- python_netgear_switch_library-0.0.post154.dist-info/WHEEL +4 -0
- python_netgear_switch_library-0.0.post154.dist-info/entry_points.txt +2 -0
- 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
|