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,221 @@
1
+ """Model-driven NSDP read operations over a sync or async client.
2
+
3
+ Parallel to ``snmp_read.py``. Maps NSDP TLVs onto the SAME public ``models``
4
+ types. NSDP genuinely exposes only port link/speed, byte/CRC statistics, VLAN
5
+ membership, PVID and management IP on these Plus switches; MAC/FDB, LLDP,
6
+ sensors and PoE are not in the protocol, so those ops raise
7
+ ``UnsupportedCapabilityError`` rather than fabricating empty results.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from .errors import UnsupportedCapabilityError
14
+ from .models import IpMode, MgmtIpConfig, PortStats, PortStatus, VLANInfo
15
+ from .protocols.nsdp.parsers import parse_device
16
+ from .protocols.nsdp.protocol import Tag
17
+ from .protocols.nsdp.types import LinkSpeed
18
+ from .registry import Backend
19
+
20
+ if TYPE_CHECKING:
21
+ from .models import LLDPNeighbor, MacEntry, PoEStatus, Sensor
22
+ from .protocols.nsdp.client import AsyncNsdpClient, NsdpClient
23
+ from .protocols.nsdp.types import NsdpDevice
24
+ from .registry import SwitchModel
25
+
26
+ _NO_MACS = "NSDP exposes no MAC/FDB table (Plus switches have no remote FDB)"
27
+ _NO_LLDP = "NSDP exposes no LLDP neighbours on these Plus switches"
28
+ _NO_SENSORS = "NSDP exposes no environmental sensors on these Plus switches"
29
+ _NO_POE = "NSDP exposes no PoE status; use the HTTP backend (Slice 6) for PoE"
30
+
31
+ # Every read-tag ``parse_device`` knows how to decode, requested together so
32
+ # ``get_device()``/``nsdp_device()`` returns the COMPLETE NsdpDevice in one
33
+ # round trip -- identity, mgmt IP, per-port status/stats, VLANs/PVIDs, and the
34
+ # QoS/mirroring/IGMP/broadcast-filtering/loop-detection tags (see parsers.py).
35
+ _FULL_DEVICE_TAGS = [
36
+ Tag.MODEL,
37
+ Tag.MAC,
38
+ Tag.HOSTNAME,
39
+ Tag.IP_ADDRESS,
40
+ Tag.NETMASK,
41
+ Tag.GATEWAY,
42
+ Tag.FIRMWARE_VER_1,
43
+ Tag.DHCP_MODE,
44
+ Tag.PORT_COUNT,
45
+ Tag.SERIAL_NUMBER,
46
+ Tag.VLAN_ENGINE,
47
+ Tag.PORT_STATUS,
48
+ Tag.PORT_STATISTICS,
49
+ Tag.VLAN_MEMBERS,
50
+ Tag.PORT_PVID,
51
+ Tag.QOS_ENGINE,
52
+ Tag.PORT_MIRRORING,
53
+ Tag.IGMP_SNOOPING,
54
+ Tag.BROADCAST_FILTERING,
55
+ Tag.LOOP_DETECTION,
56
+ ]
57
+
58
+
59
+ def _require_nsdp(model: SwitchModel) -> None:
60
+ if Backend.NSDP not in model.backends:
61
+ raise UnsupportedCapabilityError(f"model {model.key!r} has no NSDP backend")
62
+
63
+
64
+ def _ports(dev: NsdpDevice) -> list[PortStatus]:
65
+ return [
66
+ PortStatus(
67
+ port=s.port_id,
68
+ name=None, # NSDP PORT_STATUS carries no port name
69
+ # NSDP PORT_STATUS reports link speed only; it cannot distinguish an
70
+ # admin-disabled port from a link-down one, so admin_enabled is
71
+ # reported True (the honest "not administratively removed" default).
72
+ admin_enabled=True,
73
+ link_up=s.speed is not LinkSpeed.DOWN,
74
+ speed_mbps=s.speed.speed_mbps or None,
75
+ )
76
+ for s in dev.port_status
77
+ ]
78
+
79
+
80
+ def _stats(dev: NsdpDevice) -> list[PortStats]:
81
+ return [
82
+ PortStats(
83
+ port=s.port_id,
84
+ rx_bytes=s.bytes_received,
85
+ tx_bytes=s.bytes_sent,
86
+ rx_packets=None, # NSDP PORT_STATISTICS has no packet counters
87
+ tx_packets=None,
88
+ rx_errors=s.crc_errors,
89
+ tx_errors=None,
90
+ )
91
+ for s in dev.port_statistics
92
+ ]
93
+
94
+
95
+ def _vlans(dev: NsdpDevice) -> list[VLANInfo]:
96
+ return [
97
+ VLANInfo(
98
+ vlan_id=m.vlan_id,
99
+ name=None, # NSDP VLAN_MEMBERS carries no VLAN name
100
+ member_ports=m.member_ports,
101
+ tagged_ports=m.tagged_ports,
102
+ untagged_ports=m.untagged_ports,
103
+ )
104
+ for m in dev.vlan_members
105
+ ]
106
+
107
+
108
+ def _mgmt(dev: NsdpDevice) -> MgmtIpConfig:
109
+ if dev.dhcp_enabled is None:
110
+ mode = IpMode.UNKNOWN
111
+ else:
112
+ mode = IpMode.DHCP if dev.dhcp_enabled else IpMode.STATIC
113
+ return MgmtIpConfig(
114
+ mode=mode, address=dev.ip, netmask=dev.netmask, gateway=dev.gateway,
115
+ # NSDP always echoes the device MAC (Tag.MAC, with a server_mac
116
+ # fallback -- see parse_device), so this is honestly always
117
+ # populated, never a guess. Uppercased to match the SNMP-backend
118
+ # formatting (parse.parse_base_mac / _format_mac_bytes) so the public
119
+ # field has one consistent case across backends.
120
+ base_mac=dev.mac.upper(),
121
+ )
122
+
123
+
124
+ class NsdpReader:
125
+ """Synchronous NSDP read facade over one switch."""
126
+
127
+ def __init__(self, client: NsdpClient, model: SwitchModel) -> None:
128
+ _require_nsdp(model)
129
+ self.client = client
130
+ self.model = model
131
+
132
+ def _device(self, tags: list[Tag]) -> NsdpDevice:
133
+ return parse_device(self.client.read(tags))
134
+
135
+ def get_ports(self) -> list[PortStatus]:
136
+ return _ports(self._device([Tag.PORT_COUNT, Tag.PORT_STATUS]))
137
+
138
+ def get_stats(self) -> list[PortStats]:
139
+ return _stats(self._device([Tag.PORT_STATISTICS]))
140
+
141
+ def get_vlans(self) -> list[VLANInfo]:
142
+ return _vlans(self._device([Tag.PORT_COUNT, Tag.VLAN_MEMBERS]))
143
+
144
+ def get_pvids(self) -> list[tuple[int, int]]:
145
+ dev = self._device([Tag.PORT_PVID])
146
+ return [(p.port_id, p.vlan_id) for p in dev.port_pvids]
147
+
148
+ def get_mgmt_ip(self) -> MgmtIpConfig:
149
+ return _mgmt(
150
+ self._device([Tag.IP_ADDRESS, Tag.NETMASK, Tag.GATEWAY, Tag.DHCP_MODE])
151
+ )
152
+
153
+ def get_device(self) -> NsdpDevice:
154
+ """Return the COMPLETE raw ``NsdpDevice`` for this switch: every tag
155
+ ``parse_device`` knows how to decode, in one round trip. Unlike the
156
+ other ``get_*`` ops above, this returns the NSDP-native shape
157
+ (including the raw port-status speed byte) rather than mapping onto
158
+ the shared ``models`` types -- callers that need the full protocol
159
+ surface (e.g. gdoc2netcfg's DiscoveryDB) use this instead of the
160
+ per-field ops."""
161
+ return self._device(_FULL_DEVICE_TAGS)
162
+
163
+ def get_macs(self) -> list[MacEntry]:
164
+ raise UnsupportedCapabilityError(_NO_MACS)
165
+
166
+ def get_lldp(self) -> list[LLDPNeighbor]:
167
+ raise UnsupportedCapabilityError(_NO_LLDP)
168
+
169
+ def get_sensors(self) -> list[Sensor]:
170
+ raise UnsupportedCapabilityError(_NO_SENSORS)
171
+
172
+ def get_poe(self) -> list[PoEStatus]:
173
+ raise UnsupportedCapabilityError(_NO_POE)
174
+
175
+
176
+ class AsyncNsdpReader:
177
+ """Asynchronous NSDP read facade (mirror of NsdpReader)."""
178
+
179
+ def __init__(self, client: AsyncNsdpClient, model: SwitchModel) -> None:
180
+ _require_nsdp(model)
181
+ self.client = client
182
+ self.model = model
183
+
184
+ async def _device(self, tags: list[Tag]) -> NsdpDevice:
185
+ return parse_device(await self.client.read(tags))
186
+
187
+ async def get_ports(self) -> list[PortStatus]:
188
+ return _ports(await self._device([Tag.PORT_COUNT, Tag.PORT_STATUS]))
189
+
190
+ async def get_stats(self) -> list[PortStats]:
191
+ return _stats(await self._device([Tag.PORT_STATISTICS]))
192
+
193
+ async def get_vlans(self) -> list[VLANInfo]:
194
+ return _vlans(await self._device([Tag.PORT_COUNT, Tag.VLAN_MEMBERS]))
195
+
196
+ async def get_pvids(self) -> list[tuple[int, int]]:
197
+ dev = await self._device([Tag.PORT_PVID])
198
+ return [(p.port_id, p.vlan_id) for p in dev.port_pvids]
199
+
200
+ async def get_mgmt_ip(self) -> MgmtIpConfig:
201
+ return _mgmt(
202
+ await self._device(
203
+ [Tag.IP_ADDRESS, Tag.NETMASK, Tag.GATEWAY, Tag.DHCP_MODE]
204
+ )
205
+ )
206
+
207
+ async def get_device(self) -> NsdpDevice:
208
+ """Async twin of ``NsdpReader.get_device`` -- see there."""
209
+ return await self._device(_FULL_DEVICE_TAGS)
210
+
211
+ async def get_macs(self) -> list[MacEntry]:
212
+ raise UnsupportedCapabilityError(_NO_MACS)
213
+
214
+ async def get_lldp(self) -> list[LLDPNeighbor]:
215
+ raise UnsupportedCapabilityError(_NO_LLDP)
216
+
217
+ async def get_sensors(self) -> list[Sensor]:
218
+ raise UnsupportedCapabilityError(_NO_SENSORS)
219
+
220
+ async def get_poe(self) -> list[PoEStatus]:
221
+ raise UnsupportedCapabilityError(_NO_POE)
@@ -0,0 +1,315 @@
1
+ """Model-driven NSDP write/control over a write-capable sync or async client.
2
+
3
+ Parallel to ``snmp_write.py``. Every write performs the NSDP WRITE_REQUEST
4
+ (v1-authenticated) then re-reads to verify (``WriteVerificationError`` with
5
+ before/after on mismatch; a bad password / transport error surfaces as
6
+ ``NsdpError`` from the client first). Disruptive per-port writes to a
7
+ ``protected_ports`` port are refused unless ``force=True``. Writes NSDP has no
8
+ tag for (PoE, per-port admin, VLAN create/delete) raise
9
+ ``UnsupportedCapabilityError`` — never a silent no-op.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING
14
+
15
+ from .errors import (
16
+ ProtectedPortError,
17
+ UnsupportedCapabilityError,
18
+ WriteVerificationError,
19
+ )
20
+ from .models import VlanMode
21
+ from .nsdp_read import AsyncNsdpReader, NsdpReader
22
+ from .protocols.nsdp.protocol import Tag
23
+ from .protocols.nsdp.write import ipv4_tlv, pvid_tlv, vlan_members_tlv
24
+ from .registry import Backend
25
+
26
+ if TYPE_CHECKING:
27
+ from .models import VLANInfo
28
+ from .protocols.nsdp.client import AsyncNsdpWriteClient, NsdpWriteClient
29
+ from .registry import SwitchModel
30
+ from .snmp_write import PoeCycleTimeouts
31
+
32
+ _NO_POE = "NSDP has no PoE control tag; use the HTTP backend (Slice 6) for PoE"
33
+ _NO_PORT_ADMIN = (
34
+ "no per-port admin-enable is available on these Plus models: NSDP has no "
35
+ "admin-enable tag, and the web UI has no grounded port-enable endpoint "
36
+ "(UNVERIFIED-pending-capture)"
37
+ )
38
+ _NO_VLAN_LIFECYCLE = (
39
+ "NSDP has no VLAN create/destroy tag on these Plus models; only VLAN "
40
+ "membership/PVID are writable over NSDP"
41
+ )
42
+
43
+
44
+ def _require_nsdp(model: SwitchModel) -> None:
45
+ if Backend.NSDP not in model.backends:
46
+ raise UnsupportedCapabilityError(f"model {model.key!r} has no NSDP backend")
47
+
48
+
49
+ def _members_after(
50
+ current: VLANInfo | None, port: int, mode: VlanMode
51
+ ) -> tuple[set[int], set[int]]:
52
+ """Read-modify-write the (members, tagged) sets for one membership change."""
53
+ members = set(current.member_ports) if current is not None else set()
54
+ tagged = set(current.tagged_ports) if current is not None else set()
55
+ if mode is VlanMode.EXCLUDED:
56
+ members.discard(port)
57
+ tagged.discard(port)
58
+ else:
59
+ members.add(port)
60
+ tagged.add(port) if mode is VlanMode.TAGGED else tagged.discard(port)
61
+ return members, tagged
62
+
63
+
64
+ def _membership_ok(after: VLANInfo | None, port: int, mode: VlanMode) -> bool:
65
+ if after is None:
66
+ return mode is VlanMode.EXCLUDED
67
+ in_members = port in after.member_ports
68
+ in_tagged = port in after.tagged_ports
69
+ if mode is VlanMode.EXCLUDED:
70
+ return not in_members
71
+ return in_members and (in_tagged == (mode is VlanMode.TAGGED))
72
+
73
+
74
+ class NsdpWriter:
75
+ """Synchronous NSDP write facade over one switch."""
76
+
77
+ def __init__(
78
+ self,
79
+ client: NsdpWriteClient,
80
+ model: SwitchModel,
81
+ *,
82
+ password: str,
83
+ protected_ports: frozenset[int] = frozenset(),
84
+ ) -> None:
85
+ _require_nsdp(model)
86
+ self.client = client
87
+ self.model = model
88
+ self._password = password
89
+ self.protected_ports = protected_ports
90
+ self._reader = NsdpReader(client, model)
91
+
92
+ def _guard(self, port: int, force: bool) -> None:
93
+ if port in self.protected_ports and not force:
94
+ raise ProtectedPortError(
95
+ f"port {port} is protected; pass force=True to override"
96
+ )
97
+
98
+ def _vlan(self, vlan: int) -> VLANInfo | None:
99
+ return next((v for v in self._reader.get_vlans() if v.vlan_id == vlan), None)
100
+
101
+ def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None:
102
+ # UNVERIFIED pending a hardware capture: PORT_PVID (0x3000) is documented
103
+ # READ-ONLY in the reference spec, so a real switch may reject this write
104
+ # (verify-after-write below is the guard). Same house style as
105
+ # snmp_write.py:set_mgmt_ip's unverified-OID note.
106
+ self._guard(port, force)
107
+ before = dict(self._reader.get_pvids())
108
+ self.client.write([pvid_tlv(port, vlan)], password=self._password)
109
+ after = dict(self._reader.get_pvids())
110
+ if after.get(port) != vlan:
111
+ raise WriteVerificationError(
112
+ f"PVID for port {port} did not read back as {vlan}",
113
+ before=before.get(port), after=after.get(port),
114
+ )
115
+
116
+ def set_vlan_membership(
117
+ self, vlan: int, port: int, mode: VlanMode, *, force: bool = False
118
+ ) -> None:
119
+ # UNVERIFIED pending a hardware capture: VLAN_MEMBERS (0x2800) is
120
+ # documented READ-ONLY in the reference spec, so a real switch may reject
121
+ # this write (verify-after-write below is the guard). Same house style as
122
+ # snmp_write.py:set_mgmt_ip's unverified-OID note.
123
+ self._guard(port, force)
124
+ before = self._vlan(vlan)
125
+ members, tagged = _members_after(before, port, mode)
126
+ self.client.write(
127
+ [vlan_members_tlv(vlan, members, tagged, self.model.port_count)],
128
+ password=self._password,
129
+ )
130
+ after = self._vlan(vlan)
131
+ if not _membership_ok(after, port, mode):
132
+ raise WriteVerificationError(
133
+ f"VLAN {vlan} membership for port {port} did not read back as {mode}",
134
+ before=before, after=after,
135
+ )
136
+
137
+ def set_mgmt_ip(
138
+ self, address: str, netmask: str, gateway: str, *, force: bool = False
139
+ ) -> None:
140
+ # Force-gated: a wrong management-IP write can strand the switch.
141
+ # UNVERIFIED pending a hardware capture: the NSDP write path + v1 auth are
142
+ # unconfirmed against real hardware (verify-after-write below is the
143
+ # guard), same house style as snmp_write.py:set_mgmt_ip. (IP/netmask/
144
+ # gateway are R/W in the reference spec, unlike PVID/VLAN_MEMBERS.)
145
+ if not force:
146
+ raise ProtectedPortError(
147
+ "set_mgmt_ip can strand the switch; pass force=True to override"
148
+ )
149
+ before = self._reader.get_mgmt_ip()
150
+ self.client.write(
151
+ [
152
+ ipv4_tlv(Tag.IP_ADDRESS, address),
153
+ ipv4_tlv(Tag.NETMASK, netmask),
154
+ ipv4_tlv(Tag.GATEWAY, gateway),
155
+ ],
156
+ password=self._password,
157
+ )
158
+ after = self._reader.get_mgmt_ip()
159
+ if (after.address, after.netmask, after.gateway) != (address, netmask, gateway):
160
+ raise WriteVerificationError(
161
+ "management IP did not read back as written",
162
+ before=before, after=after,
163
+ )
164
+
165
+ def set_poe(self, port: int, on: bool, *, force: bool = False) -> None:
166
+ raise UnsupportedCapabilityError(_NO_POE)
167
+
168
+ def cycle_poe(
169
+ self, port: int, *, force: bool = False,
170
+ timeouts: PoeCycleTimeouts | None = None,
171
+ ) -> None:
172
+ # timeouts accepted-but-unused: matches SnmpWriter's signature so the
173
+ # facade's SnmpWriter | NsdpWriter union call site typechecks; NSDP has
174
+ # no PoE control tag at all, so there is nothing to time.
175
+ raise UnsupportedCapabilityError(_NO_POE)
176
+
177
+ def clear_poe_fault(
178
+ self, port: int, *, force: bool = False,
179
+ timeouts: PoeCycleTimeouts | None = None,
180
+ ) -> None:
181
+ raise UnsupportedCapabilityError(_NO_POE)
182
+
183
+ def set_port_enabled(
184
+ self, port: int, enabled: bool, *, force: bool = False
185
+ ) -> None:
186
+ raise UnsupportedCapabilityError(_NO_PORT_ADMIN)
187
+
188
+ def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
189
+ raise UnsupportedCapabilityError(_NO_VLAN_LIFECYCLE)
190
+
191
+ def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
192
+ raise UnsupportedCapabilityError(_NO_VLAN_LIFECYCLE)
193
+
194
+
195
+ class AsyncNsdpWriter:
196
+ """Asynchronous NSDP write facade (mirror of NsdpWriter)."""
197
+
198
+ def __init__(
199
+ self,
200
+ client: AsyncNsdpWriteClient,
201
+ model: SwitchModel,
202
+ *,
203
+ password: str,
204
+ protected_ports: frozenset[int] = frozenset(),
205
+ ) -> None:
206
+ _require_nsdp(model)
207
+ self.client = client
208
+ self.model = model
209
+ self._password = password
210
+ self.protected_ports = protected_ports
211
+ self._reader = AsyncNsdpReader(client, model)
212
+
213
+ def _guard(self, port: int, force: bool) -> None:
214
+ if port in self.protected_ports and not force:
215
+ raise ProtectedPortError(
216
+ f"port {port} is protected; pass force=True to override"
217
+ )
218
+
219
+ async def _vlan(self, vlan: int) -> VLANInfo | None:
220
+ vlans = await self._reader.get_vlans()
221
+ return next((v for v in vlans if v.vlan_id == vlan), None)
222
+
223
+ async def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None:
224
+ # UNVERIFIED pending a hardware capture: PORT_PVID (0x3000) is documented
225
+ # READ-ONLY in the reference spec, so a real switch may reject this write
226
+ # (verify-after-write below is the guard). Same house style as
227
+ # snmp_write.py:set_mgmt_ip's unverified-OID note.
228
+ self._guard(port, force)
229
+ before = dict(await self._reader.get_pvids())
230
+ await self.client.write([pvid_tlv(port, vlan)], password=self._password)
231
+ after = dict(await self._reader.get_pvids())
232
+ if after.get(port) != vlan:
233
+ raise WriteVerificationError(
234
+ f"PVID for port {port} did not read back as {vlan}",
235
+ before=before.get(port), after=after.get(port),
236
+ )
237
+
238
+ async def set_vlan_membership(
239
+ self, vlan: int, port: int, mode: VlanMode, *, force: bool = False
240
+ ) -> None:
241
+ # UNVERIFIED pending a hardware capture: VLAN_MEMBERS (0x2800) is
242
+ # documented READ-ONLY in the reference spec, so a real switch may reject
243
+ # this write (verify-after-write below is the guard). Same house style as
244
+ # snmp_write.py:set_mgmt_ip's unverified-OID note.
245
+ self._guard(port, force)
246
+ before = await self._vlan(vlan)
247
+ members, tagged = _members_after(before, port, mode)
248
+ await self.client.write(
249
+ [vlan_members_tlv(vlan, members, tagged, self.model.port_count)],
250
+ password=self._password,
251
+ )
252
+ after = await self._vlan(vlan)
253
+ if not _membership_ok(after, port, mode):
254
+ raise WriteVerificationError(
255
+ f"VLAN {vlan} membership for port {port} did not read back as {mode}",
256
+ before=before, after=after,
257
+ )
258
+
259
+ async def set_mgmt_ip(
260
+ self, address: str, netmask: str, gateway: str, *, force: bool = False
261
+ ) -> None:
262
+ # Force-gated: a wrong management-IP write can strand the switch.
263
+ # UNVERIFIED pending a hardware capture: the NSDP write path + v1 auth are
264
+ # unconfirmed against real hardware (verify-after-write below is the
265
+ # guard), same house style as snmp_write.py:set_mgmt_ip. (IP/netmask/
266
+ # gateway are R/W in the reference spec, unlike PVID/VLAN_MEMBERS.)
267
+ if not force:
268
+ raise ProtectedPortError(
269
+ "set_mgmt_ip can strand the switch; pass force=True to override"
270
+ )
271
+ before = await self._reader.get_mgmt_ip()
272
+ await self.client.write(
273
+ [
274
+ ipv4_tlv(Tag.IP_ADDRESS, address),
275
+ ipv4_tlv(Tag.NETMASK, netmask),
276
+ ipv4_tlv(Tag.GATEWAY, gateway),
277
+ ],
278
+ password=self._password,
279
+ )
280
+ after = await self._reader.get_mgmt_ip()
281
+ if (after.address, after.netmask, after.gateway) != (address, netmask, gateway):
282
+ raise WriteVerificationError(
283
+ "management IP did not read back as written",
284
+ before=before, after=after,
285
+ )
286
+
287
+ async def set_poe(self, port: int, on: bool, *, force: bool = False) -> None:
288
+ raise UnsupportedCapabilityError(_NO_POE)
289
+
290
+ async def cycle_poe(
291
+ self, port: int, *, force: bool = False,
292
+ timeouts: PoeCycleTimeouts | None = None,
293
+ ) -> None:
294
+ # timeouts accepted-but-unused: matches AsyncSnmpWriter's signature so
295
+ # the facade's AsyncSnmpWriter | AsyncNsdpWriter union call site
296
+ # typechecks; NSDP has no PoE control tag at all, so there is nothing
297
+ # to time.
298
+ raise UnsupportedCapabilityError(_NO_POE)
299
+
300
+ async def clear_poe_fault(
301
+ self, port: int, *, force: bool = False,
302
+ timeouts: PoeCycleTimeouts | None = None,
303
+ ) -> None:
304
+ raise UnsupportedCapabilityError(_NO_POE)
305
+
306
+ async def set_port_enabled(
307
+ self, port: int, enabled: bool, *, force: bool = False
308
+ ) -> None:
309
+ raise UnsupportedCapabilityError(_NO_PORT_ADMIN)
310
+
311
+ async def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
312
+ raise UnsupportedCapabilityError(_NO_VLAN_LIFECYCLE)
313
+
314
+ async def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
315
+ raise UnsupportedCapabilityError(_NO_VLAN_LIFECYCLE)
@@ -0,0 +1 @@
1
+ """SNMP protocol logic (pure, I/O-free)."""
@@ -0,0 +1 @@
1
+ """Pure (I/O-free) HTTP web-UI protocol layer: crypto, endpoints, parsers, forms."""
@@ -0,0 +1,29 @@
1
+ """Pure Netgear web-UI login crypto (no I/O).
2
+
3
+ The Plus family authenticates with ``md5(merge(password, rand))`` where
4
+ ``rand`` is a per-page nonce scraped from the login form and ``merge``
5
+ interleaves the two strings character by character. GROUNDED against
6
+ ``rcfiles/bin/netgear-smp-vlan`` and ``py_netgear_plus/netgear_crypt.py``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+
12
+
13
+ def merge(str1: str, str2: str) -> str:
14
+ """Interleave two strings character by character (Netgear login scheme)."""
15
+ out: list[str] = []
16
+ i = j = 0
17
+ while i < len(str1) or j < len(str2):
18
+ if i < len(str1):
19
+ out.append(str1[i])
20
+ i += 1
21
+ if j < len(str2):
22
+ out.append(str2[j])
23
+ j += 1
24
+ return "".join(out)
25
+
26
+
27
+ def merge_hash_md5(password: str, rand: str) -> str:
28
+ """Return ``md5(merge(password, rand))`` as lowercase hex (Plus login hash)."""
29
+ return hashlib.md5(merge(password, rand).encode()).hexdigest()