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,108 @@
1
+ """Resolve the target ``SyncSwitch`` from CLI args (inventory or host+model).
2
+
3
+ Credential precedence (design spec Sec5.1): CLI flag -> environment variable ->
4
+ config value -> interactive prompt.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import TYPE_CHECKING
11
+
12
+ from netgear_switch.config import load_inventory
13
+ from netgear_switch.errors import ConfigError
14
+ from netgear_switch.registry import get_model
15
+ from netgear_switch.sync_api import SyncSwitch
16
+
17
+ if TYPE_CHECKING:
18
+ import argparse
19
+ from collections.abc import Callable, Mapping
20
+
21
+ from netgear_switch.config import SwitchConfig
22
+
23
+
24
+ def _read_community(
25
+ args: argparse.Namespace,
26
+ env: Mapping[str, str],
27
+ config_value: str | None,
28
+ prompt: Callable[[str], str] | None,
29
+ ) -> str | None:
30
+ if args.community:
31
+ return str(args.community)
32
+ if env.get("NGSW_COMMUNITY"):
33
+ return env["NGSW_COMMUNITY"]
34
+ if config_value:
35
+ return config_value
36
+ if prompt is not None:
37
+ typed = prompt("SNMP read community: ")
38
+ # A bare Enter at the prompt must NOT become a literal empty-string
39
+ # SNMP community; treat it as unresolved so the library's existing
40
+ # lazy CredentialError fires at SNMP-build time instead. (CLI/env/
41
+ # config tiers are out of scope here -- separate hardening later.)
42
+ return typed if typed.strip() else None
43
+ return None
44
+
45
+
46
+ def _write_community_override(
47
+ args: argparse.Namespace, env: Mapping[str, str]
48
+ ) -> str | None:
49
+ if args.write_community:
50
+ return str(args.write_community)
51
+ return env.get("NGSW_WRITE_COMMUNITY")
52
+
53
+
54
+ def _from_inventory(
55
+ args: argparse.Namespace,
56
+ env: Mapping[str, str],
57
+ prompt: Callable[[str], str] | None,
58
+ ) -> SyncSwitch:
59
+ if not args.config:
60
+ raise ConfigError("--switch requires --config <inventory.toml>")
61
+ inventory = load_inventory(args.config, env=env)
62
+ try:
63
+ cfg: SwitchConfig = inventory[args.switch]
64
+ except KeyError:
65
+ raise ConfigError(
66
+ f"switch {args.switch!r} not found in {args.config}"
67
+ ) from None
68
+ community = _read_community(args, env, cfg.snmp_community, prompt)
69
+ write_override = _write_community_override(args, env)
70
+ return SyncSwitch(
71
+ cfg.model,
72
+ cfg.host,
73
+ snmp_community=community,
74
+ snmp_write_community=write_override,
75
+ snmp_write_community_resolver=lambda: cfg.snmp_write_community(env=env),
76
+ protected_ports=cfg.protected_ports,
77
+ )
78
+
79
+
80
+ def resolve_switch(
81
+ args: argparse.Namespace,
82
+ *,
83
+ env: Mapping[str, str] | None = None,
84
+ prompt: Callable[[str], str] | None = None,
85
+ ) -> SyncSwitch:
86
+ """Build a ``SyncSwitch`` from ``--config``/``--switch``/``--host``/``--model``.
87
+
88
+ Resolution: an inventory lookup (``--switch``, requires ``--config``) wins
89
+ when given; otherwise ``--host``/``--model`` build a switch directly.
90
+ Credential precedence for the SNMP read community is CLI flag ->
91
+ ``NGSW_COMMUNITY`` env var -> inventory config value -> ``prompt`` (if
92
+ supplied). The write community only ever comes from a CLI flag or
93
+ ``NGSW_WRITE_COMMUNITY``/inventory spec, resolved lazily by ``SyncSwitch``.
94
+ """
95
+ env = os.environ if env is None else env
96
+ if args.switch:
97
+ return _from_inventory(args, env, prompt)
98
+ if args.host and args.model:
99
+ community = _read_community(args, env, None, prompt)
100
+ return SyncSwitch(
101
+ get_model(args.model),
102
+ args.host,
103
+ snmp_community=community,
104
+ snmp_write_community=_write_community_override(args, env),
105
+ )
106
+ raise ConfigError(
107
+ "specify --switch <name> (with --config) or both --host and --model"
108
+ )
@@ -0,0 +1,71 @@
1
+ """Write-safety gates for disruptive ngsw commands (design spec §6)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING
5
+
6
+ from .context import EXIT_ERROR, EXIT_OK
7
+
8
+ if TYPE_CHECKING:
9
+ import argparse
10
+ from collections.abc import Callable
11
+
12
+ from .context import CliContext
13
+
14
+
15
+ def add_write_args(parser: argparse.ArgumentParser) -> None:
16
+ """Attach the shared --dry-run / --yes / --force gates to a subparser."""
17
+ parser.add_argument(
18
+ "--dry-run",
19
+ action="store_true",
20
+ help="print the operation that would be sent, then send nothing",
21
+ )
22
+ parser.add_argument(
23
+ "-y", "--yes", action="store_true", help="skip the confirmation prompt"
24
+ )
25
+ parser.add_argument(
26
+ "--force",
27
+ action="store_true",
28
+ help="override protected_ports and other force-gates",
29
+ )
30
+
31
+
32
+ def confirm(prompt: str, *, assume_yes: bool, ctx: CliContext) -> bool:
33
+ """Ask for confirmation on stderr; read one line from ctx.inp."""
34
+ if assume_yes:
35
+ return True
36
+ print(f"{prompt} [y/N]: ", end="", file=ctx.err)
37
+ ctx.err.flush()
38
+ reply = ctx.inp.readline().strip().lower()
39
+ return reply in {"y", "yes"}
40
+
41
+
42
+ def do_write(
43
+ ctx: CliContext,
44
+ *,
45
+ dry_run: bool,
46
+ assume_yes: bool,
47
+ host: str,
48
+ description: str,
49
+ action: Callable[[], None],
50
+ warning: str | None = None,
51
+ ) -> int:
52
+ """The single disruptive-write gate: dry-run -> confirm -> execute -> report.
53
+
54
+ ``action`` is the verify-after-write facade call; any NetgearSwitchError it
55
+ raises propagates to main() for clean reporting. The CLI describes the
56
+ operation at facade granularity (method + args + host) rather than
57
+ re-encoding the SNMP SET / NSDP packet / HTTP form, so no library logic is
58
+ duplicated here.
59
+ """
60
+ if dry_run:
61
+ print(f"DRY-RUN: would {description} on {host} (nothing sent)", file=ctx.out)
62
+ return EXIT_OK
63
+ prompt = f"About to {description} on {host}."
64
+ if warning:
65
+ prompt = f"{warning}\n{prompt}"
66
+ if not confirm(prompt, assume_yes=assume_yes, ctx=ctx):
67
+ print("aborted: no changes made", file=ctx.err)
68
+ return EXIT_ERROR
69
+ action()
70
+ print(f"ok: {description}", file=ctx.out)
71
+ return EXIT_OK
@@ -0,0 +1,184 @@
1
+ """TOML inventory loading and credential resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import subprocess
8
+ import tomllib
9
+ from collections.abc import Callable, Mapping
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ from .errors import ConfigError, CredentialError
14
+ from .registry import SwitchModel, get_model
15
+
16
+ if TYPE_CHECKING:
17
+ from typing import TypeGuard
18
+
19
+ Runner = Callable[..., subprocess.CompletedProcess[str]]
20
+
21
+ _SECRET_COMMAND_TIMEOUT = 10
22
+
23
+
24
+ def resolve_secret(
25
+ spec: str | None,
26
+ *,
27
+ env: Mapping[str, str],
28
+ runner: Runner = subprocess.run,
29
+ ) -> str | None:
30
+ """Resolve one secret spec to its value (or None)."""
31
+ if spec is None:
32
+ return None
33
+ if spec.startswith("${") and spec.endswith("}"):
34
+ name = spec[2:-1]
35
+ try:
36
+ return env[name]
37
+ except KeyError:
38
+ raise CredentialError(f"environment variable {name!r} is not set") from None
39
+ if spec.startswith("!"):
40
+ args = shlex.split(spec[1:])
41
+ if not args:
42
+ raise CredentialError("empty command in secret spec")
43
+ try:
44
+ result = runner(
45
+ args, capture_output=True, text=True, timeout=_SECRET_COMMAND_TIMEOUT
46
+ )
47
+ except (OSError, subprocess.TimeoutExpired) as exc:
48
+ raise CredentialError(
49
+ f"secret command {args!r} could not be run: {exc}"
50
+ ) from exc
51
+ if result.returncode != 0:
52
+ raise CredentialError(
53
+ f"secret command {args!r} failed "
54
+ f"(exit {result.returncode}): {result.stderr.strip()}"
55
+ )
56
+ return result.stdout.strip()
57
+ return spec
58
+
59
+
60
+ def _is_literal(spec: str | None) -> TypeGuard[str]:
61
+ if spec is None:
62
+ return False
63
+ return not (spec.startswith("${") and spec.endswith("}")) and not spec.startswith(
64
+ "!"
65
+ )
66
+
67
+
68
+ def ensure_secure_file(path: os.PathLike[str] | str) -> None:
69
+ """Raise if the file is readable/writable by group or other."""
70
+ mode = os.stat(path).st_mode
71
+ if mode & 0o077:
72
+ raise ConfigError(
73
+ f"{os.fspath(path)} has insecure permissions {oct(mode & 0o777)}; "
74
+ "chmod 600 it (contains a literal secret)"
75
+ )
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class SwitchConfig:
80
+ name: str
81
+ model: SwitchModel
82
+ host: str
83
+ snmp_community: str | None
84
+ snmp_write_community_spec: str | None
85
+ http_password_spec: str | None
86
+ nsdp_interface: str | None
87
+ protected_ports: frozenset[int]
88
+
89
+ def snmp_write_community(
90
+ self, *, env: Mapping[str, str], runner: Runner = subprocess.run
91
+ ) -> str | None:
92
+ return resolve_secret(self.snmp_write_community_spec, env=env, runner=runner)
93
+
94
+ def http_password(
95
+ self, *, env: Mapping[str, str], runner: Runner = subprocess.run
96
+ ) -> str | None:
97
+ return resolve_secret(self.http_password_spec, env=env, runner=runner)
98
+
99
+
100
+ def _switch_from_table(
101
+ name: str, table: Mapping[str, object]
102
+ ) -> tuple[SwitchConfig, list[str]]:
103
+ try:
104
+ model_key = table["model"]
105
+ host = table["host"]
106
+ except KeyError as exc:
107
+ raise ConfigError(
108
+ f"switch {name!r} is missing required key {exc.args[0]!r}"
109
+ ) from None
110
+ if not isinstance(model_key, str) or not isinstance(host, str):
111
+ raise ConfigError(f"switch {name!r}: 'model' and 'host' must be strings")
112
+
113
+ snmp = table.get("snmp", {})
114
+ http = table.get("http", {})
115
+ nsdp = table.get("nsdp", {})
116
+ if (
117
+ not isinstance(snmp, Mapping)
118
+ or not isinstance(http, Mapping)
119
+ or not isinstance(nsdp, Mapping)
120
+ ):
121
+ raise ConfigError(f"switch {name!r}: snmp/http/nsdp must be tables")
122
+
123
+ ports = table.get("protected_ports", [])
124
+ if not isinstance(ports, list) or not all(
125
+ isinstance(p, int) and not isinstance(p, bool) for p in ports
126
+ ):
127
+ raise ConfigError(f"switch {name!r}: protected_ports must be a list of ints")
128
+
129
+ for label, value in (
130
+ ("snmp.community", snmp.get("community")),
131
+ ("snmp.write_community", snmp.get("write_community")),
132
+ ("http.password", http.get("password")),
133
+ ("nsdp.interface", nsdp.get("interface")),
134
+ ):
135
+ if value is not None and not isinstance(value, str):
136
+ raise ConfigError(f"switch {name!r}: {label} must be a string")
137
+
138
+ secret_specs = [
139
+ snmp.get("write_community"),
140
+ http.get("password"),
141
+ ]
142
+ literals = [s for s in secret_specs if _is_literal(s)]
143
+
144
+ cfg = SwitchConfig(
145
+ name=name,
146
+ model=get_model(model_key),
147
+ host=host,
148
+ snmp_community=snmp.get("community"),
149
+ snmp_write_community_spec=snmp.get("write_community"),
150
+ http_password_spec=http.get("password"),
151
+ nsdp_interface=nsdp.get("interface"),
152
+ protected_ports=frozenset(ports),
153
+ )
154
+ return cfg, literals
155
+
156
+
157
+ def load_inventory(
158
+ path: os.PathLike[str] | str,
159
+ *,
160
+ env: Mapping[str, str] | None = None,
161
+ ) -> dict[str, SwitchConfig]:
162
+ """Load a TOML inventory into a {name: SwitchConfig} dict."""
163
+ if env is None:
164
+ env = os.environ
165
+ with open(path, "rb") as fh:
166
+ data = tomllib.load(fh)
167
+
168
+ switches = data.get("switches", {})
169
+ if not isinstance(switches, Mapping):
170
+ raise ConfigError("top-level [switches] must be a table")
171
+
172
+ result: dict[str, SwitchConfig] = {}
173
+ any_literal = False
174
+ for name, table in switches.items():
175
+ if not isinstance(table, Mapping):
176
+ raise ConfigError(f"[switches.{name}] must be a table")
177
+ cfg, literals = _switch_from_table(name, table)
178
+ if literals:
179
+ any_literal = True
180
+ result[name] = cfg
181
+
182
+ if any_literal:
183
+ ensure_secure_file(path)
184
+ return result
@@ -0,0 +1,52 @@
1
+ """Exception hierarchy for netgear_switch."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class NetgearSwitchError(Exception):
7
+ """Base class for every error raised by this library."""
8
+
9
+
10
+ class ConfigError(NetgearSwitchError):
11
+ """The inventory/config file is malformed or invalid."""
12
+
13
+
14
+ class CredentialError(NetgearSwitchError):
15
+ """A required secret could not be resolved from any source."""
16
+
17
+
18
+ class UnknownModelError(NetgearSwitchError):
19
+ """A switch references a model key that is not in the registry."""
20
+
21
+
22
+ class UnsupportedCapabilityError(NetgearSwitchError):
23
+ """The requested operation is not available on this model/backend."""
24
+
25
+
26
+ class ProtectedPortError(NetgearSwitchError):
27
+ """A disruptive write targeted a protected port without force=True."""
28
+
29
+
30
+ class WriteVerificationError(NetgearSwitchError):
31
+ """A write did not read back as expected.
32
+
33
+ Carries the observed state before and after the write attempt so callers
34
+ can report exactly what diverged.
35
+ """
36
+
37
+ def __init__(self, message: str, *, before: object, after: object) -> None:
38
+ super().__init__(message)
39
+ self.before = before
40
+ self.after = after
41
+
42
+
43
+ class HttpError(NetgearSwitchError):
44
+ """An HTTP web-UI transport operation failed (connect, HTTP status, page shape)."""
45
+
46
+
47
+ class HttpAuthError(HttpError):
48
+ """Web-UI login was rejected, or an authenticated session was lost."""
49
+
50
+
51
+ class HttpUnexpectedPageError(HttpError):
52
+ """A web-UI page or token could not be parsed into the expected shape."""
@@ -0,0 +1,174 @@
1
+ """Model-driven web-UI read operations over a sync or async ``HttpSession``.
2
+
3
+ Parallel to ``snmp_read.py``/``nsdp_read.py``. Construction is gated on
4
+ ``HttpModelSpec.reads_verified``: a model whose web reads are still
5
+ UNVERIFIED-pending-capture (gs110emx Gambit, gsm7228ps cheetah/S3300) refuses
6
+ to construct rather than return fabricated data -- the facade never gets a
7
+ plausible-but-wrong result from an unverified scrape. Web-UI-impossible ops
8
+ (MAC/FDB, box sensors, LLDP, management-IP config) raise
9
+ ``UnsupportedCapabilityError`` honestly instead of silently returning ``[]``.
10
+
11
+ All page-path selection and HTML-to-model conversion lives in the
12
+ module-level helpers below (pure, I/O-free); ``HttpReader``/``AsyncHttpReader``
13
+ differ only in whether ``session.get_page``/``post_form`` is awaited.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING
18
+
19
+ from .errors import UnsupportedCapabilityError
20
+ from .models import VLANInfo, VlanMode
21
+ from .protocols.http import parse
22
+ from .protocols.http.endpoints import http_spec
23
+
24
+ if TYPE_CHECKING:
25
+ from .models import (
26
+ LLDPNeighbor,
27
+ MacEntry,
28
+ MgmtIpConfig,
29
+ PoEStatus,
30
+ PortStats,
31
+ PortStatus,
32
+ Sensor,
33
+ )
34
+ from .protocols.http.endpoints import HttpModelSpec
35
+ from .protocols.http.session import AsyncHttpSession, HttpSession
36
+ from .registry import SwitchModel
37
+
38
+
39
+ def _require_verified_reads(spec: HttpModelSpec) -> None:
40
+ if not spec.reads_verified:
41
+ raise UnsupportedCapabilityError(
42
+ f"model {spec.model_key!r} HTTP reads are UNVERIFIED-pending-capture"
43
+ )
44
+
45
+
46
+ def _unsupported(model_key: str, op: str) -> UnsupportedCapabilityError:
47
+ return UnsupportedCapabilityError(
48
+ f"model {model_key!r} web UI does not expose {op}"
49
+ )
50
+
51
+
52
+ def _require_path(model_key: str, path: str | None, op: str) -> str:
53
+ """Return ``path`` or raise honestly if this model's spec has none for ``op``."""
54
+ if path is None:
55
+ raise _unsupported(model_key, op)
56
+ return path
57
+
58
+
59
+ def _vlan_info(vid: int, membership_html: str, port_count: int) -> VLANInfo:
60
+ """Pure conversion of one 8021qMembe.cgi response into a ``VLANInfo``."""
61
+ states = parse.parse_membership(membership_html, port_count)
62
+ tagged = frozenset(p for p, m in states.items() if m is VlanMode.TAGGED)
63
+ untagged = frozenset(p for p, m in states.items() if m is VlanMode.UNTAGGED)
64
+ return VLANInfo(
65
+ vlan_id=vid,
66
+ name=None,
67
+ member_ports=tagged | untagged,
68
+ tagged_ports=tagged,
69
+ untagged_ports=untagged,
70
+ )
71
+
72
+
73
+ class HttpReader:
74
+ """Synchronous web-UI read facade over one switch."""
75
+
76
+ def __init__(self, session: HttpSession, model: SwitchModel) -> None:
77
+ self._spec = http_spec(model)
78
+ _require_verified_reads(self._spec)
79
+ self.session = session
80
+ self.model = model
81
+
82
+ def get_ports(self) -> list[PortStatus]:
83
+ path = _require_path(self.model.key, self._spec.dashboard_path, "port status")
84
+ return parse.parse_port_status(self.session.get_page(path))
85
+
86
+ def get_stats(self) -> list[PortStats]:
87
+ path = _require_path(self.model.key, self._spec.stats_path, "port statistics")
88
+ return parse.parse_port_stats(self.session.get_page(path))
89
+
90
+ def get_poe(self) -> list[PoEStatus]:
91
+ path = _require_path(self.model.key, self._spec.poe_status_path, "PoE status")
92
+ return parse.parse_poe_status(self.session.get_page(path))
93
+
94
+ def get_pvids(self) -> list[tuple[int, int]]:
95
+ path = _require_path(self.model.key, self._spec.pvid_path, "port PVIDs")
96
+ return parse.parse_pvids(self.session.get_page(path))
97
+
98
+ def get_vlans(self) -> list[VLANInfo]:
99
+ cfg_path = _require_path(
100
+ self.model.key, self._spec.vlan_config_path, "VLAN configuration"
101
+ )
102
+ member_path = _require_path(
103
+ self.model.key, self._spec.vlan_membership_path, "VLAN membership"
104
+ )
105
+ cfg = self.session.get_page(cfg_path)
106
+ result: list[VLANInfo] = []
107
+ for vid in parse.parse_vlan_ids(cfg):
108
+ html = self.session.post_form(member_path, {"VLAN_ID": str(vid)})
109
+ result.append(_vlan_info(vid, html, self.model.port_count))
110
+ return result
111
+
112
+ def get_macs(self) -> list[MacEntry]:
113
+ raise _unsupported(self.model.key, "a MAC/FDB table")
114
+
115
+ def get_lldp(self) -> list[LLDPNeighbor]:
116
+ raise _unsupported(self.model.key, "LLDP neighbours")
117
+
118
+ def get_sensors(self) -> list[Sensor]:
119
+ raise _unsupported(self.model.key, "box sensors")
120
+
121
+ def get_mgmt_ip(self) -> MgmtIpConfig:
122
+ raise _unsupported(self.model.key, "management-IP config")
123
+
124
+
125
+ class AsyncHttpReader:
126
+ """Asynchronous web-UI read facade (mirror of ``HttpReader``)."""
127
+
128
+ def __init__(self, session: AsyncHttpSession, model: SwitchModel) -> None:
129
+ self._spec = http_spec(model)
130
+ _require_verified_reads(self._spec)
131
+ self.session = session
132
+ self.model = model
133
+
134
+ async def get_ports(self) -> list[PortStatus]:
135
+ path = _require_path(self.model.key, self._spec.dashboard_path, "port status")
136
+ return parse.parse_port_status(await self.session.get_page(path))
137
+
138
+ async def get_stats(self) -> list[PortStats]:
139
+ path = _require_path(self.model.key, self._spec.stats_path, "port statistics")
140
+ return parse.parse_port_stats(await self.session.get_page(path))
141
+
142
+ async def get_poe(self) -> list[PoEStatus]:
143
+ path = _require_path(self.model.key, self._spec.poe_status_path, "PoE status")
144
+ return parse.parse_poe_status(await self.session.get_page(path))
145
+
146
+ async def get_pvids(self) -> list[tuple[int, int]]:
147
+ path = _require_path(self.model.key, self._spec.pvid_path, "port PVIDs")
148
+ return parse.parse_pvids(await self.session.get_page(path))
149
+
150
+ async def get_vlans(self) -> list[VLANInfo]:
151
+ cfg_path = _require_path(
152
+ self.model.key, self._spec.vlan_config_path, "VLAN configuration"
153
+ )
154
+ member_path = _require_path(
155
+ self.model.key, self._spec.vlan_membership_path, "VLAN membership"
156
+ )
157
+ cfg = await self.session.get_page(cfg_path)
158
+ result: list[VLANInfo] = []
159
+ for vid in parse.parse_vlan_ids(cfg):
160
+ html = await self.session.post_form(member_path, {"VLAN_ID": str(vid)})
161
+ result.append(_vlan_info(vid, html, self.model.port_count))
162
+ return result
163
+
164
+ async def get_macs(self) -> list[MacEntry]:
165
+ raise _unsupported(self.model.key, "a MAC/FDB table")
166
+
167
+ async def get_lldp(self) -> list[LLDPNeighbor]:
168
+ raise _unsupported(self.model.key, "LLDP neighbours")
169
+
170
+ async def get_sensors(self) -> list[Sensor]:
171
+ raise _unsupported(self.model.key, "box sensors")
172
+
173
+ async def get_mgmt_ip(self) -> MgmtIpConfig:
174
+ raise _unsupported(self.model.key, "management-IP config")