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,209 @@
1
+ """Per-tag NSDP byte parsers. Lifted from ``gdoc2netcfg/src/nsdp/parsers.py``,
2
+ plus ``ports_to_bitmap`` (the write-path inverse of ``bitmap_to_ports``).
3
+
4
+ Every parser is total over the bytes it accepts and raises ``ValueError`` on a
5
+ wrong length or a bad prefix, so a malformed TLV surfaces early rather than
6
+ producing a silently-wrong value.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import socket
11
+ import struct
12
+ from typing import TYPE_CHECKING
13
+
14
+ from .protocol import Tag
15
+ from .types import (
16
+ LinkSpeed,
17
+ NsdpDevice,
18
+ NsdpIgmpSnooping,
19
+ NsdpPortMirroring,
20
+ NsdpPortPvid,
21
+ NsdpPortStatistics,
22
+ NsdpPortStatus,
23
+ NsdpVlanMembership,
24
+ VLANEngine,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Iterable
29
+
30
+ from .protocol import NSDPPacket
31
+
32
+
33
+ def parse_ipv4(data: bytes) -> str:
34
+ if len(data) != 4:
35
+ raise ValueError(f"IPv4 TLV must be 4 bytes, got {len(data)}")
36
+ return socket.inet_ntoa(data)
37
+
38
+
39
+ def parse_mac(data: bytes) -> str:
40
+ if len(data) != 6:
41
+ raise ValueError(f"MAC TLV must be 6 bytes, got {len(data)}")
42
+ return ":".join(f"{b:02x}" for b in data)
43
+
44
+
45
+ def parse_port_status(data: bytes) -> NsdpPortStatus:
46
+ if len(data) != 3:
47
+ raise ValueError(f"PORT_STATUS TLV must be 3 bytes, got {len(data)}")
48
+ return NsdpPortStatus(port_id=data[0], speed=LinkSpeed.from_byte(data[1]))
49
+
50
+
51
+ def parse_port_statistics(data: bytes) -> NsdpPortStatistics:
52
+ if len(data) != 49:
53
+ raise ValueError(f"PORT_STATISTICS TLV must be 49 bytes, got {len(data)}")
54
+ rx, tx, crc = struct.unpack_from(">QQQ", data, 1)
55
+ return NsdpPortStatistics(
56
+ port_id=data[0], bytes_received=rx, bytes_sent=tx, crc_errors=crc
57
+ )
58
+
59
+
60
+ def parse_port_pvid(data: bytes) -> NsdpPortPvid:
61
+ if len(data) != 3:
62
+ raise ValueError(f"PORT_PVID TLV must be 3 bytes, got {len(data)}")
63
+ return NsdpPortPvid(port_id=data[0], vlan_id=struct.unpack_from(">H", data, 1)[0])
64
+
65
+
66
+ def parse_serial(data: bytes) -> str:
67
+ if not data or data[0] != 0x01:
68
+ raise ValueError(f"SERIAL_NUMBER TLV: unexpected prefix byte {data[:1]!r}")
69
+ return data[1:].decode("ascii", errors="replace").rstrip("\x00")
70
+
71
+
72
+ def bitmap_to_ports(bitmap: bytes) -> frozenset[int]:
73
+ """MSB-first, 1-based: byte 0 bit 0x80 = port 1 ... 0x01 = port 8."""
74
+ ports: set[int] = set()
75
+ for byte_idx, byte_val in enumerate(bitmap):
76
+ for bit in range(8):
77
+ if byte_val & (0x80 >> bit):
78
+ ports.add(byte_idx * 8 + bit + 1)
79
+ return frozenset(ports)
80
+
81
+
82
+ def ports_to_bitmap(ports: Iterable[int], width_bytes: int) -> bytes:
83
+ """Inverse of ``bitmap_to_ports`` for the write path (same MSB-first layout)."""
84
+ data = bytearray(width_bytes)
85
+ for p in ports:
86
+ byte_idx, bit = divmod(p - 1, 8)
87
+ while byte_idx >= len(data):
88
+ data.append(0)
89
+ data[byte_idx] |= 0x80 >> bit
90
+ return bytes(data)
91
+
92
+
93
+ def parse_vlan_members(data: bytes, port_count: int = 8) -> NsdpVlanMembership:
94
+ bitmap_bytes = (port_count + 7) // 8
95
+ expected = 2 + bitmap_bytes * 2
96
+ if len(data) < expected:
97
+ raise ValueError(
98
+ f"VLAN_MEMBERS TLV must be >={expected} bytes for {port_count} ports, "
99
+ f"got {len(data)}"
100
+ )
101
+ vlan_id = struct.unpack_from(">H", data, 0)[0]
102
+ member = data[2 : 2 + bitmap_bytes]
103
+ tagged = data[2 + bitmap_bytes : 2 + bitmap_bytes * 2]
104
+ return NsdpVlanMembership(
105
+ vlan_id=vlan_id,
106
+ member_ports=bitmap_to_ports(member),
107
+ tagged_ports=bitmap_to_ports(tagged),
108
+ )
109
+
110
+
111
+ def _decode_str(data: bytes) -> str:
112
+ return data.decode("ascii", errors="replace").rstrip("\x00")
113
+
114
+
115
+ def parse_port_mirroring(data: bytes) -> NsdpPortMirroring:
116
+ """Parse NSDP tag 0x5C00 (4 bytes: dest_port(1) + source bitmap(3)).
117
+
118
+ Lifted from ``gdoc2netcfg/src/nsdp/parsers.py::parse_port_mirroring``.
119
+ """
120
+ if len(data) != 4:
121
+ raise ValueError(f"PORT_MIRRORING TLV must be 4 bytes, got {len(data)}")
122
+ dest_port = data[0]
123
+ # Bytes 1-3 are the source-port bitmap (MSB first).
124
+ source_ports = bitmap_to_ports(data[1:4])
125
+ return NsdpPortMirroring(destination_port=dest_port, source_ports=source_ports)
126
+
127
+
128
+ def parse_igmp_snooping(data: bytes) -> NsdpIgmpSnooping:
129
+ """Parse NSDP tag 0x6800 (>=2 bytes: unknown, enabled, [unknown, vlan?]).
130
+
131
+ Lifted from ``gdoc2netcfg/src/nsdp/parsers.py::parse_igmp_snooping``.
132
+ """
133
+ if len(data) < 2:
134
+ raise ValueError(f"IGMP_SNOOPING TLV must be >= 2 bytes, got {len(data)}")
135
+ enabled = bool(data[1])
136
+ vlan_id = None
137
+ if len(data) >= 4:
138
+ vlan_id = data[3] if data[3] != 0 else None
139
+ return NsdpIgmpSnooping(enabled=enabled, vlan_id=vlan_id)
140
+
141
+
142
+ def parse_device(packet: NSDPPacket) -> NsdpDevice:
143
+ """Aggregate a READ_RESPONSE packet's TLVs into an NsdpDevice."""
144
+ model: str | None = None
145
+ mac: str | None = None
146
+ fields: dict[str, object] = {}
147
+ port_status: list[NsdpPortStatus] = []
148
+ port_stats: list[NsdpPortStatistics] = []
149
+ vlan_members: list[NsdpVlanMembership] = []
150
+ pvids: list[NsdpPortPvid] = []
151
+ port_count = 8
152
+ # First pass to learn the real port count (bitmaps need it).
153
+ for tlv in packet.tlvs:
154
+ if tlv.tag == Tag.PORT_COUNT and tlv.value:
155
+ port_count = tlv.value[0]
156
+ for tlv in packet.tlvs:
157
+ if tlv.tag == Tag.MODEL:
158
+ model = _decode_str(tlv.value)
159
+ elif tlv.tag == Tag.MAC:
160
+ mac = parse_mac(tlv.value)
161
+ elif tlv.tag == Tag.HOSTNAME:
162
+ fields["hostname"] = _decode_str(tlv.value)
163
+ elif tlv.tag == Tag.IP_ADDRESS:
164
+ fields["ip"] = parse_ipv4(tlv.value)
165
+ elif tlv.tag == Tag.NETMASK:
166
+ fields["netmask"] = parse_ipv4(tlv.value)
167
+ elif tlv.tag == Tag.GATEWAY:
168
+ fields["gateway"] = parse_ipv4(tlv.value)
169
+ elif tlv.tag == Tag.FIRMWARE_VER_1:
170
+ fields["firmware_version"] = _decode_str(tlv.value)
171
+ elif tlv.tag == Tag.DHCP_MODE and tlv.value:
172
+ fields["dhcp_enabled"] = bool(tlv.value[0])
173
+ elif tlv.tag == Tag.PORT_COUNT and tlv.value:
174
+ fields["port_count"] = tlv.value[0]
175
+ elif tlv.tag == Tag.SERIAL_NUMBER:
176
+ fields["serial_number"] = parse_serial(tlv.value)
177
+ elif tlv.tag == Tag.VLAN_ENGINE and tlv.value:
178
+ fields["vlan_engine"] = VLANEngine(tlv.value[0])
179
+ elif tlv.tag == Tag.PORT_STATUS:
180
+ port_status.append(parse_port_status(tlv.value))
181
+ elif tlv.tag == Tag.PORT_STATISTICS:
182
+ port_stats.append(parse_port_statistics(tlv.value))
183
+ elif tlv.tag == Tag.VLAN_MEMBERS:
184
+ vlan_members.append(parse_vlan_members(tlv.value, port_count))
185
+ elif tlv.tag == Tag.PORT_PVID:
186
+ pvids.append(parse_port_pvid(tlv.value))
187
+ elif tlv.tag == Tag.QOS_ENGINE and tlv.value:
188
+ fields["qos_engine"] = tlv.value[0]
189
+ elif tlv.tag == Tag.PORT_MIRRORING:
190
+ fields["port_mirroring"] = parse_port_mirroring(tlv.value)
191
+ elif tlv.tag == Tag.IGMP_SNOOPING:
192
+ fields["igmp_snooping"] = parse_igmp_snooping(tlv.value)
193
+ elif tlv.tag == Tag.BROADCAST_FILTERING and tlv.value:
194
+ fields["broadcast_filtering"] = bool(tlv.value[0])
195
+ elif tlv.tag == Tag.LOOP_DETECTION and tlv.value:
196
+ fields["loop_detection"] = bool(tlv.value[0])
197
+ if model is None:
198
+ raise ValueError("no MODEL tag in NSDP response")
199
+ if mac is None:
200
+ mac = parse_mac(packet.server_mac)
201
+ return NsdpDevice(
202
+ model=model,
203
+ mac=mac,
204
+ port_status=tuple(port_status),
205
+ port_statistics=tuple(port_stats),
206
+ vlan_members=tuple(vlan_members),
207
+ port_pvids=tuple(pvids),
208
+ **fields, # type: ignore[arg-type]
209
+ )
@@ -0,0 +1,201 @@
1
+ """NSDP wire codec: 32-byte header, TLV entries, and packet encode/decode.
2
+
3
+ Lifted verbatim (field-for-field) from ``gdoc2netcfg/src/nsdp/protocol.py``.
4
+ The header is ``struct`` layout ``>BB H 4s 6s 6s HH 4s 4s`` (32 bytes): version
5
+ (always 0x01), operation, result, reserved(4), client MAC(6), server MAC(6),
6
+ reserved(2), sequence, signature ``b"NSDP"`` at offset 0x18, reserved(4). Each
7
+ TLV is ``>HH`` (tag, length) followed by ``length`` value bytes; a packet ends
8
+ with the ``0xFFFF 0x0000`` end-of-marker.
9
+
10
+ This module is a pure, zero-dependency codec: no sockets, no I/O. The write
11
+ path (``Op.WRITE_REQUEST``/``Op.WRITE_RESPONSE`` and ``NSDPPacket.add_tlv``
12
+ with a non-empty value) is new relative to the read-only prior-art client,
13
+ but uses the exact same wire layout.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import struct
18
+ from dataclasses import dataclass, field
19
+ from enum import IntEnum
20
+
21
+ NSDP_SIGNATURE = b"NSDP"
22
+ HEADER_SIZE = 32
23
+ HEADER_FORMAT = ">BB H 4s 6s 6s HH 4s 4s"
24
+ END_MARKER = struct.pack(">HH", 0xFFFF, 0x0000) # b"\xff\xff\x00\x00"
25
+
26
+
27
+ class Op(IntEnum):
28
+ """NSDP operation codes (header byte 1).
29
+
30
+ READ_REQUEST/RESPONSE are used for discovery and property queries.
31
+ WRITE_REQUEST/RESPONSE are used to modify switch configuration
32
+ (requires authentication via Tag.PASSWORD or Tag.AUTH_V2_PASSWORD).
33
+ """
34
+
35
+ READ_REQUEST = 0x01
36
+ READ_RESPONSE = 0x02
37
+ WRITE_REQUEST = 0x03
38
+ WRITE_RESPONSE = 0x04
39
+
40
+
41
+ class Tag(IntEnum):
42
+ """NSDP TLV tag identifiers.
43
+
44
+ Each tag represents a switch property. Tags are 16-bit unsigned integers
45
+ encoded big-endian in the packet. See
46
+ ``gdoc2netcfg/docs/nsdp-protocol.md`` (TLV Tag Registry) for byte-level
47
+ encoding details of each tag's value field.
48
+ """
49
+
50
+ # Packet markers
51
+ START_OF_MARK = 0x0000
52
+ END_OF_MARK = 0xFFFF
53
+
54
+ # Device identity
55
+ MODEL = 0x0001
56
+ HOSTNAME = 0x0003
57
+ MAC = 0x0004
58
+ LOCATION = 0x0005
59
+ IP_ADDRESS = 0x0006
60
+ NETMASK = 0x0007
61
+ GATEWAY = 0x0008
62
+ DHCP_MODE = 0x000B
63
+ FIRMWARE_VER_1 = 0x000D
64
+ FIRMWARE_VER_2 = 0x000E
65
+ PORT_COUNT = 0x6000
66
+ SERIAL_NUMBER = 0x7800
67
+
68
+ # Authentication
69
+ PASSWORD = 0x000A
70
+ AUTH_V2_SALT = 0x0017
71
+ AUTH_V2_PASSWORD = 0x001A
72
+
73
+ # Port information
74
+ PORT_STATUS = 0x0C00
75
+ PORT_STATISTICS = 0x1000
76
+
77
+ # VLAN
78
+ VLAN_ENGINE = 0x2000
79
+ VLAN_MEMBERS = 0x2800
80
+ PORT_PVID = 0x3000
81
+
82
+ # QoS
83
+ QOS_ENGINE = 0x3400
84
+ PORT_QOS_PRIORITY = 0x3800
85
+
86
+ # Traffic control
87
+ INGRESS_RATE_LIMIT = 0x4C00
88
+ EGRESS_RATE_LIMIT = 0x5000
89
+ BROADCAST_FILTERING = 0x5400
90
+ BROADCAST_BANDWIDTH = 0x5800
91
+ PORT_MIRRORING = 0x5C00
92
+
93
+ # IGMP
94
+ IGMP_SNOOPING = 0x6800
95
+ BLOCK_UNKNOWN_MULTICAST = 0x6C00
96
+ IGMPV3_HEADER_VALIDATION = 0x7000
97
+ IGMP_STATIC_ROUTER_PORTS = 0x8000
98
+
99
+ # Other
100
+ LOOP_DETECTION = 0x9000
101
+ ACTIVE_FIRMWARE = 0x000C
102
+
103
+ # Actions (write-only)
104
+ REBOOT = 0x0013
105
+ FACTORY_RESET = 0x0400
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class TLVEntry:
110
+ """One NSDP TLV: a 2-byte tag, 2-byte length, then that many value bytes."""
111
+
112
+ tag: Tag | int
113
+ value: bytes = b""
114
+
115
+ def encode(self) -> bytes:
116
+ return struct.pack(">HH", int(self.tag), len(self.value)) + self.value
117
+
118
+ @classmethod
119
+ def decode(cls, data: bytes) -> tuple[TLVEntry, int]:
120
+ if len(data) < 4:
121
+ raise ValueError("NSDP TLV shorter than its 4-byte header")
122
+ tag_raw, length = struct.unpack_from(">HH", data, 0)
123
+ if len(data) < 4 + length:
124
+ raise ValueError(
125
+ f"NSDP TLV declares {length} value bytes but only "
126
+ f"{len(data) - 4} are present"
127
+ )
128
+ value = data[4 : 4 + length]
129
+ tag: Tag | int
130
+ try:
131
+ tag = Tag(tag_raw)
132
+ except ValueError:
133
+ tag = tag_raw # unknown/uncatalogued tag: keep the raw int
134
+ return cls(tag=tag, value=value), 4 + length
135
+
136
+
137
+ @dataclass
138
+ class NSDPPacket:
139
+ """A full NSDP datagram: a fixed header plus a list of TLVs."""
140
+
141
+ op: Op
142
+ client_mac: bytes
143
+ server_mac: bytes = b"\x00" * 6
144
+ sequence: int = 0
145
+ result: int = 0
146
+ tlvs: list[TLVEntry] = field(default_factory=list)
147
+
148
+ def add_tlv(self, tag: Tag | int, value: bytes = b"") -> None:
149
+ self.tlvs.append(TLVEntry(tag=tag, value=value))
150
+
151
+ def encode(self) -> bytes:
152
+ header = struct.pack(
153
+ HEADER_FORMAT,
154
+ 0x01,
155
+ int(self.op),
156
+ self.result,
157
+ b"\x00" * 4,
158
+ self.client_mac,
159
+ self.server_mac,
160
+ 0,
161
+ self.sequence,
162
+ NSDP_SIGNATURE,
163
+ b"\x00" * 4,
164
+ )
165
+ body = b"".join(t.encode() for t in self.tlvs)
166
+ return header + body + END_MARKER
167
+
168
+ @classmethod
169
+ def decode(cls, data: bytes) -> NSDPPacket:
170
+ if len(data) < HEADER_SIZE:
171
+ raise ValueError(f"NSDP packet shorter than {HEADER_SIZE}-byte header")
172
+ (
173
+ _version,
174
+ op_raw,
175
+ result,
176
+ _reserved1,
177
+ client_mac,
178
+ server_mac,
179
+ _reserved2,
180
+ sequence,
181
+ signature,
182
+ _reserved3,
183
+ ) = struct.unpack(HEADER_FORMAT, data[:HEADER_SIZE])
184
+ if signature != NSDP_SIGNATURE:
185
+ raise ValueError(f"bad NSDP signature {signature!r}")
186
+ tlvs: list[TLVEntry] = []
187
+ offset = HEADER_SIZE
188
+ while offset + 4 <= len(data):
189
+ entry, consumed = TLVEntry.decode(data[offset:])
190
+ if entry.tag == Tag.END_OF_MARK:
191
+ break
192
+ tlvs.append(entry)
193
+ offset += consumed
194
+ return cls(
195
+ op=Op(op_raw),
196
+ client_mac=client_mac,
197
+ server_mac=server_mac,
198
+ sequence=sequence,
199
+ result=result,
200
+ tlvs=tlvs,
201
+ )
@@ -0,0 +1,137 @@
1
+ """NSDP-native parsed value types. Lifted from ``gdoc2netcfg/src/nsdp/types.py``.
2
+
3
+ These are the raw protocol shapes the parsers return; ``nsdp_read.py`` maps them
4
+ onto the shared ``models.py`` types. Named with an ``Nsdp`` prefix so they never
5
+ collide with the public ``models`` dataclasses.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from enum import IntEnum
11
+
12
+ _MBPS = {
13
+ 0x00: 0, 0x01: 10, 0x02: 10, 0x03: 100, 0x04: 100, 0x05: 1000, 0xFF: 10000,
14
+ }
15
+
16
+
17
+ class LinkSpeed(IntEnum):
18
+ DOWN = 0x00
19
+ HALF_10M = 0x01
20
+ FULL_10M = 0x02
21
+ HALF_100M = 0x03
22
+ FULL_100M = 0x04
23
+ GIGABIT = 0x05
24
+ # ASSUMED/UNVERIFIED — the reference spec states 2.5G/5G/10G speed byte
25
+ # values are undocumented and require a hardware capture; 0xFF is carried
26
+ # over from prior art without independent confirmation.
27
+ TEN_GIGABIT = 0xFF
28
+
29
+ @classmethod
30
+ def from_byte(cls, value: int) -> LinkSpeed:
31
+ try:
32
+ return cls(value)
33
+ except ValueError:
34
+ return cls.DOWN # unknown 2.5G/5G codes: report DOWN, never raise
35
+
36
+ @property
37
+ def speed_mbps(self) -> int:
38
+ return _MBPS.get(int(self), 0)
39
+
40
+
41
+ class VLANEngine(IntEnum):
42
+ DISABLED = 0
43
+ BASIC_PORT = 1
44
+ ADVANCED_PORT = 2
45
+ BASIC_802_1Q = 3
46
+ ADVANCED_802_1Q = 4
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class NsdpPortStatus:
51
+ port_id: int
52
+ speed: LinkSpeed
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class NsdpPortStatistics:
57
+ port_id: int
58
+ bytes_received: int
59
+ bytes_sent: int
60
+ crc_errors: int
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class NsdpVlanMembership:
65
+ vlan_id: int
66
+ member_ports: frozenset[int]
67
+ tagged_ports: frozenset[int] = frozenset()
68
+
69
+ @property
70
+ def untagged_ports(self) -> frozenset[int]:
71
+ return self.member_ports - self.tagged_ports
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class NsdpPortPvid:
76
+ port_id: int
77
+ vlan_id: int
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class NsdpPortMirroring:
82
+ """Port mirroring configuration (NSDP tag 0x5C00).
83
+
84
+ Lifted from ``gdoc2netcfg/src/nsdp/types.py::PortMirroring``.
85
+
86
+ Attributes:
87
+ destination_port: Port receiving mirrored traffic (0 = disabled).
88
+ source_ports: Ports being mirrored.
89
+ """
90
+
91
+ destination_port: int
92
+ source_ports: frozenset[int] = frozenset()
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class NsdpIgmpSnooping:
97
+ """IGMP snooping configuration (NSDP tag 0x6800).
98
+
99
+ Lifted from ``gdoc2netcfg/src/nsdp/types.py::IGMPSnooping``.
100
+
101
+ Attributes:
102
+ enabled: Whether IGMP snooping is enabled.
103
+ vlan_id: VLAN for IGMP snooping (if applicable); ``None`` when the
104
+ wire value is 0 (no VLAN association).
105
+ """
106
+
107
+ enabled: bool
108
+ vlan_id: int | None = None
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class NsdpDevice:
113
+ model: str
114
+ mac: str
115
+ hostname: str | None = None
116
+ ip: str | None = None
117
+ netmask: str | None = None
118
+ gateway: str | None = None
119
+ firmware_version: str | None = None
120
+ dhcp_enabled: bool | None = None
121
+ port_count: int | None = None
122
+ serial_number: str | None = None
123
+ vlan_engine: VLANEngine | None = None
124
+ port_status: tuple[NsdpPortStatus, ...] = ()
125
+ port_statistics: tuple[NsdpPortStatistics, ...] = ()
126
+ vlan_members: tuple[NsdpVlanMembership, ...] = ()
127
+ port_pvids: tuple[NsdpPortPvid, ...] = field(default_factory=tuple)
128
+ # QoS engine mode (tag 0x3400): 0=disabled, 1=port-based, 2=802.1p.
129
+ qos_engine: int | None = None
130
+ # Port mirroring configuration (tag 0x5C00).
131
+ port_mirroring: NsdpPortMirroring | None = None
132
+ # IGMP snooping configuration (tag 0x6800).
133
+ igmp_snooping: NsdpIgmpSnooping | None = None
134
+ # Whether broadcast storm filtering is enabled (tag 0x5400).
135
+ broadcast_filtering: bool | None = None
136
+ # Whether loop detection is enabled (tag 0x9000).
137
+ loop_detection: bool | None = None
@@ -0,0 +1,98 @@
1
+ """NSDP request framing + value-TLV encoders for the read and write paths.
2
+
3
+ Pure: builds ``NSDPPacket`` objects, no I/O. The write path (absent from the
4
+ lifted ``gdoc2netcfg`` package, which was read-only) prepends a v1 ``PASSWORD``
5
+ TLV — a real switch rejects an unauthenticated or wrongly-authenticated write
6
+ with result 0x0700, which the transport turns into an ``NsdpError`` (Task 4).
7
+
8
+ UNVERIFIED write path (mirrors ``snmp_write.py``'s house style for its
9
+ mgmt_write_* OIDs). This entire NSDP write path — the WRITE_REQUEST value-TLV
10
+ encodings here plus the v1 XOR auth in ``auth.py`` — is a from-scratch addition
11
+ with ZERO verification against real hardware: the lifted ``gdoc2netcfg/src/nsdp``
12
+ prior art is READ-ONLY, so nothing in it exercises writes. It stays UNVERIFIED
13
+ pending a real capture (Slice 7 capture utility / a real-hardware run);
14
+ verify-after-write in ``nsdp_write.py`` is the runtime guard against a silently
15
+ wrong encoding. Critically, the reference spec
16
+ (``gdoc2netcfg/docs/nsdp-protocol.md``) marks ``PORT_PVID`` (0x3000) and
17
+ ``VLAN_MEMBERS`` (0x2800) as READ-ONLY (R), unlike hostname/ip/netmask/gateway/
18
+ dhcp_mode/vlan_engine (R/W). Writing PVID/VLAN membership via NSDP may therefore
19
+ be REJECTED by real hardware — the switch may only accept those changes via the
20
+ ``vlan_engine`` (or other R/W) tags or via HTTP. Do NOT read the ``pvid_tlv`` /
21
+ ``vlan_members_tlv`` encoders here as confirmation that those tags are writable;
22
+ their writability is unconfirmed and must be settled by a hardware capture.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import socket
27
+ import struct
28
+ from typing import TYPE_CHECKING
29
+
30
+ from .auth import encode_password_v1
31
+ from .parsers import ports_to_bitmap
32
+ from .protocol import NSDPPacket, Op, Tag, TLVEntry
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Iterable
36
+
37
+ RESULT_SUCCESS = 0x0000
38
+ RESULT_BAD_PASSWORD = 0x0700
39
+
40
+
41
+ def build_read_request(
42
+ client_mac: bytes, server_mac: bytes, sequence: int, tags: list[Tag]
43
+ ) -> NSDPPacket:
44
+ pkt = NSDPPacket(
45
+ op=Op.READ_REQUEST,
46
+ client_mac=client_mac,
47
+ server_mac=server_mac,
48
+ sequence=sequence,
49
+ )
50
+ for tag in tags:
51
+ pkt.add_tlv(tag) # length-0 TLV = "please read this"
52
+ return pkt
53
+
54
+
55
+ def build_write_request(
56
+ client_mac: bytes,
57
+ server_mac: bytes,
58
+ sequence: int,
59
+ password: str,
60
+ tlvs: list[TLVEntry],
61
+ ) -> NSDPPacket:
62
+ pkt = NSDPPacket(
63
+ op=Op.WRITE_REQUEST,
64
+ client_mac=client_mac,
65
+ server_mac=server_mac,
66
+ sequence=sequence,
67
+ )
68
+ pkt.tlvs.append(TLVEntry(Tag.PASSWORD, encode_password_v1(password)))
69
+ pkt.tlvs.extend(tlvs)
70
+ return pkt
71
+
72
+
73
+ def pvid_tlv(port: int, vlan: int) -> TLVEntry:
74
+ return TLVEntry(Tag.PORT_PVID, bytes([port]) + struct.pack(">H", vlan))
75
+
76
+
77
+ def vlan_members_tlv(
78
+ vlan: int, members: Iterable[int], tagged: Iterable[int], port_count: int
79
+ ) -> TLVEntry:
80
+ width = (port_count + 7) // 8
81
+ value = (
82
+ struct.pack(">H", vlan)
83
+ + ports_to_bitmap(members, width)
84
+ + ports_to_bitmap(tagged, width)
85
+ )
86
+ return TLVEntry(Tag.VLAN_MEMBERS, value)
87
+
88
+
89
+ def ipv4_tlv(tag: Tag, dotted: str) -> TLVEntry:
90
+ return TLVEntry(tag, socket.inet_aton(dotted))
91
+
92
+
93
+ def dhcp_tlv(enabled: bool) -> TLVEntry:
94
+ return TLVEntry(Tag.DHCP_MODE, b"\x01" if enabled else b"\x00")
95
+
96
+
97
+ def reboot_tlv() -> TLVEntry:
98
+ return TLVEntry(Tag.REBOOT, b"")
@@ -0,0 +1 @@
1
+ """SNMP protocol logic (pure, I/O-free)."""