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,420 @@
1
+ """Model-driven web-UI write operations with verify-after-write + guards.
2
+
3
+ Parallel to ``snmp_write.py``. Every mutating op: (1) enforces
4
+ ``protected_ports`` on disruptive ports unless ``force=True``; (2) GETs the
5
+ target page to scrape the fresh CSRF ``hash``; (3) POSTs the encoded form;
6
+ (4) re-GETs and re-parses to confirm the change actually took — raising
7
+ ``WriteVerificationError(before, after)`` on divergence, NEVER silently
8
+ succeeding. Web-UI-impossible/UNVERIFIED writes (port enable, mgmt-IP) raise
9
+ ``UnsupportedCapabilityError`` honestly.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from typing import TYPE_CHECKING
16
+
17
+ from .errors import (
18
+ HttpUnexpectedPageError,
19
+ ProtectedPortError,
20
+ UnsupportedCapabilityError,
21
+ WriteVerificationError,
22
+ )
23
+ from .protocols.http import forms, parse
24
+ from .protocols.http.endpoints import http_spec
25
+
26
+ if TYPE_CHECKING:
27
+ from .models import VlanMode
28
+ from .protocols.http.session import AsyncHttpSession, HttpSession
29
+ from .registry import SwitchModel
30
+ from .snmp_write import PoeCycleTimeouts
31
+
32
+
33
+ def _csrf(html: str) -> str:
34
+ token = parse.parse_csrf_hash(html)
35
+ if token is None:
36
+ raise HttpUnexpectedPageError("no CSRF 'hash' token on page before write")
37
+ return token
38
+
39
+
40
+ def _require_path(model_key: str, path: str | None, op: str) -> str:
41
+ """Return ``path`` or raise honestly if this model's spec has none for ``op``."""
42
+ if path is None:
43
+ raise UnsupportedCapabilityError(
44
+ f"model {model_key!r} web UI does not expose {op}"
45
+ )
46
+ return path
47
+
48
+
49
+ def _vlan_checkbox_index(html: str, vlan: int) -> int | None:
50
+ for m in re.finditer(r'name="vlanck(\d+)"[^>]*value="(\d+)"', html):
51
+ if int(m.group(2)) == vlan:
52
+ return int(m.group(1))
53
+ return None
54
+
55
+
56
+ class HttpWriter:
57
+ def __init__(
58
+ self,
59
+ session: HttpSession,
60
+ model: SwitchModel,
61
+ *,
62
+ protected_ports: frozenset[int] = frozenset(),
63
+ ) -> None:
64
+ self._spec = http_spec(model)
65
+ self.session = session
66
+ self.model = model
67
+ self.protected_ports = protected_ports
68
+
69
+ def _guard(self, port: int, force: bool) -> None:
70
+ if port in self.protected_ports and not force:
71
+ raise ProtectedPortError(
72
+ f"port {port} is protected on {self.model.key!r}; pass force=True"
73
+ )
74
+
75
+ def set_poe(self, port: int, on: bool, *, force: bool = False) -> None:
76
+ path = _require_path(
77
+ self.model.key, self._spec.poe_config_path, "web PoE config"
78
+ )
79
+ self._guard(port, force)
80
+ before = self._poe_admin(port)
81
+ page = self.session.get_page(path)
82
+ form = forms.poe_apply_form(
83
+ port=port, on=on, is_epx=self._spec.is_epx_poe, csrf_hash=_csrf(page)
84
+ )
85
+ self.session.post_form(path, form)
86
+ after = self._poe_admin(port)
87
+ if after != on:
88
+ raise WriteVerificationError(
89
+ f"PoE port {port} did not read back as on={on}",
90
+ before=before,
91
+ after=after,
92
+ )
93
+
94
+ def cycle_poe(
95
+ self,
96
+ port: int,
97
+ *,
98
+ force: bool = False,
99
+ timeouts: PoeCycleTimeouts | None = None,
100
+ ) -> None:
101
+ # timeouts accepted-but-unused: matches SnmpWriter/NsdpWriter so the
102
+ # facade's SnmpWriter | NsdpWriter | HttpWriter union call site typechecks.
103
+ del timeouts
104
+ path = _require_path(
105
+ self.model.key, self._spec.poe_config_path, "web PoE config"
106
+ )
107
+ self._guard(port, force)
108
+ page = self.session.get_page(path)
109
+ self.session.post_form(
110
+ path, forms.poe_reset_form(port=port, csrf_hash=_csrf(page))
111
+ )
112
+
113
+ def clear_poe_fault(
114
+ self,
115
+ port: int,
116
+ *,
117
+ force: bool = False,
118
+ timeouts: PoeCycleTimeouts | None = None,
119
+ ) -> None:
120
+ # Present so the facade's writer union has a uniform surface; the Plus
121
+ # web UI has no grounded clear-PoE-fault endpoint (a fault clears on the
122
+ # next PoEPortConfig apply/reset), so this is honestly unsupported.
123
+ del port, force, timeouts
124
+ raise UnsupportedCapabilityError(
125
+ f"{self.model.key!r} web clear-PoE-fault is UNVERIFIED-pending-capture"
126
+ )
127
+
128
+ def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None:
129
+ self._guard(port, force)
130
+ path = _require_path(self.model.key, self._spec.pvid_path, "port PVIDs")
131
+ page = self.session.get_page(path)
132
+ self.session.post_form(
133
+ path, forms.pvid_form(port=port, vlan=vlan, csrf_hash=_csrf(page))
134
+ )
135
+ after = dict(parse.parse_pvids(self.session.get_page(path)))
136
+ if after.get(port) != vlan:
137
+ raise WriteVerificationError(
138
+ f"PVID for port {port} did not read back as {vlan}",
139
+ before=None,
140
+ after=after.get(port),
141
+ )
142
+
143
+ def set_vlan_membership(
144
+ self, vlan: int, port: int, mode: VlanMode, *, force: bool = False
145
+ ) -> None:
146
+ self._guard(port, force)
147
+ path = _require_path(
148
+ self.model.key, self._spec.vlan_membership_path, "VLAN membership"
149
+ )
150
+ html = self.session.post_form(path, {"VLAN_ID": str(vlan)})
151
+ states = parse.parse_membership(html, self.model.port_count)
152
+ states[port] = mode
153
+ hidden = forms.membership_hidden_mem(states, self.model.port_count)
154
+ self.session.post_form(
155
+ path,
156
+ forms.membership_form(vlan=vlan, hidden_mem=hidden, csrf_hash=_csrf(html)),
157
+ )
158
+ verify = self.session.post_form(path, {"VLAN_ID": str(vlan)})
159
+ after = parse.parse_membership(verify, self.model.port_count)
160
+ if after.get(port) is not mode:
161
+ raise WriteVerificationError(
162
+ f"VLAN {vlan} port {port} did not read back as {mode.value}",
163
+ before=states.get(port),
164
+ after=after.get(port),
165
+ )
166
+
167
+ def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
168
+ del name, force # web UI 8021qCf.cgi has no VLAN-name field (GROUNDED).
169
+ path = _require_path(self.model.key, self._spec.vlan_config_path, "VLAN config")
170
+ page = self.session.get_page(path)
171
+ self.session.post_form(
172
+ path, forms.vlan_add_form(vlan=vlan, csrf_hash=_csrf(page))
173
+ )
174
+ after = parse.parse_vlan_ids(self.session.get_page(path))
175
+ if vlan not in after:
176
+ raise WriteVerificationError(
177
+ f"VLAN {vlan} was not created", before=None, after=after
178
+ )
179
+
180
+ def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
181
+ del force # VLAN delete disruptiveness is guarded per-member elsewhere.
182
+ path = _require_path(self.model.key, self._spec.vlan_config_path, "VLAN config")
183
+ page = self.session.get_page(path)
184
+ idx = _vlan_checkbox_index(page, vlan)
185
+ if idx is None:
186
+ raise HttpUnexpectedPageError(f"VLAN {vlan} not present to delete")
187
+ self.session.post_form(
188
+ path,
189
+ forms.vlan_delete_form(
190
+ vlan=vlan, checkbox_index=idx, csrf_hash=_csrf(page)
191
+ ),
192
+ )
193
+ after = parse.parse_vlan_ids(self.session.get_page(path))
194
+ if vlan in after:
195
+ raise WriteVerificationError(
196
+ f"VLAN {vlan} was not deleted", before=None, after=after
197
+ )
198
+
199
+ def reboot(self, *, force: bool = False) -> None:
200
+ # Capability check BEFORE the force gate: a model with no reboot endpoint
201
+ # must raise the (accurate) UnsupportedCapabilityError, not ProtectedPortError.
202
+ reboot_path = _require_path(
203
+ self.model.key, self._spec.reboot_path, "web reboot"
204
+ )
205
+ if not force:
206
+ raise ProtectedPortError("reboot is disruptive; pass force=True")
207
+ landing = self._spec.vlan_config_path or self._spec.dashboard_path
208
+ page = self.session.get_page(
209
+ _require_path(self.model.key, landing, "web reboot")
210
+ )
211
+ self.session.post_form(reboot_path, forms.reboot_form(csrf_hash=_csrf(page)))
212
+
213
+ def set_port_enabled(
214
+ self, port: int, enabled: bool, *, force: bool = False
215
+ ) -> None:
216
+ del port, enabled, force
217
+ raise UnsupportedCapabilityError(
218
+ f"{self.model.key!r} web port-enable endpoint is UNVERIFIED-pending-capture"
219
+ )
220
+
221
+ def set_mgmt_ip(
222
+ self, address: str, netmask: str, gateway: str, *, force: bool = False
223
+ ) -> None:
224
+ del address, netmask, gateway, force
225
+ raise UnsupportedCapabilityError(
226
+ f"{self.model.key!r} web mgmt-IP endpoint is UNVERIFIED-pending-capture"
227
+ )
228
+
229
+ def _poe_admin(self, port: int) -> bool:
230
+ path = _require_path(self.model.key, self._spec.poe_status_path, "PoE status")
231
+ rows = parse.parse_poe_status(self.session.get_page(path))
232
+ for r in rows:
233
+ if r.port == port:
234
+ return r.admin_enabled
235
+ return False
236
+
237
+
238
+ class AsyncHttpWriter:
239
+ def __init__(
240
+ self,
241
+ session: AsyncHttpSession,
242
+ model: SwitchModel,
243
+ *,
244
+ protected_ports: frozenset[int] = frozenset(),
245
+ ) -> None:
246
+ self._spec = http_spec(model)
247
+ self.session = session
248
+ self.model = model
249
+ self.protected_ports = protected_ports
250
+
251
+ def _guard(self, port: int, force: bool) -> None:
252
+ if port in self.protected_ports and not force:
253
+ raise ProtectedPortError(
254
+ f"port {port} is protected on {self.model.key!r}; pass force=True"
255
+ )
256
+
257
+ async def _poe_admin(self, port: int) -> bool:
258
+ path = _require_path(self.model.key, self._spec.poe_status_path, "PoE status")
259
+ rows = parse.parse_poe_status(await self.session.get_page(path))
260
+ for r in rows:
261
+ if r.port == port:
262
+ return r.admin_enabled
263
+ return False
264
+
265
+ async def set_poe(self, port: int, on: bool, *, force: bool = False) -> None:
266
+ path = _require_path(
267
+ self.model.key, self._spec.poe_config_path, "web PoE config"
268
+ )
269
+ self._guard(port, force)
270
+ before = await self._poe_admin(port)
271
+ page = await self.session.get_page(path)
272
+ form = forms.poe_apply_form(
273
+ port=port, on=on, is_epx=self._spec.is_epx_poe, csrf_hash=_csrf(page)
274
+ )
275
+ await self.session.post_form(path, form)
276
+ after = await self._poe_admin(port)
277
+ if after != on:
278
+ raise WriteVerificationError(
279
+ f"PoE port {port} did not read back as on={on}",
280
+ before=before,
281
+ after=after,
282
+ )
283
+
284
+ async def cycle_poe(
285
+ self,
286
+ port: int,
287
+ *,
288
+ force: bool = False,
289
+ timeouts: PoeCycleTimeouts | None = None,
290
+ ) -> None:
291
+ del timeouts # accepted-but-unused; uniform writer surface (see sync).
292
+ path = _require_path(
293
+ self.model.key, self._spec.poe_config_path, "web PoE config"
294
+ )
295
+ self._guard(port, force)
296
+ page = await self.session.get_page(path)
297
+ await self.session.post_form(
298
+ path, forms.poe_reset_form(port=port, csrf_hash=_csrf(page))
299
+ )
300
+
301
+ async def clear_poe_fault(
302
+ self,
303
+ port: int,
304
+ *,
305
+ force: bool = False,
306
+ timeouts: PoeCycleTimeouts | None = None,
307
+ ) -> None:
308
+ del port, force, timeouts
309
+ raise UnsupportedCapabilityError(
310
+ f"{self.model.key!r} web clear-PoE-fault is UNVERIFIED-pending-capture"
311
+ )
312
+
313
+ async def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None:
314
+ self._guard(port, force)
315
+ path = _require_path(self.model.key, self._spec.pvid_path, "port PVIDs")
316
+ page = await self.session.get_page(path)
317
+ await self.session.post_form(
318
+ path, forms.pvid_form(port=port, vlan=vlan, csrf_hash=_csrf(page))
319
+ )
320
+ after = dict(parse.parse_pvids(await self.session.get_page(path)))
321
+ if after.get(port) != vlan:
322
+ raise WriteVerificationError(
323
+ f"PVID for port {port} did not read back as {vlan}",
324
+ before=None,
325
+ after=after.get(port),
326
+ )
327
+
328
+ async def set_vlan_membership(
329
+ self, vlan: int, port: int, mode: VlanMode, *, force: bool = False
330
+ ) -> None:
331
+ self._guard(port, force)
332
+ path = _require_path(
333
+ self.model.key, self._spec.vlan_membership_path, "VLAN membership"
334
+ )
335
+ html = await self.session.post_form(path, {"VLAN_ID": str(vlan)})
336
+ states = parse.parse_membership(html, self.model.port_count)
337
+ states[port] = mode
338
+ hidden = forms.membership_hidden_mem(states, self.model.port_count)
339
+ await self.session.post_form(
340
+ path,
341
+ forms.membership_form(vlan=vlan, hidden_mem=hidden, csrf_hash=_csrf(html)),
342
+ )
343
+ verify = await self.session.post_form(path, {"VLAN_ID": str(vlan)})
344
+ after = parse.parse_membership(verify, self.model.port_count)
345
+ if after.get(port) is not mode:
346
+ raise WriteVerificationError(
347
+ f"VLAN {vlan} port {port} did not read back as {mode.value}",
348
+ before=states.get(port),
349
+ after=after.get(port),
350
+ )
351
+
352
+ async def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
353
+ del name, force
354
+ path = _require_path(self.model.key, self._spec.vlan_config_path, "VLAN config")
355
+ page = await self.session.get_page(path)
356
+ await self.session.post_form(
357
+ path, forms.vlan_add_form(vlan=vlan, csrf_hash=_csrf(page))
358
+ )
359
+ after = parse.parse_vlan_ids(await self.session.get_page(path))
360
+ if vlan not in after:
361
+ raise WriteVerificationError(
362
+ f"VLAN {vlan} was not created", before=None, after=after
363
+ )
364
+
365
+ async def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
366
+ del force
367
+ path = _require_path(self.model.key, self._spec.vlan_config_path, "VLAN config")
368
+ page = await self.session.get_page(path)
369
+ idx = _vlan_checkbox_index(page, vlan)
370
+ if idx is None:
371
+ raise HttpUnexpectedPageError(f"VLAN {vlan} not present to delete")
372
+ await self.session.post_form(
373
+ path,
374
+ forms.vlan_delete_form(
375
+ vlan=vlan, checkbox_index=idx, csrf_hash=_csrf(page)
376
+ ),
377
+ )
378
+ after = parse.parse_vlan_ids(await self.session.get_page(path))
379
+ if vlan in after:
380
+ raise WriteVerificationError(
381
+ f"VLAN {vlan} was not deleted", before=None, after=after
382
+ )
383
+
384
+ async def reboot(self, *, force: bool = False) -> None:
385
+ # Capability check BEFORE the force gate (see sync reboot).
386
+ reboot_path = _require_path(
387
+ self.model.key, self._spec.reboot_path, "web reboot"
388
+ )
389
+ if not force:
390
+ raise ProtectedPortError("reboot is disruptive; pass force=True")
391
+ landing = self._spec.vlan_config_path or self._spec.dashboard_path
392
+ page = await self.session.get_page(
393
+ _require_path(self.model.key, landing, "web reboot")
394
+ )
395
+ await self.session.post_form(
396
+ reboot_path, forms.reboot_form(csrf_hash=_csrf(page))
397
+ )
398
+
399
+ async def set_port_enabled(
400
+ self, port: int, enabled: bool, *, force: bool = False
401
+ ) -> None:
402
+ # Task 9 fix: was a plain (non-async) `def` -- inconsistent with every
403
+ # other AsyncHttpWriter method and unsound under a facade call site
404
+ # typed `Callable[[...], Awaitable[None]]` (mypy --strict caught the
405
+ # union-type mismatch once the facade's generic per-op dispatcher
406
+ # actually called it). Same immediate-refusal behaviour, now reachable
407
+ # via `await` like its siblings.
408
+ del port, enabled, force
409
+ raise UnsupportedCapabilityError(
410
+ f"{self.model.key!r} web port-enable endpoint is UNVERIFIED-pending-capture"
411
+ )
412
+
413
+ async def set_mgmt_ip(
414
+ self, address: str, netmask: str, gateway: str, *, force: bool = False
415
+ ) -> None:
416
+ # Task 9 fix: see set_port_enabled above.
417
+ del address, netmask, gateway, force
418
+ raise UnsupportedCapabilityError(
419
+ f"{self.model.key!r} web mgmt-IP endpoint is UNVERIFIED-pending-capture"
420
+ )
@@ -0,0 +1,156 @@
1
+ """Public device-data model: frozen dataclasses returned by both APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from dataclasses import dataclass
7
+
8
+
9
+ class PoEDetect(enum.Enum):
10
+ DISABLED = "disabled"
11
+ SEARCHING = "searching"
12
+ DELIVERING = "delivering"
13
+ FAULT = "fault"
14
+ UNKNOWN = "unknown"
15
+
16
+
17
+ class VlanMode(enum.Enum):
18
+ UNTAGGED = "untagged"
19
+ TAGGED = "tagged"
20
+ EXCLUDED = "excluded"
21
+
22
+
23
+ class IpMode(enum.Enum):
24
+ DHCP = "dhcp"
25
+ STATIC = "static"
26
+ UNKNOWN = "unknown"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class PortStatus:
31
+ port: int
32
+ name: str | None
33
+ admin_enabled: bool
34
+ link_up: bool
35
+ speed_mbps: int | None
36
+ # ifAlias (operator-set port description) -- distinct from `name` (ifName).
37
+ # Defaults to None so existing positional call sites (name-only backends,
38
+ # older tests) keep constructing without it; a backend that cannot read
39
+ # ifAlias (NSDP, HTTP) leaves it honestly None rather than fabricating "".
40
+ description: str | None = None
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class PoEStatus:
45
+ port: int
46
+ admin_enabled: bool
47
+ detect: PoEDetect
48
+ power_mw: int | None
49
+
50
+ @property
51
+ def delivering(self) -> bool:
52
+ return self.detect is PoEDetect.DELIVERING
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class VLANInfo:
57
+ vlan_id: int
58
+ name: str | None
59
+ member_ports: frozenset[int]
60
+ tagged_ports: frozenset[int]
61
+ untagged_ports: frozenset[int]
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class LLDPNeighbor:
66
+ local_port: int
67
+ remote_sys_name: str | None
68
+ remote_port_desc: str | None
69
+ remote_chassis_id: str | None
70
+ # lldpRemPortId (LLDP-MIB column 7): the remote port's IDENTIFIER, distinct
71
+ # from remote_port_desc (lldpRemPortDesc, column 8) -- e.g. a neighbour can
72
+ # report port_id "gi24" and port_desc "gi24.uplink" as different values.
73
+ # Defaults to None so existing positional call sites (older tests, a
74
+ # backend that cannot read this column) keep constructing without it.
75
+ remote_port_id: str | None = None
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class MacEntry:
80
+ mac: str
81
+ port: int
82
+ vlan_id: int | None
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class Sensor:
87
+ name: str
88
+ kind: str # "temperature" | "fan" | "power"
89
+ value: float
90
+ unit: str
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class PortStats:
95
+ port: int
96
+ rx_bytes: int | None
97
+ tx_bytes: int | None
98
+ rx_packets: int | None
99
+ tx_packets: int | None
100
+ rx_errors: int | None
101
+ tx_errors: int | None
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class MgmtIpConfig:
106
+ mode: IpMode
107
+ address: str | None
108
+ netmask: str | None
109
+ gateway: str | None
110
+ # dot1dBaseBridgeAddress (BRIDGE-MIB) / the NSDP identity MAC: the switch's
111
+ # own base MAC, formatted "XX:XX:XX:XX:XX:XX". Defaults to None so existing
112
+ # positional call sites keep constructing without it; a backend that
113
+ # genuinely cannot read it (HTTP) leaves it honestly None.
114
+ base_mac: str | None = None
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class DetectedModel:
119
+ """Result of identifying a switch's model over SNMP (sysDescr matching).
120
+
121
+ ``key`` is a registry key (see ``registry.get_model``) if the switch's
122
+ sysDescr confidently matched exactly one registered model's name, or
123
+ ``None`` if it did not -- e.g. an unregistered Netgear model, a
124
+ non-Netgear device, or an unreadable/absent sysDescr. ``None`` is NEVER a
125
+ fabricated guess; see ``protocols.snmp.parse.detect_model_from_sysdescr``.
126
+
127
+ ``sys_descr``/``sys_object_id`` are the raw SNMP-reported strings, kept
128
+ for the caller/logging even when unmatched. ``sys_object_id`` is READ but
129
+ NOT used for matching -- there is no known sysObjectID -> model table
130
+ (no MIBs/captures/prior-art exist for one), so it is carried purely as a
131
+ raw signal and a future exact-match hook, never as fabricated tie-break
132
+ data.
133
+ """
134
+
135
+ key: str | None
136
+ sys_descr: str | None
137
+ sys_object_id: str | None
138
+
139
+ @property
140
+ def matched(self) -> bool:
141
+ return self.key is not None
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class SwitchData:
146
+ model: str
147
+ host: str
148
+ ports: tuple[PortStatus, ...] = ()
149
+ poe: tuple[PoEStatus, ...] = ()
150
+ vlans: tuple[VLANInfo, ...] = ()
151
+ pvids: tuple[tuple[int, int], ...] = ()
152
+ lldp: tuple[LLDPNeighbor, ...] = ()
153
+ macs: tuple[MacEntry, ...] = ()
154
+ sensors: tuple[Sensor, ...] = ()
155
+ stats: tuple[PortStats, ...] = ()
156
+ mgmt_ip: MgmtIpConfig | None = None