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,625 @@
1
+ """Model-driven SNMP write/control over a write-capable sync or async client.
2
+
3
+ Parallel to ``snmp_read.py``. Every write performs the SET then re-reads and
4
+ verifies (``WriteVerificationError`` with before/after on mismatch — a real
5
+ ``commitFailed`` surfaces as an ``SnmpError`` from the transport first).
6
+ Disruptive writes to a ``protected_ports`` port are refused unless ``force=True``
7
+ (design spec §6).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import time
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING
15
+
16
+ from .errors import (
17
+ ProtectedPortError,
18
+ UnsupportedCapabilityError,
19
+ WriteVerificationError,
20
+ )
21
+ from .models import PoEDetect
22
+ from .protocols.snmp import oids
23
+ from .protocols.snmp.client import SnmpError
24
+ from .protocols.snmp.parse import decode_port_bitmap
25
+ from .protocols.snmp.write import (
26
+ SetVarbind,
27
+ encode_port_bitmap,
28
+ membership_bitmaps,
29
+ vlan_bitmap_width,
30
+ )
31
+ from .registry import Backend
32
+ from .snmp_read import AsyncSnmpReader, SnmpReader
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Awaitable, Callable
36
+
37
+ from .models import PoEStatus, PortStatus, VLANInfo, VlanMode
38
+ from .protocols.snmp.client import AsyncSnmpWriteClient, SnmpWriteClient
39
+ from .registry import SwitchModel
40
+
41
+
42
+ def _require_snmp(model: SwitchModel) -> None:
43
+ if Backend.SNMP not in model.backends:
44
+ raise UnsupportedCapabilityError(f"model {model.key!r} has no SNMP backend")
45
+
46
+
47
+ def _poe_admin_oid(port: int) -> str:
48
+ return f"{oids.PETH_PSE_PORT_TABLE}.3.1.{port}"
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class PoeCycleTimeouts:
53
+ """Injectable PoE-cycle deadlines (seconds). Defaults match design spec §6;
54
+ tests pass tiny values so cycles run fast against the coherent mock."""
55
+
56
+ off_timeout: float = 30.0
57
+ on_timeout: float = 60.0
58
+ poll_interval: float = 2.0
59
+
60
+
61
+ _DEFAULT_POE_TIMEOUTS = PoeCycleTimeouts()
62
+
63
+
64
+ def _poe_is_off(status: PoEStatus | None, port_up: bool) -> bool:
65
+ return (
66
+ status is not None
67
+ and status.detect in (PoEDetect.DISABLED, PoEDetect.SEARCHING)
68
+ and not port_up
69
+ )
70
+
71
+
72
+ def _poe_recovered(status: PoEStatus | None) -> bool:
73
+ """True once detect has left FAULT and settled to delivering/searching."""
74
+ return status is not None and status.detect in (
75
+ PoEDetect.DELIVERING,
76
+ PoEDetect.SEARCHING,
77
+ )
78
+
79
+
80
+ class SnmpWriter:
81
+ """Synchronous SNMP write facade over one switch."""
82
+
83
+ def __init__(
84
+ self,
85
+ client: SnmpWriteClient,
86
+ model: SwitchModel,
87
+ *,
88
+ protected_ports: frozenset[int] = frozenset(),
89
+ ) -> None:
90
+ _require_snmp(model)
91
+ self.client = client
92
+ self.model = model
93
+ self.protected_ports = protected_ports
94
+ self._reader = SnmpReader(client, model)
95
+
96
+ def _guard(self, port: int, force: bool) -> None:
97
+ if port in self.protected_ports and not force:
98
+ raise ProtectedPortError(
99
+ f"port {port} is protected; pass force=True to override"
100
+ )
101
+
102
+ def _poe_status(self, port: int) -> PoEStatus | None:
103
+ return next((p for p in self._reader.get_poe() if p.port == port), None)
104
+
105
+ def _port_status(self, port: int) -> PortStatus | None:
106
+ return next((p for p in self._reader.get_ports() if p.port == port), None)
107
+
108
+ def _vlan(self, vlan: int) -> VLANInfo | None:
109
+ return next((v for v in self._reader.get_vlans() if v.vlan_id == vlan), None)
110
+
111
+ def set_poe(self, port: int, on: bool, *, force: bool = False) -> None:
112
+ if not on:
113
+ self._guard(port, force) # turning PoE off is disruptive
114
+ before = self._poe_status(port)
115
+ self.client.set(SetVarbind(_poe_admin_oid(port), 1 if on else 2, "i"))
116
+ after = self._poe_status(port)
117
+ if after is None or after.admin_enabled != on:
118
+ raise WriteVerificationError(
119
+ f"PoE admin for port {port} did not read back as {on}",
120
+ before=before, after=after,
121
+ )
122
+
123
+ def _poe_rearm(
124
+ self,
125
+ port: int,
126
+ *,
127
+ timeouts: PoeCycleTimeouts,
128
+ sleep: Callable[[float], None],
129
+ clock: Callable[[], float],
130
+ on_recovered: Callable[[PoEStatus | None], bool],
131
+ on_timeout_message: str,
132
+ ) -> None:
133
+ """Re-arm PoE on ``port``: TWO SEPARATE sequential SETs (off, then on)
134
+ each polled to completion -- never a single duplicate-OID ``set_many``
135
+ PDU. Per-varbind ordering within one PDU carrying the same OID twice
136
+ is undefined on real hardware (RFC 3416); a real agent may reject it
137
+ or collapse it (last-wins), silently defeating the off->on re-arm.
138
+ Shared by ``cycle_poe`` (recovery = delivering) and ``clear_poe_fault``
139
+ (recovery = delivering OR searching, i.e. detect has left FAULT)."""
140
+ before = self._poe_status(port)
141
+ # Phase 1: off, poll until unused/searching + link down.
142
+ self.client.set(SetVarbind(_poe_admin_oid(port), 2, "i"))
143
+ deadline = clock() + timeouts.off_timeout
144
+ while not _poe_is_off(self._poe_status(port), self._port_up(port)):
145
+ if clock() >= deadline:
146
+ raise WriteVerificationError(
147
+ f"PoE port {port} did not turn off within {timeouts.off_timeout}s",
148
+ before=before, after=self._poe_status(port))
149
+ sleep(timeouts.poll_interval)
150
+ # Phase 2: on, poll until the caller's recovery predicate is met.
151
+ self.client.set(SetVarbind(_poe_admin_oid(port), 1, "i"))
152
+ deadline = clock() + timeouts.on_timeout
153
+ while not on_recovered(self._poe_status(port)):
154
+ if clock() >= deadline:
155
+ raise WriteVerificationError(
156
+ on_timeout_message.format(timeout=timeouts.on_timeout),
157
+ before=before, after=self._poe_status(port))
158
+ sleep(timeouts.poll_interval)
159
+
160
+ def cycle_poe(
161
+ self,
162
+ port: int,
163
+ *,
164
+ force: bool = False,
165
+ timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
166
+ sleep: Callable[[float], None] = time.sleep,
167
+ clock: Callable[[], float] = time.monotonic,
168
+ ) -> None:
169
+ self._guard(port, force)
170
+ self._poe_rearm(
171
+ port, timeouts=timeouts, sleep=sleep, clock=clock,
172
+ on_recovered=lambda st: bool(st and st.delivering),
173
+ on_timeout_message=(
174
+ f"PoE port {port} did not return to delivering within "
175
+ "{timeout}s"
176
+ ),
177
+ )
178
+
179
+ def clear_poe_fault(
180
+ self,
181
+ port: int,
182
+ *,
183
+ force: bool = False,
184
+ timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
185
+ sleep: Callable[[float], None] = time.sleep,
186
+ clock: Callable[[], float] = time.monotonic,
187
+ ) -> None:
188
+ self._guard(port, force)
189
+ # Re-arm detection: disable then enable as TWO SEPARATE SETs (never a
190
+ # single duplicate-OID set_many -- see _poe_rearm), then POLL for
191
+ # detect to leave FAULT. An immediate single re-read false-negatives
192
+ # on real hardware because detect transitions take seconds (review
193
+ # item 5); tests inject tiny timeouts so this is fast against the
194
+ # coherent mock.
195
+ self._poe_rearm(
196
+ port, timeouts=timeouts, sleep=sleep, clock=clock,
197
+ on_recovered=_poe_recovered,
198
+ on_timeout_message=(
199
+ f"PoE port {port} still in FAULT after clear within {{timeout}}s"
200
+ ),
201
+ )
202
+
203
+ def _port_up(self, port: int) -> bool:
204
+ status = self._port_status(port)
205
+ return bool(status and status.link_up)
206
+
207
+ def set_port_enabled(
208
+ self, port: int, enabled: bool, *, force: bool = False
209
+ ) -> None:
210
+ if not enabled:
211
+ self._guard(port, force) # disabling a port is disruptive
212
+ before = self._port_status(port)
213
+ self.client.set(
214
+ SetVarbind(f"{oids.IF_ADMIN_STATUS}.{port}", 1 if enabled else 2, "i")
215
+ )
216
+ after = self._port_status(port)
217
+ if after is None or after.admin_enabled != enabled:
218
+ raise WriteVerificationError(
219
+ f"admin state for port {port} did not read back as {enabled}",
220
+ before=before, after=after,
221
+ )
222
+
223
+ def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None:
224
+ self._guard(port, force) # changing a port's PVID is disruptive
225
+ before = self._reader.get_pvids()
226
+ self.client.set(SetVarbind(f"{oids.DOT1Q_PVID}.{port}", vlan, "u"))
227
+ after = self._reader.get_pvids()
228
+ if (port, vlan) not in after:
229
+ raise WriteVerificationError(
230
+ f"PVID for port {port} did not read back as {vlan}",
231
+ before=before, after=after,
232
+ )
233
+
234
+ def set_vlan_membership(
235
+ self, vlan: int, port: int, mode: VlanMode, *, force: bool = False
236
+ ) -> None:
237
+ self._guard(port, force)
238
+ before = self._vlan(vlan)
239
+ if before is None:
240
+ # Precondition failure: no SET has been attempted, so this is NOT a
241
+ # verification divergence (review item 9).
242
+ raise SnmpError(f"VLAN {vlan} does not exist")
243
+ new_egress, new_untagged = membership_bitmaps(
244
+ mode=mode, port=port,
245
+ egress=encode_port_bitmap(before.member_ports),
246
+ untagged=encode_port_bitmap(before.untagged_ports),
247
+ width_bytes=vlan_bitmap_width(self.model),
248
+ )
249
+ self.client.set_many([
250
+ SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_EGRESS}.{vlan}", new_egress, "x"),
251
+ SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_UNTAGGED}.{vlan}", new_untagged, "x"),
252
+ ])
253
+ after = self._vlan(vlan)
254
+ # Verify BOTH columns this op wrote: egress membership AND the untagged
255
+ # set. A mock/device that accepts the egress SET but silently drops the
256
+ # untagged SET must be caught (review item 1).
257
+ want_egress = frozenset(decode_port_bitmap(new_egress))
258
+ want_untagged = frozenset(decode_port_bitmap(new_untagged))
259
+ if after is None:
260
+ raise WriteVerificationError(
261
+ f"VLAN {vlan} disappeared while setting membership for port {port}",
262
+ before=before, after=after,
263
+ )
264
+ if after.member_ports != want_egress:
265
+ raise WriteVerificationError(
266
+ f"VLAN {vlan} egress (member_ports) for port {port} did not "
267
+ f"verify: wanted {sorted(want_egress)}, "
268
+ f"got {sorted(after.member_ports)}",
269
+ before=before, after=after,
270
+ )
271
+ if after.untagged_ports != want_untagged:
272
+ raise WriteVerificationError(
273
+ f"VLAN {vlan} untagged_ports for port {port} did not verify: "
274
+ f"wanted {sorted(want_untagged)}, got {sorted(after.untagged_ports)}",
275
+ before=before, after=after,
276
+ )
277
+
278
+ def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
279
+ # Creating an EMPTY VLAN adds no port membership, so it is
280
+ # non-disruptive and does NOT require force. ``force`` exists only for
281
+ # signature symmetry with delete_vlan (review item 3).
282
+ before = self._vlan(vlan)
283
+ self.client.set_many([
284
+ SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_ROW_STATUS}.{vlan}",
285
+ oids.ROW_STATUS_CREATE_AND_GO, "i"),
286
+ SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_NAME}.{vlan}", name, "s"),
287
+ ])
288
+ after = self._vlan(vlan)
289
+ if after is None or (after.name or "") != name:
290
+ raise WriteVerificationError(
291
+ f"VLAN {vlan} was not created with name {name!r}",
292
+ before=before, after=after,
293
+ )
294
+
295
+ def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
296
+ before = self._vlan(vlan)
297
+ if before is None:
298
+ # Precondition failure: no SET has been attempted, so this is NOT a
299
+ # verification divergence (review item 9, mirrors
300
+ # set_vlan_membership's missing-VLAN precondition). The mock
301
+ # accepts destroy(6) on an absent row as a silent no-op, so
302
+ # skipping this check would let a delete of a non-existent VLAN
303
+ # pass verification vacuously instead of surfacing as an error.
304
+ raise SnmpError(f"VLAN {vlan} does not exist")
305
+ # Destroying a VLAN strips membership from EVERY member port; if any is a
306
+ # protected (uplink/mgmt) port, refuse without force (review item 3).
307
+ if not force:
308
+ clash = before.member_ports & self.protected_ports
309
+ if clash:
310
+ raise ProtectedPortError(
311
+ f"VLAN {vlan} includes protected port(s) {sorted(clash)}; "
312
+ f"pass force=True to delete it anyway"
313
+ )
314
+ self.client.set(SetVarbind(
315
+ f"{oids.DOT1Q_VLAN_STATIC_ROW_STATUS}.{vlan}",
316
+ oids.ROW_STATUS_DESTROY, "i",
317
+ ))
318
+ after = self._vlan(vlan)
319
+ if after is not None:
320
+ raise WriteVerificationError(
321
+ f"VLAN {vlan} still exists after destroy", before=before, after=after,
322
+ )
323
+
324
+ def set_mgmt_ip(
325
+ self, address: str, netmask: str, gateway: str, *, force: bool = False
326
+ ) -> None:
327
+ """Set the switch's own management IP (address/netmask/gateway).
328
+
329
+ UNVERIFIED write path (see oids.VendorOids mgmt_write_* fields): the
330
+ exact writable OIDs are placeholders pending Slice 7 hardware capture,
331
+ so this is force-gated (a wrong mgmt-IP write can strand the switch —
332
+ design spec §11.1). DHCP-mode switching is intentionally NOT offered
333
+ here because even its read OID is unverified; do not fabricate it.
334
+ """
335
+ if not force:
336
+ raise ProtectedPortError(
337
+ "set_mgmt_ip can strand the switch and uses UNVERIFIED OIDs; "
338
+ "pass force=True to proceed"
339
+ )
340
+ vo = oids.vendor_oids(self.model)
341
+ before = self._reader.get_mgmt_ip()
342
+ self.client.set_many([
343
+ SetVarbind(vo.mgmt_write_addr_unverified, address, "a"),
344
+ SetVarbind(vo.mgmt_write_netmask_unverified, netmask, "a"),
345
+ SetVarbind(vo.mgmt_write_gateway_unverified, gateway, "a"),
346
+ ])
347
+ after = self._reader.get_mgmt_ip()
348
+ # Highest strand-risk op: verify EVERY field written (address, netmask,
349
+ # AND gateway), naming whichever diverged (review item 2).
350
+ for field, want, got in (
351
+ ("address", address, after.address),
352
+ ("netmask", netmask, after.netmask),
353
+ ("gateway", gateway, after.gateway),
354
+ ):
355
+ if got != want:
356
+ raise WriteVerificationError(
357
+ f"management {field} did not read back as {want!r} (got {got!r})",
358
+ before=before, after=after)
359
+
360
+
361
+ class AsyncSnmpWriter:
362
+ """Asynchronous SNMP write facade (mirror of SnmpWriter)."""
363
+
364
+ def __init__(
365
+ self,
366
+ client: AsyncSnmpWriteClient,
367
+ model: SwitchModel,
368
+ *,
369
+ protected_ports: frozenset[int] = frozenset(),
370
+ ) -> None:
371
+ _require_snmp(model)
372
+ self.client = client
373
+ self.model = model
374
+ self.protected_ports = protected_ports
375
+ self._reader = AsyncSnmpReader(client, model)
376
+
377
+ def _guard(self, port: int, force: bool) -> None:
378
+ if port in self.protected_ports and not force:
379
+ raise ProtectedPortError(
380
+ f"port {port} is protected; pass force=True to override"
381
+ )
382
+
383
+ async def _poe_status(self, port: int) -> PoEStatus | None:
384
+ return next((p for p in await self._reader.get_poe() if p.port == port), None)
385
+
386
+ async def _port_status(self, port: int) -> PortStatus | None:
387
+ return next((p for p in await self._reader.get_ports() if p.port == port), None)
388
+
389
+ async def _vlan(self, vlan: int) -> VLANInfo | None:
390
+ vlans = await self._reader.get_vlans()
391
+ return next((v for v in vlans if v.vlan_id == vlan), None)
392
+
393
+ async def set_poe(self, port: int, on: bool, *, force: bool = False) -> None:
394
+ if not on:
395
+ self._guard(port, force)
396
+ before = await self._poe_status(port)
397
+ await self.client.set(SetVarbind(_poe_admin_oid(port), 1 if on else 2, "i"))
398
+ after = await self._poe_status(port)
399
+ if after is None or after.admin_enabled != on:
400
+ raise WriteVerificationError(
401
+ f"PoE admin for port {port} did not read back as {on}",
402
+ before=before, after=after,
403
+ )
404
+
405
+ async def _port_up(self, port: int) -> bool:
406
+ status = await self._port_status(port)
407
+ return bool(status and status.link_up)
408
+
409
+ async def _poe_rearm(
410
+ self,
411
+ port: int,
412
+ *,
413
+ timeouts: PoeCycleTimeouts,
414
+ sleep: Callable[[float], Awaitable[None]],
415
+ clock: Callable[[], float],
416
+ on_recovered: Callable[[PoEStatus | None], bool],
417
+ on_timeout_message: str,
418
+ ) -> None:
419
+ """Async twin of ``SnmpWriter._poe_rearm``: TWO SEPARATE sequential
420
+ SETs (off, then on) each polled to completion -- never a single
421
+ duplicate-OID ``set_many`` PDU (RFC 3416 per-varbind ordering is
422
+ undefined for a repeated OID, so a real agent may reject it or
423
+ collapse it and silently defeat the off->on re-arm)."""
424
+ before = await self._poe_status(port)
425
+ # Phase 1: off, poll until unused/searching + link down.
426
+ await self.client.set(SetVarbind(_poe_admin_oid(port), 2, "i"))
427
+ deadline = clock() + timeouts.off_timeout
428
+ while not _poe_is_off(await self._poe_status(port), await self._port_up(port)):
429
+ if clock() >= deadline:
430
+ raise WriteVerificationError(
431
+ f"PoE port {port} did not turn off within {timeouts.off_timeout}s",
432
+ before=before, after=await self._poe_status(port))
433
+ await sleep(timeouts.poll_interval)
434
+ # Phase 2: on, poll until the caller's recovery predicate is met.
435
+ await self.client.set(SetVarbind(_poe_admin_oid(port), 1, "i"))
436
+ deadline = clock() + timeouts.on_timeout
437
+ while not on_recovered(await self._poe_status(port)):
438
+ if clock() >= deadline:
439
+ raise WriteVerificationError(
440
+ on_timeout_message.format(timeout=timeouts.on_timeout),
441
+ before=before, after=await self._poe_status(port))
442
+ await sleep(timeouts.poll_interval)
443
+
444
+ async def cycle_poe(
445
+ self,
446
+ port: int,
447
+ *,
448
+ force: bool = False,
449
+ timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
450
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
451
+ clock: Callable[[], float] = time.monotonic,
452
+ ) -> None:
453
+ self._guard(port, force)
454
+ await self._poe_rearm(
455
+ port, timeouts=timeouts, sleep=sleep, clock=clock,
456
+ on_recovered=lambda st: bool(st and st.delivering),
457
+ on_timeout_message=(
458
+ f"PoE port {port} did not return to delivering within "
459
+ "{timeout}s"
460
+ ),
461
+ )
462
+
463
+ async def clear_poe_fault(
464
+ self,
465
+ port: int,
466
+ *,
467
+ force: bool = False,
468
+ timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
469
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
470
+ clock: Callable[[], float] = time.monotonic,
471
+ ) -> None:
472
+ self._guard(port, force)
473
+ # Re-arm as TWO SEPARATE SETs (never a single duplicate-OID set_many
474
+ # -- see _poe_rearm), then poll for detect to leave FAULT (review item
475
+ # 5); tiny timeouts in tests.
476
+ await self._poe_rearm(
477
+ port, timeouts=timeouts, sleep=sleep, clock=clock,
478
+ on_recovered=_poe_recovered,
479
+ on_timeout_message=(
480
+ f"PoE port {port} still in FAULT after clear within {{timeout}}s"
481
+ ),
482
+ )
483
+
484
+ async def set_port_enabled(
485
+ self, port: int, enabled: bool, *, force: bool = False
486
+ ) -> None:
487
+ if not enabled:
488
+ self._guard(port, force)
489
+ before = await self._port_status(port)
490
+ await self.client.set(
491
+ SetVarbind(f"{oids.IF_ADMIN_STATUS}.{port}", 1 if enabled else 2, "i")
492
+ )
493
+ after = await self._port_status(port)
494
+ if after is None or after.admin_enabled != enabled:
495
+ raise WriteVerificationError(
496
+ f"admin state for port {port} did not read back as {enabled}",
497
+ before=before, after=after,
498
+ )
499
+
500
+ async def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None:
501
+ self._guard(port, force)
502
+ before = await self._reader.get_pvids()
503
+ await self.client.set(SetVarbind(f"{oids.DOT1Q_PVID}.{port}", vlan, "u"))
504
+ after = await self._reader.get_pvids()
505
+ if (port, vlan) not in after:
506
+ raise WriteVerificationError(
507
+ f"PVID for port {port} did not read back as {vlan}",
508
+ before=before, after=after,
509
+ )
510
+
511
+ async def set_vlan_membership(
512
+ self, vlan: int, port: int, mode: VlanMode, *, force: bool = False
513
+ ) -> None:
514
+ self._guard(port, force)
515
+ before = await self._vlan(vlan)
516
+ if before is None:
517
+ # Precondition failure (review item 9): no SET attempted.
518
+ raise SnmpError(f"VLAN {vlan} does not exist")
519
+ new_egress, new_untagged = membership_bitmaps(
520
+ mode=mode, port=port,
521
+ egress=encode_port_bitmap(before.member_ports),
522
+ untagged=encode_port_bitmap(before.untagged_ports),
523
+ width_bytes=vlan_bitmap_width(self.model),
524
+ )
525
+ await self.client.set_many([
526
+ SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_EGRESS}.{vlan}", new_egress, "x"),
527
+ SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_UNTAGGED}.{vlan}", new_untagged, "x"),
528
+ ])
529
+ after = await self._vlan(vlan)
530
+ # Verify BOTH written columns (egress AND untagged) — review item 1.
531
+ want_egress = frozenset(decode_port_bitmap(new_egress))
532
+ want_untagged = frozenset(decode_port_bitmap(new_untagged))
533
+ if after is None:
534
+ raise WriteVerificationError(
535
+ f"VLAN {vlan} disappeared while setting membership for port {port}",
536
+ before=before, after=after,
537
+ )
538
+ if after.member_ports != want_egress:
539
+ raise WriteVerificationError(
540
+ f"VLAN {vlan} egress (member_ports) for port {port} did not "
541
+ f"verify: wanted {sorted(want_egress)}, "
542
+ f"got {sorted(after.member_ports)}",
543
+ before=before, after=after,
544
+ )
545
+ if after.untagged_ports != want_untagged:
546
+ raise WriteVerificationError(
547
+ f"VLAN {vlan} untagged_ports for port {port} did not verify: "
548
+ f"wanted {sorted(want_untagged)}, got {sorted(after.untagged_ports)}",
549
+ before=before, after=after,
550
+ )
551
+
552
+ async def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
553
+ # Empty VLAN creation is non-disruptive; force is for symmetry only.
554
+ before = await self._vlan(vlan)
555
+ await self.client.set_many([
556
+ SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_ROW_STATUS}.{vlan}",
557
+ oids.ROW_STATUS_CREATE_AND_GO, "i"),
558
+ SetVarbind(f"{oids.DOT1Q_VLAN_STATIC_NAME}.{vlan}", name, "s"),
559
+ ])
560
+ after = await self._vlan(vlan)
561
+ if after is None or (after.name or "") != name:
562
+ raise WriteVerificationError(
563
+ f"VLAN {vlan} was not created with name {name!r}",
564
+ before=before, after=after,
565
+ )
566
+
567
+ async def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
568
+ before = await self._vlan(vlan)
569
+ if before is None:
570
+ # Precondition failure (review item 9): no SET attempted. Mirrors
571
+ # the sync path -- destroy(6) on an absent row is a silent no-op
572
+ # in the mock, so this must be raised before issuing any SET.
573
+ raise SnmpError(f"VLAN {vlan} does not exist")
574
+ # Refuse if a member port is protected, unless force (review item 3).
575
+ if not force:
576
+ clash = before.member_ports & self.protected_ports
577
+ if clash:
578
+ raise ProtectedPortError(
579
+ f"VLAN {vlan} includes protected port(s) {sorted(clash)}; "
580
+ f"pass force=True to delete it anyway"
581
+ )
582
+ await self.client.set(SetVarbind(
583
+ f"{oids.DOT1Q_VLAN_STATIC_ROW_STATUS}.{vlan}",
584
+ oids.ROW_STATUS_DESTROY, "i",
585
+ ))
586
+ after = await self._vlan(vlan)
587
+ if after is not None:
588
+ raise WriteVerificationError(
589
+ f"VLAN {vlan} still exists after destroy", before=before, after=after,
590
+ )
591
+
592
+ async def set_mgmt_ip(
593
+ self, address: str, netmask: str, gateway: str, *, force: bool = False
594
+ ) -> None:
595
+ """Set the switch's own management IP (address/netmask/gateway).
596
+
597
+ UNVERIFIED write path (see oids.VendorOids mgmt_write_* fields): the
598
+ exact writable OIDs are placeholders pending Slice 7 hardware capture,
599
+ so this is force-gated (a wrong mgmt-IP write can strand the switch —
600
+ design spec §11.1). DHCP-mode switching is intentionally NOT offered
601
+ here because even its read OID is unverified; do not fabricate it.
602
+ """
603
+ if not force:
604
+ raise ProtectedPortError(
605
+ "set_mgmt_ip can strand the switch and uses UNVERIFIED OIDs; "
606
+ "pass force=True to proceed"
607
+ )
608
+ vo = oids.vendor_oids(self.model)
609
+ before = await self._reader.get_mgmt_ip()
610
+ await self.client.set_many([
611
+ SetVarbind(vo.mgmt_write_addr_unverified, address, "a"),
612
+ SetVarbind(vo.mgmt_write_netmask_unverified, netmask, "a"),
613
+ SetVarbind(vo.mgmt_write_gateway_unverified, gateway, "a"),
614
+ ])
615
+ after = await self._reader.get_mgmt_ip()
616
+ # Verify EVERY field written (address, netmask, AND gateway) — item 2.
617
+ for field, want, got in (
618
+ ("address", address, after.address),
619
+ ("netmask", netmask, after.netmask),
620
+ ("gateway", gateway, after.gateway),
621
+ ):
622
+ if got != want:
623
+ raise WriteVerificationError(
624
+ f"management {field} did not read back as {want!r} (got {got!r})",
625
+ before=before, after=after)