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,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
|
+
)
|
netgear_switch/models.py
ADDED
|
@@ -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
|