python-netgear-switch-library 0.0.post332__py3-none-any.whl → 0.0.post336__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.
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.0.post332'
22
- __version_tuple__ = version_tuple = (0, 0, 'post332')
21
+ __version__ = version = '0.0.post336'
22
+ __version_tuple__ = version_tuple = (0, 0, 'post336')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -103,5 +103,14 @@ class CliReader:
103
103
  def get_mgmt_ip(self) -> MgmtIpConfig:
104
104
  return parse.parse_mgmt_ip(self.session.run(self._spec.network_cmd))
105
105
 
106
+ def get_hostname(self) -> str:
107
+ """The switch's host name, from ``show hosts``.
108
+
109
+ See ``parse.parse_hostname`` for why this command and not
110
+ ``show running-config``: the two report different values, and only this
111
+ one agrees with SNMP's ``sysName``.
112
+ """
113
+ return parse.parse_hostname(self.session.run(self._spec.hosts_cmd))
114
+
106
115
  def identify(self) -> DetectedModel:
107
116
  return parse.parse_version(self.session.run(self._spec.version_cmd), MODELS)
@@ -561,8 +561,10 @@ class CliWriter:
561
561
  timeouts=timeouts or _DEFAULT_POE_TIMEOUTS,
562
562
  sleep=sleep,
563
563
  clock=clock,
564
- recovered=lambda st: st is not None
565
- and st.detect in (PoEDetect.DELIVERING, PoEDetect.SEARCHING),
564
+ recovered=lambda st: (
565
+ st is not None
566
+ and st.detect in (PoEDetect.DELIVERING, PoEDetect.SEARCHING)
567
+ ),
566
568
  timeout_message=(
567
569
  f"PoE port {port} still in FAULT after clear within {{timeout}}s"
568
570
  ),
@@ -596,6 +598,52 @@ class CliWriter:
596
598
  def _port_status(self, port: int) -> PortStatus | None:
597
599
  return next((p for p in self._reader.get_ports() if p.port == port), None)
598
600
 
601
+ # --- host name -----------------------------------------------------------
602
+
603
+ def set_hostname(self, name: str, *, force: bool = False) -> None:
604
+ """Set the switch's host name, via global-config ``hostname <name>``.
605
+
606
+ Not force-gated: renaming cannot strand the switch and is reversible by
607
+ writing the old name back, unlike ``set_mgmt_ip`` below which drops the
608
+ session that issues it. ``force`` is accepted so the signature matches
609
+ every other writer.
610
+
611
+ Verified by re-reading ``show hosts``. That command, rather than
612
+ ``show running-config``, is deliberate and load-bearing here: the two
613
+ report different values on real hardware (see
614
+ ``protocols.cli.parse.parse_hostname``), and ``show hosts`` is the one
615
+ that agrees with SNMP, so a CLI write verified this way is also
616
+ observable over SNMP.
617
+
618
+ Nothing is persisted -- no ``write memory`` -- exactly like every other
619
+ write in this module.
620
+ """
621
+ del force # accepted for a uniform writer signature; nothing to gate
622
+ if not name.strip():
623
+ # `hostname` with no argument is rejected by the device itself
624
+ # ("Command not found / Incomplete command"), so sending it would
625
+ # surface as a confusing CliCommandError from deep in _in_mode.
626
+ # CLEARING a host name is a different operation -- FASTPATH spells
627
+ # it `no hostname` -- and that has not been driven against real
628
+ # hardware here, so it is not offered rather than guessed at.
629
+ raise ValueError(
630
+ "hostname must not be empty; clearing a switch's host name is "
631
+ "`no hostname` on FASTPATH, which this library does not "
632
+ "implement because it has not been verified on a device"
633
+ )
634
+ before = self._reader.get_hostname()
635
+ self._in_mode(
636
+ [self._spec.configure_cmd],
637
+ [self._spec.hostname_config_cmd.format(name=name)],
638
+ )
639
+ after = self._reader.get_hostname()
640
+ if after != name:
641
+ raise WriteVerificationError(
642
+ f"`show hosts` reports {after!r} after setting hostname {name!r}",
643
+ before=before,
644
+ after=after,
645
+ )
646
+
599
647
  # --- management IP + reboot --------------------------------------------
600
648
 
601
649
  def set_mgmt_ip(
@@ -216,6 +216,18 @@ class NsdpReader:
216
216
  dev = self._device([Tag.PORT_PVID])
217
217
  return [(p.port_id, p.vlan_id) for p in dev.port_pvids]
218
218
 
219
+ def get_hostname(self) -> str:
220
+ """The switch's host name, from the NSDP ``HOSTNAME`` tag (0x0003).
221
+
222
+ The same value SNMP would report as ``sysName`` -- except that a Plus
223
+ switch has no SNMP agent at all, which is why NSDP carries it.
224
+
225
+ A switch that has never been named answers the tag with nothing, and
226
+ that is a real answer rather than a failure: unlike SNMP's ``sysName``,
227
+ which is a mandatory scalar, this tag is genuinely optional.
228
+ """
229
+ return self._device([Tag.HOSTNAME]).hostname or ""
230
+
219
231
  def get_mgmt_ip(self) -> MgmtIpConfig:
220
232
  return _mgmt(
221
233
  self._device([Tag.IP_ADDRESS, Tag.NETMASK, Tag.GATEWAY, Tag.DHCP_MODE])
@@ -270,6 +282,10 @@ class AsyncNsdpReader:
270
282
  dev = await self._device([Tag.PORT_PVID])
271
283
  return [(p.port_id, p.vlan_id) for p in dev.port_pvids]
272
284
 
285
+ async def get_hostname(self) -> str:
286
+ """Async twin of ``NsdpReader.get_hostname`` -- see there."""
287
+ return (await self._device([Tag.HOSTNAME])).hostname or ""
288
+
273
289
  async def get_mgmt_ip(self) -> MgmtIpConfig:
274
290
  return _mgmt(
275
291
  await self._device(
@@ -94,6 +94,18 @@ class CliModelSpec:
94
94
  environment_cmd: str = "show environment"
95
95
  network_cmd: str = "show network"
96
96
  interface_stats_cmd: str = "show interface ethernet {iface}"
97
+ # Host name. `show hosts` and NOT `show running-config | include hostname`:
98
+ # the two report DIFFERENT values, measured 2026-08-02. On m4300-16x
99
+ # (10.1.5.20) `show hosts` gives "sw-netgear-m4300-16x-poe-s2" while
100
+ # running-config gives "manage-sw-netgear-m4300-16x-poe-s2", and on
101
+ # gsm7252ps (10.1.5.22) running-config carries no hostname line at all while
102
+ # `show hosts` still reports one. `show hosts` is the one that agrees with
103
+ # SNMP's sysName, so it is what keeps the two backends returning the same
104
+ # answer for the same switch.
105
+ hosts_cmd: str = "show hosts"
106
+ # Global-config directive. Quoted on the wire by the device's own
107
+ # running-config output ('hostname "sw-netgear-m4300-24x"').
108
+ hostname_config_cmd: str = "hostname {name}"
97
109
 
98
110
  # --- physical-interface naming -----------------------------------------
99
111
  # How this model's firmware ADDRESSES one physical port in a command
@@ -30,6 +30,7 @@ from __future__ import annotations
30
30
  import re
31
31
  from typing import TYPE_CHECKING
32
32
 
33
+ from ...errors import CliCommandError
33
34
  from ...models import (
34
35
  DetectedModel,
35
36
  IpMode,
@@ -605,6 +606,46 @@ def parse_environment(text: str) -> list[Sensor]:
605
606
  return out
606
607
 
607
608
 
609
+ # ---------------------------------------------------------------------------
610
+ # show hosts -> host name
611
+ # ---------------------------------------------------------------------------
612
+
613
+
614
+ def parse_hostname(text: str) -> str:
615
+ """``show hosts`` -> the switch's host name.
616
+
617
+ The command reports far more than the name -- DNS servers, the domain list,
618
+ resolver retry counts, the static host-to-address table -- and only the
619
+ first labelled field is wanted::
620
+
621
+ Host name...................................... sw-netgear-m4300-24x
622
+ Default domain................................. Domain name is not configured
623
+ Name servers (Preference order)................ 8.8.8.8, 10.1.5.1
624
+
625
+ Captured 2026-08-02 from m4300-24x (10.1.5.13), m4300-16x (10.1.5.20) and
626
+ gsm7252ps (10.1.5.22); all three label it exactly "Host name".
627
+
628
+ This is deliberately NOT ``show running-config | include hostname``. The two
629
+ report different values: on m4300-16x running-config holds
630
+ "manage-sw-netgear-m4300-16x-poe-s2" against this command's
631
+ "sw-netgear-m4300-16x-poe-s2", and on gsm7252ps running-config has no
632
+ hostname line at all while this command still answers. ``show hosts`` is the
633
+ one that matches SNMP's sysName, so parsing it is what stops the CLI and
634
+ SNMP backends disagreeing about the same switch.
635
+
636
+ Raises rather than returning "" when the label is absent: every FASTPATH
637
+ switch measured answers it, so silence means the command failed or the
638
+ output drifted, and a blank host name would be a fabrication.
639
+ """
640
+ name = labelled_values(text).get("Host name")
641
+ if name is None:
642
+ raise CliCommandError(
643
+ "`show hosts` output carries no 'Host name' field; got: "
644
+ + " ".join(text.split())[:200]
645
+ )
646
+ return name.strip()
647
+
648
+
608
649
  # ---------------------------------------------------------------------------
609
650
  # show network -> management IP
610
651
  # ---------------------------------------------------------------------------
@@ -21,6 +21,23 @@ SYS_DESCR = "1.3.6.1.2.1.1.1.0" # sysDescr: text incl. the model name
21
21
  SYS_OBJECT_ID = "1.3.6.1.2.1.1.2.0" # sysObjectID: read-only signal, unused
22
22
  # for matching (no known OID->model table exists -- see parse.py's
23
23
  # detect_model_from_sysdescr docstring).
24
+ # sysName: the switch's host name. A STANDARD MIB-II scalar, which is why it is
25
+ # the one hostname source that also works on gs728tpp -- that agent publishes no
26
+ # Netgear vendor subtree at all.
27
+ #
28
+ # WRITABLE on every SNMP model in this fleet. Measured 2026-08-02 by SETting the
29
+ # value the switch already had: a zero-impact writability probe, since the device
30
+ # state cannot change but a read-only column still answers notWritable. All five
31
+ # accepted -- gsm7228ps (10.1.5.11) on community `public`, which is the only one
32
+ # it has, and m4300-24x (.13), m4300-16x (.20), gsm7252ps (.22) and gs728tpp
33
+ # (10.2.5.10) on `private`.
34
+ #
35
+ # NOT the same value as the FASTPATH `hostname` running-config directive. On
36
+ # m4300-16x sysName is "sw-netgear-m4300-16x-poe-s2" while running-config holds
37
+ # "manage-sw-netgear-m4300-16x-poe-s2", and on gsm7252ps running-config carries
38
+ # no hostname at all. sysName tracks `show hosts`, which is therefore what the
39
+ # CLI reader parses so that the two backends cannot disagree.
40
+ SYS_NAME = "1.3.6.1.2.1.1.5.0"
24
41
  IF_TYPE = "1.3.6.1.2.1.2.2.1.3" # ifType (6=ethernetCsmacd=physical)
25
42
  IF_ADMIN_STATUS = "1.3.6.1.2.1.2.2.1.7" # ifAdminStatus (1=up,2=down)
26
43
  IF_OPER_STATUS = "1.3.6.1.2.1.2.2.1.8" # ifOperStatus (1=up,2=down)
@@ -826,6 +826,27 @@ def _scalar_text(rows: Sequence[SnmpRow], oid: str) -> str | None:
826
826
  return None
827
827
 
828
828
 
829
+ def parse_hostname(rows: Sequence[SnmpRow]) -> str:
830
+ """Extract ``sysName`` from one exact-OID GET.
831
+
832
+ Raises rather than returning a placeholder when the scalar is absent. Every
833
+ switch in this fleet answers ``sysName`` -- it is a mandatory MIB-II scalar
834
+ -- so an absent one is a real failure to report, not an empty hostname to
835
+ invent. An empty *string* is a different thing and is passed through: a
836
+ switch with no name configured genuinely has one.
837
+ """
838
+ from . import oids
839
+
840
+ value = _scalar_text(rows, oids.SYS_NAME)
841
+ if value is None:
842
+ raise SnmpError(
843
+ f"switch did not answer sysName ({oids.SYS_NAME}); it is a mandatory "
844
+ "MIB-II scalar, so this is an agent or transport failure rather "
845
+ "than an absent hostname"
846
+ )
847
+ return value
848
+
849
+
829
850
  def parse_system_info(rows: Sequence[SnmpRow]) -> tuple[str | None, str | None]:
830
851
  """Extract the raw sysDescr/sysObjectID scalar text from one combined GET.
831
852
 
@@ -193,6 +193,14 @@ class SnmpReader:
193
193
  w(oids.IP_ADDRESS_IFINDEX), # RFC-4293 fallback (M4300)
194
194
  )
195
195
 
196
+ def get_hostname(self) -> str:
197
+ """The switch's host name, from the standard MIB-II ``sysName`` scalar.
198
+
199
+ Standard, so this works on every SNMP model -- including ``gs728tpp``,
200
+ which publishes no Netgear vendor subtree at all.
201
+ """
202
+ return parse.parse_hostname(self.client.get([oids.SYS_NAME]))
203
+
196
204
  def get_system_info(self) -> DetectedModel:
197
205
  """Identify this switch's model via sysDescr (see ``read_system_info``).
198
206
 
@@ -320,6 +328,10 @@ class AsyncSnmpReader:
320
328
  await w(oids.IP_ADDRESS_IFINDEX), # RFC-4293 fallback (M4300)
321
329
  )
322
330
 
331
+ async def get_hostname(self) -> str:
332
+ """Async twin of ``SnmpReader.get_hostname`` -- see there."""
333
+ return parse.parse_hostname(await self.client.get([oids.SYS_NAME]))
334
+
323
335
  async def get_system_info(self) -> DetectedModel:
324
336
  """Async twin of ``SnmpReader.get_system_info`` -- see there."""
325
337
  return await async_read_system_info(self.client)
@@ -238,9 +238,7 @@ def _plan_switchport_membership(
238
238
  # rebuild it from the membership the port actually has.
239
239
  allowed = _vlan_bitmap({untagged_vlan, *want_tagged})
240
240
  varbinds.append(
241
- SetVarbind(
242
- f"{oids.FASTPATH_SWITCHPORT_ALLOWED_VLANS}.{port}", allowed, "x"
243
- )
241
+ SetVarbind(f"{oids.FASTPATH_SWITCHPORT_ALLOWED_VLANS}.{port}", allowed, "x")
244
242
  )
245
243
  varbinds.append(
246
244
  SetVarbind(
@@ -558,9 +556,7 @@ class SnmpWriter:
558
556
  self.client.set(vb)
559
557
  problem = _switchport_divergence(plan, vlan, port, self._reader.get_vlans())
560
558
  if problem is not None:
561
- raise WriteVerificationError(
562
- problem, before=before, after=self._vlan(vlan)
563
- )
559
+ raise WriteVerificationError(problem, before=before, after=self._vlan(vlan))
564
560
 
565
561
  def _switchport_vlan_bitmap(self, base_oid: str, port: int) -> bytes:
566
562
  """A switchport VLAN-list column's octets, or an all-zero 512-byte map."""
@@ -596,11 +592,18 @@ class SnmpWriter:
596
592
  raw_egress = self._raw_bitmap(oids.DOT1Q_VLAN_STATIC_EGRESS, vlan)
597
593
  raw_untagged = self._raw_bitmap(oids.DOT1Q_VLAN_STATIC_UNTAGGED, vlan)
598
594
  new_egress, new_untagged = membership_bitmaps(
599
- mode=mode, port=port,
600
- egress=(raw_egress if raw_egress is not None
601
- else encode_port_bitmap(before.member_ports)),
602
- untagged=(raw_untagged if raw_untagged is not None
603
- else encode_port_bitmap(before.untagged_ports)),
595
+ mode=mode,
596
+ port=port,
597
+ egress=(
598
+ raw_egress
599
+ if raw_egress is not None
600
+ else encode_port_bitmap(before.member_ports)
601
+ ),
602
+ untagged=(
603
+ raw_untagged
604
+ if raw_untagged is not None
605
+ else encode_port_bitmap(before.untagged_ports)
606
+ ),
604
607
  width_bytes=vlan_bitmap_width(self.model),
605
608
  )
606
609
  egress_vb = SetVarbind(
@@ -704,6 +707,30 @@ class SnmpWriter:
704
707
  after=after,
705
708
  )
706
709
 
710
+ def set_hostname(self, name: str, *, force: bool = False) -> None:
711
+ """Set the switch's host name via the standard MIB-II ``sysName``.
712
+
713
+ GROUNDED, unlike ``set_mgmt_ip`` below: ``sysName`` was confirmed
714
+ writable on every SNMP model in this fleet on 2026-08-02, by SETting
715
+ each switch the value it already held. See ``oids.SYS_NAME`` for the
716
+ hosts and communities, and for why this is NOT the same value as the
717
+ FASTPATH ``hostname`` running-config directive.
718
+
719
+ Not force-gated: renaming a switch cannot strand it the way a mgmt-IP
720
+ write can, and it is trivially reversible by writing the old name back.
721
+ ``force`` is accepted so the signature matches every other writer.
722
+ """
723
+ del force # accepted for a uniform writer signature; nothing to gate
724
+ before = self._reader.get_hostname()
725
+ self.client.set_many([SetVarbind(oids.SYS_NAME, name, "s")])
726
+ after = self._reader.get_hostname()
727
+ if after != name:
728
+ raise WriteVerificationError(
729
+ f"sysName is {after!r} after writing {name!r}",
730
+ before=before,
731
+ after=after,
732
+ )
733
+
707
734
  def set_mgmt_ip(
708
735
  self, address: str, netmask: str, gateway: str, *, force: bool = False
709
736
  ) -> None:
@@ -982,11 +1009,18 @@ class AsyncSnmpWriter:
982
1009
  raw_egress = await self._raw_bitmap(oids.DOT1Q_VLAN_STATIC_EGRESS, vlan)
983
1010
  raw_untagged = await self._raw_bitmap(oids.DOT1Q_VLAN_STATIC_UNTAGGED, vlan)
984
1011
  new_egress, new_untagged = membership_bitmaps(
985
- mode=mode, port=port,
986
- egress=(raw_egress if raw_egress is not None
987
- else encode_port_bitmap(before.member_ports)),
988
- untagged=(raw_untagged if raw_untagged is not None
989
- else encode_port_bitmap(before.untagged_ports)),
1012
+ mode=mode,
1013
+ port=port,
1014
+ egress=(
1015
+ raw_egress
1016
+ if raw_egress is not None
1017
+ else encode_port_bitmap(before.member_ports)
1018
+ ),
1019
+ untagged=(
1020
+ raw_untagged
1021
+ if raw_untagged is not None
1022
+ else encode_port_bitmap(before.untagged_ports)
1023
+ ),
990
1024
  width_bytes=vlan_bitmap_width(self.model),
991
1025
  )
992
1026
  egress_vb = SetVarbind(
@@ -1078,6 +1112,19 @@ class AsyncSnmpWriter:
1078
1112
  after=after,
1079
1113
  )
1080
1114
 
1115
+ async def set_hostname(self, name: str, *, force: bool = False) -> None:
1116
+ """Async twin of ``SnmpWriter.set_hostname`` -- see there."""
1117
+ del force # accepted for a uniform writer signature; nothing to gate
1118
+ before = await self._reader.get_hostname()
1119
+ await self.client.set_many([SetVarbind(oids.SYS_NAME, name, "s")])
1120
+ after = await self._reader.get_hostname()
1121
+ if after != name:
1122
+ raise WriteVerificationError(
1123
+ f"sysName is {after!r} after writing {name!r}",
1124
+ before=before,
1125
+ after=after,
1126
+ )
1127
+
1081
1128
  async def set_mgmt_ip(
1082
1129
  self, address: str, netmask: str, gateway: str, *, force: bool = False
1083
1130
  ) -> None:
@@ -127,6 +127,44 @@ def render_network(state: VirtualSwitchState) -> str:
127
127
  )
128
128
 
129
129
 
130
+ # --- show hosts -------------------------------------------------------------
131
+
132
+
133
+ def render_hosts(state: VirtualSwitchState) -> str:
134
+ """``show hosts``, transcribed from real output captured 2026-08-02.
135
+
136
+ From m4300-24x (10.1.5.13), m4300-16x (10.1.5.20) and gsm7252ps
137
+ (10.1.5.22). All three label the name exactly "Host name", and the resolver
138
+ and static-mapping sections around it are reproduced because the reader has
139
+ to pick one field out of them -- a mock emitting only the wanted line would
140
+ not exercise that at all.
141
+
142
+ The trailing static-mapping tables are the empty form all three returned;
143
+ none had a host-to-address mapping configured.
144
+ """
145
+ return "\n".join(
146
+ [
147
+ _dotted("Host name", state.hostname),
148
+ _dotted("Default domain", "Domain name is not configured"),
149
+ _dotted("Default domain list", "Domain Name List is not configured"),
150
+ _dotted("Domain Name Lookup", "Enabled"),
151
+ _dotted("Number of retries", "2"),
152
+ _dotted("Retry timeout period", "3"),
153
+ _dotted("Name servers (Preference order)", "10.1.5.1"),
154
+ "",
155
+ "Configured host name-to-address mapping:",
156
+ "",
157
+ " Host Addresses",
158
+ "------------------------ ----------------------",
159
+ "No host name is configured to IP address",
160
+ "",
161
+ " Host Total Elapsed Type Addresses",
162
+ "---------------------- ------- ------- ---- --------------",
163
+ "No hostname is mapped to an IP address",
164
+ ]
165
+ )
166
+
167
+
130
168
  # --- show port all ----------------------------------------------------------
131
169
 
132
170
 
@@ -59,6 +59,7 @@ _COPY_RE = re.compile(r"^copy\s+(\S+)\s+(\S+)$")
59
59
 
60
60
  # --- configuration-mode commands -------------------------------------------
61
61
  _CONFIGURE_RE = re.compile(r"^config(?:ure)?(?: terminal)?$")
62
+ _HOSTNAME_RE = re.compile(r'^hostname\s+("?[^"]+"?)$')
62
63
  _VLAN_DATABASE_RE = re.compile(r"^vlan database$")
63
64
  _VLAN_CREATE_RE = re.compile(r"^vlan (\d+)$")
64
65
  _VLAN_NAME_RE = re.compile(r"^vlan name (\d+) (\S+)$")
@@ -339,6 +340,14 @@ class VirtualCliFace:
339
340
  if self._mode == _VLAN_DB:
340
341
  return self._vlan_db_command(c)
341
342
  if self._mode == _CONFIG:
343
+ m = _HOSTNAME_RE.match(c)
344
+ if m:
345
+ # The device stores the name unquoted; its running-config
346
+ # renders it quoted, and `show hosts` reports it bare. Accept
347
+ # either form on the wire so a caller that quotes is not
348
+ # silently given a name with quotes embedded in it.
349
+ self.state.hostname = m.group(1).strip().strip('"')
350
+ return _ACCEPTED
342
351
  m = _INTERFACE_RE.match(c)
343
352
  if m:
344
353
  port = cli_fastpath.port_for_iface(self.state, m.group(1))
@@ -406,6 +415,8 @@ class VirtualCliFace:
406
415
  return cli_fastpath.render_environment(self.state)
407
416
  if c == self.spec.network_cmd:
408
417
  return cli_fastpath.render_network(self.state)
418
+ if c == self.spec.hosts_cmd:
419
+ return cli_fastpath.render_hosts(self.state)
409
420
  m = _SHOW_VLAN_ID_RE.match(c)
410
421
  if m:
411
422
  return cli_fastpath.render_vlan_detail(self.state, int(m.group(1)))
@@ -2123,7 +2123,9 @@ _M4300_24X_SWITCHPORT: dict[int, _SwitchportRow] = {
2123
2123
  # VLAN (90) differs from its NATIVE VLAN (5) -- proving membership follows
2124
2124
  # col4 not col3 in trunk mode -- with a genuinely sparse allowed list.
2125
2125
  5: (
2126
- 2, 90, 5,
2126
+ 2,
2127
+ 90,
2128
+ 5,
2127
2129
  frozenset({1, 5, 6, 7, 10, 20, 41, 90, 99, 121, 141}),
2128
2130
  {1, 90},
2129
2131
  set(),
@@ -2393,6 +2395,9 @@ def seed_m4300_24x() -> VirtualSwitchState:
2393
2395
  mode="static",
2394
2396
  )
2395
2397
  state = VirtualSwitchState(
2398
+ # Measured 2026-08-02: sysName and `show hosts` both report this on the
2399
+ # real switch. Was empty here, which no real FASTPATH switch is.
2400
+ hostname="sw-netgear-m4300-24x",
2396
2401
  model_key="m4300-24x",
2397
2402
  ports=ports,
2398
2403
  vlans=vlans,
@@ -2587,6 +2592,9 @@ def seed_m4300_16x() -> VirtualSwitchState:
2587
2592
  ),
2588
2593
  ]
2589
2594
  state = VirtualSwitchState(
2595
+ # Measured 2026-08-02: sysName and `show hosts` both report this on the
2596
+ # real switch. Was empty here, which no real FASTPATH switch is.
2597
+ hostname="sw-netgear-m4300-16x-poe-s2",
2590
2598
  model_key="m4300-16x",
2591
2599
  ports=ports,
2592
2600
  vlans=vlans,
@@ -729,6 +729,13 @@ class VirtualSwitchState:
729
729
  # has no vendor subtree at all -- see the field docstring).
730
730
  default_object_id = f"{v.base}.1" if v is not None else "1.3.6.1.2.1"
731
731
  m[oids.SYS_OBJECT_ID] = ("OID", self.sys_object_id or default_object_id)
732
+ # sysName: the same host name the CLI face reports through `show hosts`
733
+ # and the web faces render, so the backends cannot disagree about it
734
+ # here any more than they do on real hardware. Projected for EVERY model
735
+ # with an SNMP backend, matching the devices: all five reachable
736
+ # switches answered sysName on 2026-08-02, including gs728tpp, which
737
+ # publishes no vendor subtree at all.
738
+ m[oids.SYS_NAME] = ("OCTETSTR", self.hostname)
732
739
 
733
740
  for port, sim in self.ports.items():
734
741
  m[f"{oids.IF_ADMIN_STATUS}.{port}"] = ("INTEGER", "1" if sim.admin else "2")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-netgear-switch-library
3
- Version: 0.0.post332
3
+ Version: 0.0.post336
4
4
  Summary: Python library and CLI to query and control Netgear switches over SNMP, NSDP and HTTP.
5
5
  Author-email: Tim Ansell <me@mith.ro>
6
6
  License-Expression: Apache-2.0
@@ -1,20 +1,20 @@
1
1
  netgear_switch/__init__.py,sha256=3P68qR3Rcf9zl5u4_SMkmloD8MnzZi6VWv7n4AMb_-4,5835
2
2
  netgear_switch/_dispatch.py,sha256=JO30TCPjE_1Y10SIGEkQybPT5EQB5z0LGfyT5YDQYMc,12615
3
- netgear_switch/_version.py,sha256=d-pPwfCr3anPWgjTk2fTrcAvIDOcRmWxnZPflC9vBPw,534
3
+ netgear_switch/_version.py,sha256=4HzOEecARHceEEKkaCZrTBoCdQsC-Q-Lt1G_iCZU2jg,534
4
4
  netgear_switch/aio_api.py,sha256=_KY6CnUesVLSAzVI7Qs9cJYxh0SyaoE_rU2-GgOnpdM,27558
5
5
  netgear_switch/capabilities.py,sha256=3TXHpSDHaM4TO5kbtC5ecIK5ammVlEea4dapW7Xh698,16658
6
- netgear_switch/cli_read.py,sha256=1mOzCGp2gZ3OoZtYLDu8sIuDaT9qngh2L01unuRcpE8,4531
7
- netgear_switch/cli_write.py,sha256=o4F2ARt00Q7-DDNrQF1xTxcqBdnHudkgE7eZfeEjUhU,30357
6
+ netgear_switch/cli_read.py,sha256=77fyvMRp3Pi9UuKf8hoGE0ySK1F1TNDz4Jcqpa-evwc,4902
7
+ netgear_switch/cli_write.py,sha256=K1vjhYs4_lb20W6UgzlZT0DKJVlT_K7Y0s3oe5-rcIY,32689
8
8
  netgear_switch/config.py,sha256=z2vV1LqsLTb83ekEJwy_nnkeUlxcknicmkmKfILH5kc,5787
9
9
  netgear_switch/errors.py,sha256=zb9fFU71o69NXBhH51OOe5tXVp1UigMd2U64TN5cTW0,2203
10
10
  netgear_switch/http_read.py,sha256=X-sZfB818Hq0RV37jjPZ2UI9t_H6Cw10PYvbBQ7LRoM,35018
11
11
  netgear_switch/http_write.py,sha256=6S1G8vh_Uu1v233OcgBbH8Vv-RmYqTKySjoxg4dk2mQ,59907
12
12
  netgear_switch/models.py,sha256=XDkd9SHG9EsfPnGDaR5jVC4g65OYQ5U_1dxlUki3FZI,5005
13
- netgear_switch/nsdp_read.py,sha256=Fvm4m1ZUUAknELIcMavljmC_x1v4AWTTYXvgAeVLJB4,11874
13
+ netgear_switch/nsdp_read.py,sha256=lupPC3O9h5j9hwrKTY7YoCWfya90_C478QbyOJMy874,12613
14
14
  netgear_switch/nsdp_write.py,sha256=Am6X4ztuiY66h2GuMA6-rE7UO4xBDRw66aa2DXYljEg,18983
15
15
  netgear_switch/registry.py,sha256=gyR8diWnq95ZqXMl9q2PLbZ3H-F3So9NjLtcA9gFvfo,19706
16
- netgear_switch/snmp_read.py,sha256=SqRLu0QP0Pv2Zrf5HE8gU22ZvP4qJqq4Gr9vjJEnNs0,13805
17
- netgear_switch/snmp_write.py,sha256=SIvOjrv5XHe0hyV_cmZ1yMkoxBumTuny9cyHI04l83M,47288
16
+ netgear_switch/snmp_read.py,sha256=QkrmhQo7VokN7zJ7DVmjgl7NU7wvK1hCUEyecpeCUpk,14329
17
+ netgear_switch/snmp_write.py,sha256=iY5ENb3M9Cf-y6ReTfcU_SflM2IhRhg6MZtprYfchaw,49221
18
18
  netgear_switch/sync_api.py,sha256=BgiYbDLrjs23k3g8KRajP9RfM7J8Wka1WUipwmVjekc,38575
19
19
  netgear_switch/cli/__init__.py,sha256=tWZVE4BHoVLwYF-KKXIyrilqFpKBkAmHGC68UEK78e4,74
20
20
  netgear_switch/cli/capture.py,sha256=Cs8O3wFsPqCosXw88XHnRkbQb6oDkp2lV8AywHzmaYI,4546
@@ -27,8 +27,8 @@ netgear_switch/mcp/__init__.py,sha256=zpdWW_mSjSaj81ZFf5y9-N_RabR2Ncb-rLm2eZ-ihX
27
27
  netgear_switch/mcp/server.py,sha256=7YIYc-eu1egE0RM_9BzJrL8rqPKAR-WkLY9W6yVkV0I,21517
28
28
  netgear_switch/protocols/__init__.py,sha256=gWdi-aLLU-zDi7XAIoIjrdvHn4bQBlZDGmZy2b6nysk,44
29
29
  netgear_switch/protocols/cli/__init__.py,sha256=3jQRQlNylAFzw9Gf8E_rpLgHMyrsE7gcnT50DS-4RDY,69
30
- netgear_switch/protocols/cli/commands.py,sha256=9R6RY-A8YxpOR872M_WGF6s-6AIIwT99z7yrGwYuTGA,21846
31
- netgear_switch/protocols/cli/parse.py,sha256=jRxKatf6-N43YsHR8EYx8TKxMRVChSmNA2EYBkwrMUo,27064
30
+ netgear_switch/protocols/cli/commands.py,sha256=KXEdB514hQQheg5k4e8lNuIF2Mxwk-EWNwz3iv3tDv4,22634
31
+ netgear_switch/protocols/cli/parse.py,sha256=pVwvXq3bLJHC0iilh-GxaI3jXDIQUhBvTQg_gQh9wdQ,28923
32
32
  netgear_switch/protocols/http/__init__.py,sha256=n_iSvNTdSqeEE8U2hbk5SYSNXrWHDYzlqok89FVnK10,85
33
33
  netgear_switch/protocols/http/crypt.py,sha256=zwFqE9ff49-j-qlvo7k-ydcG3z1l2SNJ8WLel65-w58,976
34
34
  netgear_switch/protocols/http/endpoints.py,sha256=krxd8cDbvOPecHQzk8SMbWOrdKmqlAWKRH9YcYZmZ1c,52469
@@ -45,8 +45,8 @@ netgear_switch/protocols/nsdp/types.py,sha256=vjATxuy-6MqyipiQI6T4LQVO8uvrnaUTaD
45
45
  netgear_switch/protocols/nsdp/write.py,sha256=LRY9wOc4MGDrn3Q1WuxcFYcOv3R9keqWh01tJBayu0s,6305
46
46
  netgear_switch/protocols/snmp/__init__.py,sha256=gWdi-aLLU-zDi7XAIoIjrdvHn4bQBlZDGmZy2b6nysk,44
47
47
  netgear_switch/protocols/snmp/client.py,sha256=YsSuav6kkZZrqUTHgsEwEcrP9uIm85xzdC88epsE-MU,2912
48
- netgear_switch/protocols/snmp/oids.py,sha256=jygRdZidK61oJiUfjgMTp5IXLhSl4DC7BqljDJ4Dm-Y,10353
49
- netgear_switch/protocols/snmp/parse.py,sha256=fGQvWNGC23yJbBQMRsW5Iw1VD3hDhIB4iM27ikiqnbM,40163
48
+ netgear_switch/protocols/snmp/oids.py,sha256=6bf9vEYnK-F-tbJKyNtVaKFMvTrhKAlgVNvzfs4CejU,11390
49
+ netgear_switch/protocols/snmp/parse.py,sha256=I2yr_U3BZwuYDaNdXfcS2jEHmJWCiOZxn2sYtJhr5lE,40988
50
50
  netgear_switch/protocols/snmp/write.py,sha256=jVInRdTVKjs6nH0dhZVK8Chkkccekyho0Di2y1DwFDM,4090
51
51
  netgear_switch/transport/__init__.py,sha256=h_kjWqAcadW0Tvwdp930BACZO7-fwKVFlJ68yOLINQo,80
52
52
  netgear_switch/transport/aio/__init__.py,sha256=bC64jAmHQpa5q1UUK84ANaZ5h3d-Sg7v0B3SGcc6fqI,51
@@ -63,10 +63,10 @@ netgear_switch/transport/sync/__init__.py,sha256=RF_zqpgYttAnAoU-GFDlbJt7qcAwvUe
63
63
  netgear_switch/transport/sync/nsdp_udp.py,sha256=X3OOEvr6i6A3JBUB0UpZdbVLjhomFwAZg0ieRdqvj98,6456
64
64
  netgear_switch/transport/sync/snmp_netsnmp_cli.py,sha256=NM2mB114AjrUmBnCZYTnKAvDHZt7nrHpHnOBhuTIzTo,10761
65
65
  netgear_switch/virtual/__init__.py,sha256=uNWEXBp9g4FM7MdgCoey0qeG25zH_x5BOn5A4Io5asA,415
66
- netgear_switch/virtual/cli_fastpath.py,sha256=hDf7qLSaEGC4surhhFeDgnkJHQKwd99EiPn649A_Nlk,15225
67
- netgear_switch/virtual/seed.py,sha256=c_7c1L8nHAgNa2qbjpT11xgV4xm3lgeSKdg5Pl34uTw,95417
66
+ netgear_switch/virtual/cli_fastpath.py,sha256=Mk1ER8_PRp2apT-l85Imnd8fPhQcM05RaM5bSF-WVLw,16885
67
+ netgear_switch/virtual/seed.py,sha256=RG2o-ZPzgnDqL4A_j5ilVz4tc6_pXkNtIWKkeQxdcDw,95828
68
68
  netgear_switch/virtual/server.py,sha256=L6pkdsK48nVhSOVx-TCNLQTtjTJkud-xi-EuRh5CbiQ,9335
69
- netgear_switch/virtual/state.py,sha256=jh3XXdZtEVSui33N3PNatLExYhOhb8WVs3r8S4EHr8Q,71854
69
+ netgear_switch/virtual/state.py,sha256=zykUe-U29kyP86l_vFhMoDral1jZjP49H90UBeErPp4,72343
70
70
  netgear_switch/virtual/web.py,sha256=abRn0pr_T_SycW2khBt8lvErPiq1KdiMVqSnloeG-WY,7323
71
71
  netgear_switch/virtual/web_fastpath_vlan.py,sha256=W5TsIrjRzt57jno-YAjXUiuXW_6IwIzTzPqs1N2O_Pw,19443
72
72
  netgear_switch/virtual/web_fastpath_xui.py,sha256=B2K3c_CJ5dzOzCw8ELIdMiUyCj1wmw_psp7luNh70s0,11208
@@ -78,14 +78,14 @@ netgear_switch/virtual/web_gsm7228ps.py,sha256=ZuDaVF9r14fB1yQ21LlOqlhw7QjY2kGAn
78
78
  netgear_switch/virtual/web_gsm7252ps.py,sha256=Y9clRqXOuZIKv84p3AV8ff0teEGJ5kP4JrfHPtjKuWI,20876
79
79
  netgear_switch/virtual/web_m4300.py,sha256=nCW_4MTfakH4tUXCiQeaYB4Awo3Ph-U5ew2rsQbLAf4,10863
80
80
  netgear_switch/virtual/faces/__init__.py,sha256=HmrZ8ebAu6gTnnS8F7mDfteZD5AP_-dNWfLHIbavTdo,114
81
- netgear_switch/virtual/faces/cli.py,sha256=A8WCQQAZnoThMmCmO7BY1smbuFsf29ZY2qc3hmCWlnk,19141
81
+ netgear_switch/virtual/faces/cli.py,sha256=OEOMF1lps7YqskC-PWDbNXsVkkBY5mNeFSvq92_9jdM,19740
82
82
  netgear_switch/virtual/faces/http.py,sha256=bgpwjZcPL-rXmsizGuJgWGZlNtplOuq3K_N1zRxSF5A,34272
83
83
  netgear_switch/virtual/faces/mibview.py,sha256=g4EsSfXO6ojyPDT633lEkNVmPcqzU0VdbqNFpApRdMQ,4875
84
84
  netgear_switch/virtual/faces/nsdp.py,sha256=WX9w1YWArUvm4-L2Y7QI8njvBRD4t0TVFUL2V0yuQmI,10395
85
85
  netgear_switch/virtual/faces/snmp.py,sha256=y8N3rDwO3AuHWi9Ke10WLG1yzZITpn363PunIG_ZuSk,21252
86
86
  netgear_switch/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
87
- python_netgear_switch_library-0.0.post332.dist-info/METADATA,sha256=eeTC7CoEnDaRngHgcVVH3OwD1dzXGalYpcbb82sFbzk,6682
88
- python_netgear_switch_library-0.0.post332.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
89
- python_netgear_switch_library-0.0.post332.dist-info/entry_points.txt,sha256=mf6HFDGVV0KIDI0dHkh0BNcCjHkSg1lhE70xTzB47WQ,96
90
- python_netgear_switch_library-0.0.post332.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
91
- python_netgear_switch_library-0.0.post332.dist-info/RECORD,,
87
+ python_netgear_switch_library-0.0.post336.dist-info/METADATA,sha256=SpBD08inKHzt5uS7CwoZB17Yj6cuWcBgBeNcIHPVL4g,6682
88
+ python_netgear_switch_library-0.0.post336.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
89
+ python_netgear_switch_library-0.0.post336.dist-info/entry_points.txt,sha256=mf6HFDGVV0KIDI0dHkh0BNcCjHkSg1lhE70xTzB47WQ,96
90
+ python_netgear_switch_library-0.0.post336.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
91
+ python_netgear_switch_library-0.0.post336.dist-info/RECORD,,