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,615 @@
1
+ """The one authoritative in-memory virtual-switch device state.
2
+
3
+ ``VirtualSwitchState`` holds everything a simulated switch "knows" about
4
+ itself — port link/admin/speed, counters, VLANs, PoE, sensors, the MAC/FDB
5
+ table, LLDP neighbours and the management IP — as small mutable ``*Sim``
6
+ dataclasses. ``oid_map()`` projects that state onto the flat numeric
7
+ OID -> (snmp_type, value) view a protocol face (Task 15) serves and the
8
+ Task 5-9 parsers consume. This module is pure data + projection: no network.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ import dataclasses
14
+ import struct
15
+ from dataclasses import dataclass, field
16
+ from typing import TYPE_CHECKING
17
+
18
+ from ..registry import get_model
19
+
20
+ if TYPE_CHECKING:
21
+ from ..protocols.nsdp.protocol import Tag, TLVEntry
22
+
23
+
24
+ def encode_port_bitmap(ports: set[int], width_bytes: int = 8) -> str:
25
+ """Inverse of ``parse.decode_port_bitmap``: a port set -> a latin-1 bitmap.
26
+
27
+ Delegates to the canonical bytes encoder in
28
+ ``protocols/snmp/write.encode_port_bitmap`` (single source of truth for the
29
+ MSB-first bit-packing) and decodes to the latin-1 ``str`` this module's
30
+ callers expect.
31
+ """
32
+ from ..protocols.snmp.write import encode_port_bitmap as _encode_bytes
33
+
34
+ return _encode_bytes(ports, width_bytes).decode("latin-1")
35
+
36
+
37
+ def _mbps_to_speed_byte(mbps: int) -> int:
38
+ """Map a negotiated Mbps rate to its NSDP LinkSpeed wire byte."""
39
+ return {10: 0x02, 100: 0x04, 1000: 0x05, 10000: 0xFF}.get(mbps, 0x00)
40
+
41
+
42
+ @dataclass
43
+ class PortSim:
44
+ """One switch port's link/admin/speed/name plus optional HC counters.
45
+
46
+ Counters are ``int | None``: ``None`` means "this port does not expose
47
+ this counter" and must round-trip to an *absent* row in ``oid_map()`` (no
48
+ fabricated zero), so ``parse_port_stats`` yields ``None`` there too.
49
+ """
50
+
51
+ name: str
52
+ admin: bool
53
+ link: bool
54
+ speed: int
55
+ rx_octets: int | None = None
56
+ tx_octets: int | None = None
57
+ rx_ucast: int | None = None
58
+ tx_ucast: int | None = None
59
+ rx_errors: int | None = None
60
+ tx_errors: int | None = None
61
+ # ifAlias (operator-set port description). None = this port's ifAlias
62
+ # column instance is entirely absent (never configured), mirroring real
63
+ # hardware where an unset alias may not answer at all -- not a fabricated
64
+ # "".
65
+ description: str | None = None
66
+
67
+
68
+ @dataclass
69
+ class VlanSim:
70
+ """One dot1q VLAN: display name plus egress-member and untagged port sets."""
71
+
72
+ name: str
73
+ member: set[int] = field(default_factory=set)
74
+ untagged: set[int] = field(default_factory=set)
75
+
76
+
77
+ @dataclass
78
+ class PoeSim:
79
+ """One PoE port: RFC3621 admin/detect state plus vendor delivered power."""
80
+
81
+ admin: bool
82
+ detect: int
83
+ power_mw: int = 0
84
+
85
+
86
+ @dataclass
87
+ class SensorSim:
88
+ """One box sensor reading (fan RPM / PSU watts / temperature).
89
+
90
+ ``raw`` is the literal wire text: either a decimal integer string or
91
+ Netgear's ``"Not Supported"`` placeholder for an unpopulated slot.
92
+ """
93
+
94
+ kind: str # "fan" | "power" | "temperature"
95
+ instance: str
96
+ raw: str
97
+
98
+
99
+ @dataclass
100
+ class MacSim:
101
+ """One learned MAC/FDB entry: VLAN, 6-byte MAC, bridge-port index."""
102
+
103
+ vlan: int
104
+ mac_bytes: tuple[int, int, int, int, int, int]
105
+ bridge_port: int
106
+
107
+
108
+ @dataclass
109
+ class LldpSim:
110
+ """One lldpRemTable neighbour row group."""
111
+
112
+ time_mark: int
113
+ local_port: int
114
+ rem_idx: int
115
+ chassis: str
116
+ port_id: str
117
+ port_desc: str
118
+ sys_name: str
119
+
120
+
121
+ @dataclass
122
+ class MgmtSim:
123
+ """The switch's own management-IP configuration."""
124
+
125
+ address: str
126
+ netmask: str
127
+ gateway: str
128
+ mode: str # "static" | "dhcp"
129
+
130
+
131
+ @dataclass
132
+ class VirtualSwitchState:
133
+ """The one authoritative virtual-switch device state.
134
+
135
+ A mutable holder (later slices mutate it to simulate writes); pure data
136
+ plus the ``oid_map()`` SNMP projection, no network here.
137
+ """
138
+
139
+ model_key: str
140
+ ports: dict[int, PortSim] = field(default_factory=dict)
141
+ vlans: dict[int, VlanSim] = field(default_factory=dict)
142
+ pvids: dict[int, int] = field(default_factory=dict)
143
+ poe: dict[int, PoeSim] = field(default_factory=dict)
144
+ sensors: list[SensorSim] = field(default_factory=list)
145
+ macs: list[MacSim] = field(default_factory=list)
146
+ bridge_ports: dict[int, int] = field(default_factory=dict)
147
+ lldp: list[LldpSim] = field(default_factory=list)
148
+ mgmt: MgmtSim = field(
149
+ default_factory=lambda: MgmtSim(
150
+ address="0.0.0.0", netmask="0.0.0.0", gateway="0.0.0.0", mode="dhcp"
151
+ )
152
+ )
153
+ model_name: str = ""
154
+ serial: str = ""
155
+ firmware: str = ""
156
+ hostname: str = ""
157
+ nsdp_password: str = "password"
158
+ # QoS engine mode (NSDP tag 0x3400): None = unseeded (tag omitted from
159
+ # nsdp_tlvs(), exactly like a real switch that doesn't answer it).
160
+ nsdp_qos_engine: int | None = None
161
+ # Port mirroring (NSDP tag 0x5C00): None destination = unseeded/disabled.
162
+ nsdp_port_mirroring_dest: int | None = None
163
+ nsdp_port_mirroring_sources: frozenset[int] = field(default_factory=frozenset)
164
+ # IGMP snooping (NSDP tag 0x6800): None = unseeded.
165
+ nsdp_igmp_snooping_enabled: bool | None = None
166
+ nsdp_igmp_snooping_vlan: int | None = None
167
+ # Broadcast storm filtering (NSDP tag 0x5400): None = unseeded.
168
+ nsdp_broadcast_filtering: bool | None = None
169
+ # Loop detection (NSDP tag 0x9000): None = unseeded.
170
+ nsdp_loop_detection: bool | None = None
171
+ # Fixed seed MAC for device identity: the NSDP identity TLV (Tag.MAC /
172
+ # server_mac) AND the SNMP dot1dBaseBridgeAddress scalar (see oid_map())
173
+ # both project this same value -- on real hardware they're the same
174
+ # physical base MAC.
175
+ nsdp_mac: bytes = b"\x28\xc6\x8e\x00\x00\x01"
176
+ # MIB-II sysDescr (Task 2 model detection). Empty means unseeded: oid_map()
177
+ # falls back to a generic-but-real-model-name text derived from the
178
+ # registry's own display_name, so sysDescr-based detection works out of
179
+ # the box for every SNMP-capable registered model, not just the ones with
180
+ # a hand-authored seed_*() (see seed_gsm7252ps for the hand-seeded case).
181
+ sys_descr: str = ""
182
+ # UNVERIFIED sysObjectID test fixture -- see oid_map(). There is no known
183
+ # real sysObjectID -> model table (no MIBs/captures/prior-art exist for
184
+ # one); this value is NEVER a claim about real hardware, purely a
185
+ # plausible-looking virtual/test placeholder under the model's own
186
+ # 1.3.6.1.4.1.4526 vendor subtree, so sysObjectID round-trips end-to-end.
187
+ # Empty means unseeded: oid_map() derives one from the model's vendor base.
188
+ sys_object_id: str = ""
189
+
190
+ def oid_map(self) -> dict[str, tuple[str, str]]:
191
+ """Project this state onto the full numeric OID -> (type, value) view.
192
+
193
+ Built directly from the exact OID layouts in ``protocols.snmp.oids``
194
+ so a protocol face can serve it and the Task 5-9 parsers reconstruct
195
+ the seeded state from what the face returns.
196
+ """
197
+ from ..protocols.snmp import oids
198
+ from ..protocols.snmp.write import vlan_bitmap_width
199
+
200
+ model = get_model(self.model_key)
201
+ v = oids.vendor_oids(model)
202
+ vlan_width = vlan_bitmap_width(model)
203
+ m: dict[str, tuple[str, str]] = {}
204
+
205
+ # dot1dBaseBridgeAddress (BRIDGE-MIB scalar): the switch's own base
206
+ # MAC. Reuses `nsdp_mac` -- on a real device the SNMP bridge base
207
+ # address and the NSDP-reported identity MAC are the same physical
208
+ # address, so one seed value serves both protocol faces.
209
+ m[f"{oids.DOT1D_BASE_BRIDGE_ADDRESS}.0"] = (
210
+ "OCTETSTR", self.nsdp_mac.decode("latin-1"))
211
+
212
+ # MIB-II System group (Task 2 model detection). sysDescr is a REAL,
213
+ # honestly-matchable signal (a real switch's own sysDescr text
214
+ # contains its model name); sysObjectID has no known OID->model
215
+ # table, so the value projected here is an UNVERIFIED virtual/test
216
+ # fixture only -- see the field docstrings above, never trust it as
217
+ # ground truth for a real device.
218
+ m[oids.SYS_DESCR] = (
219
+ "OCTETSTR", self.sys_descr or f"Netgear {model.display_name}"
220
+ )
221
+ m[oids.SYS_OBJECT_ID] = ("OID", self.sys_object_id or f"{v.base}.1")
222
+
223
+ for port, sim in self.ports.items():
224
+ m[f"{oids.IF_ADMIN_STATUS}.{port}"] = ("INTEGER", "1" if sim.admin else "2")
225
+ m[f"{oids.IF_OPER_STATUS}.{port}"] = ("INTEGER", "1" if sim.link else "2")
226
+ m[f"{oids.IF_HIGH_SPEED}.{port}"] = ("Gauge32", str(sim.speed))
227
+ m[f"{oids.IF_NAME}.{port}"] = ("OCTETSTR", sim.name)
228
+ if sim.description is not None:
229
+ m[f"{oids.IF_ALIAS}.{port}"] = ("OCTETSTR", sim.description)
230
+ # Port stats: only emit a counter the port actually exposes
231
+ # (None -> skip, so parse_port_stats yields None there, never a
232
+ # fabricated 0).
233
+ stat_cols: tuple[tuple[str, str, int | None], ...] = (
234
+ (oids.IF_HC_IN_OCTETS, "Counter64", sim.rx_octets),
235
+ (oids.IF_HC_OUT_OCTETS, "Counter64", sim.tx_octets),
236
+ (oids.IF_HC_IN_UCAST, "Counter64", sim.rx_ucast),
237
+ (oids.IF_HC_OUT_UCAST, "Counter64", sim.tx_ucast),
238
+ (oids.IF_IN_ERRORS, "Counter32", sim.rx_errors),
239
+ (oids.IF_OUT_ERRORS, "Counter32", sim.tx_errors),
240
+ )
241
+ for base, typ, val in stat_cols:
242
+ if val is not None:
243
+ m[f"{base}.{port}"] = (typ, str(val))
244
+
245
+ for vid, vsim in self.vlans.items():
246
+ m[f"{oids.DOT1Q_VLAN_STATIC_NAME}.{vid}"] = ("OCTETSTR", vsim.name)
247
+ m[f"{oids.DOT1Q_VLAN_STATIC_EGRESS}.{vid}"] = (
248
+ "OCTETSTR", encode_port_bitmap(vsim.member, width_bytes=vlan_width))
249
+ m[f"{oids.DOT1Q_VLAN_STATIC_UNTAGGED}.{vid}"] = (
250
+ "OCTETSTR", encode_port_bitmap(vsim.untagged, width_bytes=vlan_width))
251
+
252
+ for port, pv in self.pvids.items():
253
+ m[f"{oids.DOT1Q_PVID}.{port}"] = ("Gauge32", str(pv))
254
+
255
+ for port, psim in self.poe.items():
256
+ m[f"{oids.PETH_PSE_PORT_TABLE}.3.1.{port}"] = (
257
+ "INTEGER", "1" if psim.admin else "2")
258
+ m[f"{oids.PETH_PSE_PORT_TABLE}.6.1.{port}"] = (
259
+ "INTEGER", str(psim.detect))
260
+ m[f"{v.poe_power_mw}.1.{port}"] = ("Gauge32", str(psim.power_mw))
261
+
262
+ for ssim in self.sensors:
263
+ base = {
264
+ "fan": v.box_fan,
265
+ "power": v.box_psu_power,
266
+ "temperature": v.box_temp,
267
+ }[ssim.kind]
268
+ m[f"{base}.{ssim.instance}"] = ("OCTETSTR", ssim.raw)
269
+
270
+ # MAC/FDB: dot1qTpFdbPort values keyed by <vlan>.<6 MAC bytes>, plus
271
+ # the dot1dBasePortIfIndex bridge-port -> ifIndex rows the parser
272
+ # joins on.
273
+ for msim in self.macs:
274
+ mac_suffix = ".".join(str(b) for b in msim.mac_bytes)
275
+ m[f"{oids.DOT1Q_TP_FDB_PORT}.{msim.vlan}.{mac_suffix}"] = (
276
+ "INTEGER", str(msim.bridge_port))
277
+ for bridge_port, ifindex in self.bridge_ports.items():
278
+ m[f"{oids.DOT1D_BASE_PORT_IF_INDEX}.{bridge_port}"] = (
279
+ "INTEGER", str(ifindex))
280
+
281
+ # LLDP remote neighbours across lldpRemTable columns 5/7/8/9.
282
+ for nb in self.lldp:
283
+ idx = f"{nb.time_mark}.{nb.local_port}.{nb.rem_idx}"
284
+ m[f"{oids.LLDP_REM_TABLE}.1.5.{idx}"] = ("OCTETSTR", nb.chassis)
285
+ m[f"{oids.LLDP_REM_TABLE}.1.7.{idx}"] = ("OCTETSTR", nb.port_id)
286
+ m[f"{oids.LLDP_REM_TABLE}.1.8.{idx}"] = ("OCTETSTR", nb.port_desc)
287
+ m[f"{oids.LLDP_REM_TABLE}.1.9.{idx}"] = ("OCTETSTR", nb.sys_name)
288
+
289
+ # mgmt-ip: ipAddrTable + ipRouteTable + DHCP mode.
290
+ idx = self.mgmt.address
291
+ m[f"{oids.IP_ADENT_ADDR}.{idx}"] = ("IPADDR", self.mgmt.address)
292
+ m[f"{oids.IP_ADENT_NETMASK}.{idx}"] = ("IPADDR", self.mgmt.netmask)
293
+ m[f"{oids.IP_ROUTE_DEST}.0.0.0.0"] = ("IPADDR", "0.0.0.0")
294
+ m[f"{oids.IP_ROUTE_NEXTHOP}.0.0.0.0"] = ("IPADDR", self.mgmt.gateway)
295
+ # Single named UNVERIFIED DHCP-mode OID (Task 4) — never a bare
296
+ # ".99.1" literal.
297
+ m[f"{v.dhcp_mode_unverified}.0"] = (
298
+ "INTEGER", "2" if self.mgmt.mode == "static" else "1")
299
+
300
+ return m
301
+
302
+ def snapshot(self) -> VirtualSwitchState:
303
+ """Deep-copy this state, for atomic multi-varbind SET rollback.
304
+
305
+ A single SNMP SET PDU can carry several varbinds (e.g.
306
+ ``set_vlan_membership`` writing both the egress and untagged
307
+ bitmaps in one ``set_many`` call) and a real agent guarantees they
308
+ apply all-or-nothing. ``faces/snmp.py``'s ``write_variables``
309
+ snapshots the state before applying a PDU's varbinds and calls
310
+ ``restore`` on this snapshot if any of them fails, so a partial
311
+ mutation is never observable. See ``restore``.
312
+ """
313
+ return copy.deepcopy(self)
314
+
315
+ def restore(self, snapshot: VirtualSwitchState) -> None:
316
+ """Restore this state in place from a prior ``snapshot()`` result.
317
+
318
+ Copies every dataclass field from ``snapshot`` onto ``self`` rather
319
+ than replacing ``self`` itself, so existing references to this exact
320
+ object (e.g. ``VirtualSwitch.state``, ``StateMibView._state``) keep
321
+ seeing the restored data.
322
+ """
323
+ for f in dataclasses.fields(self):
324
+ setattr(self, f.name, getattr(snapshot, f.name))
325
+
326
+ def apply_write(self, oid: str, value: int | bytes | str) -> None:
327
+ """Mutate this state from one SNMP SET varbind, with device coherence.
328
+
329
+ Dispatches on the OID's column prefix. Applies the same coherence a real
330
+ PoE switch shows so ``cycle_poe`` terminates against the mock: admin off
331
+ -> detect=1 (unused) + data-port link down; admin on -> detect=3
332
+ (delivering). Unhandled writable OIDs are a deliberate no-op (the write
333
+ "succeeds" but reads back unchanged), which is exactly what a
334
+ verify-after-write must catch. (The SNMP face layer additionally
335
+ rejects a SET on an OID ``is_writable_oid`` doesn't recognize at all
336
+ with a proper SNMP error, before it ever reaches here — see
337
+ ``faces/snmp.py``.)
338
+ """
339
+ from ..protocols.snmp import oids
340
+ from ..registry import get_model
341
+
342
+ v = oids.vendor_oids(get_model(self.model_key))
343
+
344
+ def _tail(base: str) -> int | None:
345
+ prefix = base + "."
346
+ if oid.startswith(prefix) and oid[len(prefix):].isdigit():
347
+ return int(oid[len(prefix):])
348
+ return None
349
+
350
+ def _as_bytes(val: int | bytes | str) -> bytes:
351
+ if isinstance(val, bytes):
352
+ return val
353
+ if isinstance(val, str):
354
+ return val.encode("latin-1")
355
+ return bytes([val])
356
+
357
+ # ifAdminStatus.<port>
358
+ port = _tail(oids.IF_ADMIN_STATUS)
359
+ if port is not None and port in self.ports:
360
+ self.ports[port].admin = int(value) == 1
361
+ if int(value) != 1:
362
+ self.ports[port].link = False
363
+ return
364
+
365
+ # pethPsePortAdminEnable = <table>.3.1.<port>
366
+ poe_prefix = f"{oids.PETH_PSE_PORT_TABLE}.3.1."
367
+ if oid.startswith(poe_prefix) and oid[len(poe_prefix):].isdigit():
368
+ p = int(oid[len(poe_prefix):])
369
+ if p in self.poe:
370
+ on = int(value) == 1
371
+ self.poe[p].admin = on
372
+ self.poe[p].detect = 3 if on else 1 # delivering / unused
373
+ if not on and p in self.ports:
374
+ self.ports[p].link = False
375
+ return
376
+
377
+ # dot1qPvid.<port>
378
+ port = _tail(oids.DOT1Q_PVID)
379
+ if port is not None:
380
+ self.pvids[port] = int(value)
381
+ return
382
+
383
+ # dot1qVlanStaticEgressPorts.<vid>
384
+ vid = _tail(oids.DOT1Q_VLAN_STATIC_EGRESS)
385
+ if vid is not None and vid in self.vlans:
386
+ from ..protocols.snmp.parse import decode_port_bitmap
387
+ self.vlans[vid].member = set(decode_port_bitmap(_as_bytes(value)))
388
+ return
389
+
390
+ # dot1qVlanStaticUntaggedPorts.<vid>
391
+ vid = _tail(oids.DOT1Q_VLAN_STATIC_UNTAGGED)
392
+ if vid is not None and vid in self.vlans:
393
+ from ..protocols.snmp.parse import decode_port_bitmap
394
+ self.vlans[vid].untagged = set(decode_port_bitmap(_as_bytes(value)))
395
+ return
396
+
397
+ # dot1qVlanStaticRowStatus.<vid> (createAndGo=4 / destroy=6)
398
+ vid = _tail(oids.DOT1Q_VLAN_STATIC_ROW_STATUS)
399
+ if vid is not None:
400
+ if int(value) == oids.ROW_STATUS_DESTROY:
401
+ self.vlans.pop(vid, None)
402
+ elif int(value) == oids.ROW_STATUS_CREATE_AND_GO and vid not in self.vlans:
403
+ self.vlans[vid] = VlanSim(name="")
404
+ return
405
+
406
+ # dot1qVlanStaticName.<vid>
407
+ vid = _tail(oids.DOT1Q_VLAN_STATIC_NAME)
408
+ if vid is not None:
409
+ name = value.decode("latin-1") if isinstance(value, bytes) else str(value)
410
+ if vid in self.vlans:
411
+ self.vlans[vid].name = name
412
+ else:
413
+ self.vlans[vid] = VlanSim(name=name)
414
+ return
415
+
416
+ # UNVERIFIED mgmt-IP write OIDs -> MgmtSim (read projection follows).
417
+ if oid == v.mgmt_write_addr_unverified:
418
+ self.mgmt.address = str(value)
419
+ return
420
+ if oid == v.mgmt_write_netmask_unverified:
421
+ self.mgmt.netmask = str(value)
422
+ return
423
+ if oid == v.mgmt_write_gateway_unverified:
424
+ self.mgmt.gateway = str(value)
425
+ return
426
+
427
+ # UNVERIFIED dhcp-mode write OID (same scalar the read projection
428
+ # advertises, mirroring the mgmt-write precedent above): 2=static,
429
+ # anything else=dhcp, matching oid_map()'s own encoding exactly.
430
+ if oid == f"{v.dhcp_mode_unverified}.0":
431
+ self.mgmt.mode = "static" if int(value) == 2 else "dhcp"
432
+ return
433
+
434
+ # Unhandled writable OID: deliberate no-op (verify-after-write catches it).
435
+
436
+ def nsdp_tlvs(self, tags: set[Tag]) -> list[TLVEntry]:
437
+ """Project this state onto NSDP read TLVs for the requested tags.
438
+
439
+ MODEL / MAC / PORT_COUNT identity is always included (a real Plus switch
440
+ echoes it, and ``parse_device`` needs the model + a port count to size
441
+ VLAN bitmaps). Only tags this mock knows are emitted; unknown requested
442
+ tags are silently skipped, exactly as real hardware does.
443
+ """
444
+ import socket
445
+
446
+ from ..protocols.nsdp.protocol import Tag, TLVEntry
447
+
448
+ model = get_model(self.model_key)
449
+ port_count = model.port_count
450
+ width = (port_count + 7) // 8
451
+ model_bytes = (self.model_name or model.display_name).encode("ascii")
452
+ out: list[TLVEntry] = [
453
+ TLVEntry(Tag.MODEL, model_bytes),
454
+ TLVEntry(Tag.MAC, self.nsdp_mac),
455
+ TLVEntry(Tag.PORT_COUNT, bytes([port_count])),
456
+ ]
457
+ if Tag.SERIAL_NUMBER in tags and self.serial:
458
+ serial_bytes = b"\x01" + self.serial.encode("ascii")
459
+ out.append(TLVEntry(Tag.SERIAL_NUMBER, serial_bytes))
460
+ if Tag.HOSTNAME in tags and self.hostname:
461
+ out.append(TLVEntry(Tag.HOSTNAME, self.hostname.encode("ascii")))
462
+ if Tag.FIRMWARE_VER_1 in tags and self.firmware:
463
+ out.append(TLVEntry(Tag.FIRMWARE_VER_1, self.firmware.encode("ascii")))
464
+ if Tag.PORT_STATUS in tags:
465
+ for port, sim in sorted(self.ports.items()):
466
+ speed_byte = _mbps_to_speed_byte(sim.speed) if sim.link else 0x00
467
+ out.append(TLVEntry(Tag.PORT_STATUS, bytes([port, speed_byte, 0x01])))
468
+ if Tag.PORT_STATISTICS in tags:
469
+ for port, sim in sorted(self.ports.items()):
470
+ if sim.rx_octets is None:
471
+ continue
472
+ out.append(
473
+ TLVEntry(
474
+ Tag.PORT_STATISTICS,
475
+ bytes([port])
476
+ + struct.pack(">Q", sim.rx_octets or 0)
477
+ + struct.pack(">Q", sim.tx_octets or 0)
478
+ + struct.pack(">Q", sim.rx_errors or 0)
479
+ + b"\x00" * 24,
480
+ )
481
+ )
482
+ if Tag.VLAN_MEMBERS in tags:
483
+ from ..protocols.nsdp.parsers import ports_to_bitmap
484
+ for vid, vsim in sorted(self.vlans.items()):
485
+ tagged = vsim.member - vsim.untagged
486
+ out.append(
487
+ TLVEntry(
488
+ Tag.VLAN_MEMBERS,
489
+ struct.pack(">H", vid)
490
+ + ports_to_bitmap(vsim.member, width)
491
+ + ports_to_bitmap(tagged, width),
492
+ )
493
+ )
494
+ if Tag.PORT_PVID in tags:
495
+ for port, pv in sorted(self.pvids.items()):
496
+ pvid_bytes = bytes([port]) + struct.pack(">H", pv)
497
+ out.append(TLVEntry(Tag.PORT_PVID, pvid_bytes))
498
+ if Tag.IP_ADDRESS in tags:
499
+ out.append(TLVEntry(Tag.IP_ADDRESS, socket.inet_aton(self.mgmt.address)))
500
+ if Tag.NETMASK in tags:
501
+ out.append(TLVEntry(Tag.NETMASK, socket.inet_aton(self.mgmt.netmask)))
502
+ if Tag.GATEWAY in tags:
503
+ out.append(TLVEntry(Tag.GATEWAY, socket.inet_aton(self.mgmt.gateway)))
504
+ if Tag.DHCP_MODE in tags:
505
+ dhcp_byte = b"\x00" if self.mgmt.mode == "static" else b"\x01"
506
+ out.append(TLVEntry(Tag.DHCP_MODE, dhcp_byte))
507
+ if Tag.QOS_ENGINE in tags and self.nsdp_qos_engine is not None:
508
+ out.append(TLVEntry(Tag.QOS_ENGINE, bytes([self.nsdp_qos_engine])))
509
+ if Tag.PORT_MIRRORING in tags and self.nsdp_port_mirroring_dest is not None:
510
+ from ..protocols.nsdp.parsers import ports_to_bitmap
511
+ out.append(
512
+ TLVEntry(
513
+ Tag.PORT_MIRRORING,
514
+ bytes([self.nsdp_port_mirroring_dest])
515
+ + ports_to_bitmap(self.nsdp_port_mirroring_sources, 3),
516
+ )
517
+ )
518
+ if Tag.IGMP_SNOOPING in tags and self.nsdp_igmp_snooping_enabled is not None:
519
+ vlan_byte = self.nsdp_igmp_snooping_vlan or 0
520
+ out.append(
521
+ TLVEntry(
522
+ Tag.IGMP_SNOOPING,
523
+ bytes([0x00, 1 if self.nsdp_igmp_snooping_enabled else 0,
524
+ 0x00, vlan_byte]),
525
+ )
526
+ )
527
+ if (Tag.BROADCAST_FILTERING in tags
528
+ and self.nsdp_broadcast_filtering is not None):
529
+ out.append(
530
+ TLVEntry(
531
+ Tag.BROADCAST_FILTERING,
532
+ bytes([1 if self.nsdp_broadcast_filtering else 0]),
533
+ )
534
+ )
535
+ if Tag.LOOP_DETECTION in tags and self.nsdp_loop_detection is not None:
536
+ out.append(
537
+ TLVEntry(
538
+ Tag.LOOP_DETECTION,
539
+ bytes([1 if self.nsdp_loop_detection else 0]),
540
+ )
541
+ )
542
+ return out
543
+
544
+ def apply_nsdp_write(self, tag: Tag | int, value: bytes) -> None:
545
+ """Mutate this state from one NSDP write TLV (verify-after-write reads it
546
+ back). Unknown/read-only tags are a deliberate no-op."""
547
+ import socket
548
+
549
+ from ..protocols.nsdp.parsers import parse_vlan_members
550
+ from ..protocols.nsdp.protocol import Tag
551
+
552
+ model = get_model(self.model_key)
553
+ if tag == Tag.PORT_PVID:
554
+ self.pvids[value[0]] = struct.unpack_from(">H", value, 1)[0]
555
+ elif tag == Tag.VLAN_MEMBERS:
556
+ m = parse_vlan_members(value, model.port_count)
557
+ existing = self.vlans.get(m.vlan_id)
558
+ name = existing.name if existing is not None else ""
559
+ self.vlans[m.vlan_id] = VlanSim(
560
+ name=name,
561
+ member=set(m.member_ports),
562
+ untagged=set(m.untagged_ports),
563
+ )
564
+ elif tag == Tag.IP_ADDRESS:
565
+ self.mgmt.address = socket.inet_ntoa(value)
566
+ elif tag == Tag.NETMASK:
567
+ self.mgmt.netmask = socket.inet_ntoa(value)
568
+ elif tag == Tag.GATEWAY:
569
+ self.mgmt.gateway = socket.inet_ntoa(value)
570
+ elif tag == Tag.DHCP_MODE:
571
+ self.mgmt.mode = "dhcp" if value[:1] == b"\x01" else "static"
572
+ # REBOOT / FACTORY_RESET / unknown: deliberate no-op.
573
+
574
+ def is_writable_oid(self, oid: str) -> bool:
575
+ """True if ``oid`` is one this mock recognizes as SNMP-writable.
576
+
577
+ Mirrors ``apply_write``'s dispatch prefixes on purpose (single set of
578
+ column constants from ``protocols.snmp.oids``, kept in sync
579
+ deliberately) so the SNMP face (``faces/snmp.py``) can reject a SET on
580
+ a genuinely unknown/read-only OID with a proper SNMP error
581
+ (notWritable) instead of the always-succeeding no-op ``apply_write``
582
+ itself deliberately allows for a recognized-but-absent instance (e.g.
583
+ creating a not-yet-existing VLAN row).
584
+ """
585
+ from ..protocols.snmp import oids
586
+ from ..registry import get_model
587
+
588
+ v = oids.vendor_oids(get_model(self.model_key))
589
+
590
+ def _is_col(base: str) -> bool:
591
+ prefix = base + "."
592
+ return oid.startswith(prefix) and oid[len(prefix):].isdigit()
593
+
594
+ if _is_col(oids.IF_ADMIN_STATUS):
595
+ return True
596
+ poe_prefix = f"{oids.PETH_PSE_PORT_TABLE}.3.1."
597
+ if oid.startswith(poe_prefix) and oid[len(poe_prefix):].isdigit():
598
+ return True
599
+ if _is_col(oids.DOT1Q_PVID):
600
+ return True
601
+ if _is_col(oids.DOT1Q_VLAN_STATIC_EGRESS):
602
+ return True
603
+ if _is_col(oids.DOT1Q_VLAN_STATIC_UNTAGGED):
604
+ return True
605
+ if _is_col(oids.DOT1Q_VLAN_STATIC_ROW_STATUS):
606
+ return True
607
+ if _is_col(oids.DOT1Q_VLAN_STATIC_NAME):
608
+ return True
609
+ if oid in (
610
+ v.mgmt_write_addr_unverified,
611
+ v.mgmt_write_netmask_unverified,
612
+ v.mgmt_write_gateway_unverified,
613
+ ):
614
+ return True
615
+ return oid == f"{v.dhcp_mode_unverified}.0"