Source code for netgear_switch.nsdp_write

"""Model-driven NSDP write/control over a write-capable sync or async client.

Parallel to ``snmp_write.py``. Every write performs the NSDP WRITE_REQUEST
(authenticated with whichever scheme the switch advertises -- v1 XOR or the v2
salted challenge-response) then re-reads to verify (``WriteVerificationError``
with before/after on mismatch; a bad password / transport error surfaces as
``NsdpError`` from the client first). Disruptive per-port writes to a
``protected_ports`` port are refused unless ``force=True``.

VLAN create/delete ARE implemented here (create = write the VLAN_MEMBERS tag
for a not-yet-existing VLAN id; delete = the VLAN_DESTROY action tag 0x2C00),
replacing a previous unproven "NSDP has no VLAN create/destroy tag" refusal --
see ``protocols/nsdp/write.py::vlan_destroy_tlv`` for the ngadmin evidence.

FIRMWARE NOTE, measured 2026-07-29/30: GS110EMX firmware 1.0.2.8 refuses the v1
XOR password outright (WRITE_REQUEST answered error=13/14 on ATTR_PASSWORD) and
requires the v2 salted auth. That scheme is implemented and live-verified (see
``protocols/nsdp/auth.py``), so writes DO work on that firmware; the client
picks the scheme from AUTH_V2_ENCPASS automatically. ``check_result`` names the
blamed attribute when a switch still refuses.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from .errors import (
    ProtectedPortError,
    UnsupportedCapabilityError,
    WriteVerificationError,
)
from .models import VlanMode
from .nsdp_read import AsyncNsdpReader, NsdpReader
from .protocols.nsdp.protocol import Tag
from .protocols.nsdp.write import (
    hostname_tlv,
    ipv4_tlv,
    port_name_tlv,
    pvid_tlv,
    vlan_destroy_tlv,
    vlan_members_tlv,
)
from .registry import Backend

if TYPE_CHECKING:
    from .models import PortSpeed, VLANInfo
    from .protocols.nsdp.client import AsyncNsdpWriteClient, NsdpWriteClient
    from .registry import SwitchModel
    from .snmp_write import PoeCycleTimeouts

# MEASURED, not assumed -- the exhaustive tag sweep behind ``nsdp_read._SWEEP``
# (real GS110EMX 10.1.5.25, fw 1.0.2.8, 2026-07-30) found no PoE tag anywhere in
# the 16-bit tag space, and that firmware's own web-UI nav (``GET /frame.js``,
# 37 pages) has no PoE page either. Of the three NSDP-class models, only gs305ep
# is a PSE at all, and it reads/writes PoE over its HTTP backend.
_NO_POE = (
    "NSDP has no PoE control tag (measured by an exhaustive tag sweep of a "
    "real GS110EMX, 10.1.5.25 fw 1.0.2.8, 2026-07-30 -- see nsdp_read._SWEEP); "
    "use the HTTP backend for PoE"
)
# NOT PROVEN EITHER WAY, and this says so rather than claiming a device limit.
# The sweep DID find two undocumented per-port 3-byte config tags, 0x0800 and
# 0x9400, shaped (port, 0x01, flow_control) -- byte 1 is a strong candidate for
# the web UI's PHYSICAL_MODE knob (1=Auto, 6=Disable, 2..5 fixed speeds), which
# is how that UI disables a port. What blocks this is no longer the auth: NSDP
# writes DO work on GS110EMX fw 1.0.2.8 now that the v2 salted scheme is
# implemented (PORT_PVID and VLAN_MEMBERS were both written and read back live).
# It is that NOBODY HAS PROBED these two tags with a write, and guessing at a
# per-port PHYSICAL_MODE encoding on a live switch can drop the link that is
# carrying the probe. Settling it needs a deliberate session on a spare port.
_NO_PORT_ADMIN = (
    "per-port admin-enable over NSDP is UNPROVEN on these Plus models: the "
    "measured tag inventory (GS110EMX fw 1.0.2.8) has two candidate per-port "
    "config tags (0x0800, 0x9400) whose semantics were never settled -- no "
    "write has been attempted against either, and a wrong guess can drop the "
    "port's link. Use the HTTP backend, whose port-settings page IS grounded"
)


def _require_nsdp(model: SwitchModel) -> None:
    if Backend.NSDP not in model.backends:
        raise UnsupportedCapabilityError(f"model {model.key!r} has no NSDP backend")


def _members_after(
    current: VLANInfo | None, port: int, mode: VlanMode
) -> tuple[set[int], set[int]]:
    """Read-modify-write the (members, tagged) sets for one membership change."""
    members = set(current.member_ports) if current is not None else set()
    tagged = set(current.tagged_ports) if current is not None else set()
    if mode is VlanMode.EXCLUDED:
        members.discard(port)
        tagged.discard(port)
    else:
        members.add(port)
        tagged.add(port) if mode is VlanMode.TAGGED else tagged.discard(port)
    return members, tagged


def _membership_ok(after: VLANInfo | None, port: int, mode: VlanMode) -> bool:
    if after is None:
        return mode is VlanMode.EXCLUDED
    in_members = port in after.member_ports
    in_tagged = port in after.tagged_ports
    if mode is VlanMode.EXCLUDED:
        return not in_members
    return in_members and (in_tagged == (mode is VlanMode.TAGGED))


[docs] class NsdpWriter: """Synchronous NSDP write facade over one switch.""" def __init__( self, client: NsdpWriteClient, model: SwitchModel, *, password: str, protected_ports: frozenset[int] = frozenset(), ) -> None: _require_nsdp(model) self.client = client self.model = model self._password = password self.protected_ports = protected_ports self._reader = NsdpReader(client, model) def _guard(self, port: int, force: bool) -> None: if port in self.protected_ports and not force: raise ProtectedPortError( f"port {port} is protected; pass force=True to override" ) def _vlan(self, vlan: int) -> VLANInfo | None: return next((v for v in self._reader.get_vlans() if v.vlan_id == vlan), None) def _require_vlan_exists(self, vlan: int) -> None: """Refuse a PVID pointing at a VLAN this switch does not have. ``NsdpError`` (not UnsupportedCapabilityError): the operation IS supported, the switch simply has no such VLAN right now. Each backend raises its own transport-native error for this precondition, matching what ``set_vlan_membership`` already does there; all of them derive from ``NetgearSwitchError``, so a backend-agnostic caller can catch one type. """ from .protocols.nsdp.client import NsdpError if self._vlan(vlan) is None: raise NsdpError(f"VLAN {vlan} does not exist")
[docs] def set_hostname(self, name: str, *, force: bool = False) -> None: """Set the switch's host name over NSDP (tag 0x0003). The Plus family's only write route for this: those switches have no SNMP agent and no CLI, so without this they cannot be renamed at all. Not force-gated -- renaming cannot strand a switch, and it is reversible by writing the old name back. Verified by re-reading the tag. """ del force # accepted for a uniform writer signature; nothing to gate if not name.strip(): raise ValueError("hostname must not be empty") before = self._reader.get_hostname() self.client.write([hostname_tlv(name)], password=self._password) after = self._reader.get_hostname() if after != name: raise WriteVerificationError( f"host name is {after!r} after writing {name!r}", before=before, after=after, )
[docs] def set_port_description( self, port: int, description: str, *, force: bool = False ) -> None: """Set a port's description over NSDP tag 0xB000 (``PORT_NAME``). The READ encoding is measured on three real GS110EMX units -- one TLV per port, byte 0 the port number and the rest the description -- and the write is that same shape (``port_name_tlv``). The write itself has NOT been exercised against hardware: the three Plus units in this fleet were powered off when it was attempted. Verify-after-write below is the guard that makes that safe to ship -- a wrong shape cannot pass silently. """ self._guard(port, force) before = {p.port: p.description for p in self._reader.get_ports()} self.client.write([port_name_tlv(port, description)], password=self._password) after = {p.port: p.description for p in self._reader.get_ports()} want = description or None if after.get(port) != want: raise WriteVerificationError( f"description for port {port} did not read back as {want!r}", before=before.get(port), after=after.get(port), )
[docs] def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None: # LIVE-VERIFIED over NSDP v2 on a GS110EMX (fw 1.0.2.8): PORT_PVID # (0x3000) IS writable (the reference spec's READ-ONLY marking was # conservative). Constraint observed live: the target VLAN must be one # the port is already a member of, else the switch rejects with header # error 2 (surfaced as NsdpError by check_result). verify-after-write # below is the runtime guard. self._guard(port, force) # Precondition, matching every other backend: a PVID for a VLAN that # does not exist is refused here rather than sent. This switch happens # to reject it, but that is not universal -- a GS728TPP ACCEPTS the # equivalent write and reads it back (measured), so the check belongs in # the library, on every backend, not in the hope that firmware objects. self._require_vlan_exists(vlan) before = dict(self._reader.get_pvids()) self.client.write([pvid_tlv(port, vlan)], password=self._password) after = dict(self._reader.get_pvids()) if after.get(port) != vlan: raise WriteVerificationError( f"PVID for port {port} did not read back as {vlan}", before=before.get(port), after=after.get(port), )
[docs] def set_vlan_membership( self, vlan: int, port: int, mode: VlanMode, *, force: bool = False ) -> None: # LIVE-VERIFIED over NSDP v2 on a GS110EMX (fw 1.0.2.8): VLAN_MEMBERS # (0x2800) IS writable -- a link-down port was flipped tagged->excluded # ->tagged in an existing VLAN and read back exactly. The reference # spec's READ-ONLY marking was conservative. verify-after-write guards. self._guard(port, force) before = self._vlan(vlan) members, tagged = _members_after(before, port, mode) self.client.write( [vlan_members_tlv(vlan, members, tagged, self.model.port_count)], password=self._password, ) after = self._vlan(vlan) if not _membership_ok(after, port, mode): raise WriteVerificationError( f"VLAN {vlan} membership for port {port} did not read back as {mode}", before=before, after=after, )
[docs] def set_mgmt_ip( self, address: str, netmask: str, gateway: str, *, force: bool = False ) -> None: # Force-gated: a wrong management-IP write can strand the switch. # The NSDP write+auth path is LIVE-VERIFIED (v2) via set_pvid / # set_vlan_membership on a GS110EMX; IP/netmask/gateway are R/W in the # spec and go through the same authenticated write. NOT live-exercised # here on purpose -- changing a switch's management IP is barred by the # safety rules. verify-after-write below is the runtime guard. if not force: raise ProtectedPortError( "set_mgmt_ip can strand the switch; pass force=True to override" ) before = self._reader.get_mgmt_ip() self.client.write( [ ipv4_tlv(Tag.IP_ADDRESS, address), ipv4_tlv(Tag.NETMASK, netmask), ipv4_tlv(Tag.GATEWAY, gateway), ], password=self._password, ) after = self._reader.get_mgmt_ip() if (after.address, after.netmask, after.gateway) != (address, netmask, gateway): raise WriteVerificationError( "management IP did not read back as written", before=before, after=after, )
[docs] def set_poe(self, port: int, on: bool, *, force: bool = False) -> None: raise UnsupportedCapabilityError(_NO_POE)
[docs] def cycle_poe( self, port: int, *, force: bool = False, timeouts: PoeCycleTimeouts | None = None, ) -> None: # timeouts accepted-but-unused: matches SnmpWriter's signature so the # facade's SnmpWriter | NsdpWriter union call site typechecks; NSDP has # no PoE control tag at all, so there is nothing to time. raise UnsupportedCapabilityError(_NO_POE)
[docs] def clear_poe_fault( self, port: int, *, force: bool = False, timeouts: PoeCycleTimeouts | None = None, ) -> None: raise UnsupportedCapabilityError(_NO_POE)
[docs] def set_port_enabled( self, port: int, enabled: bool, *, force: bool = False ) -> None: raise UnsupportedCapabilityError(_NO_PORT_ADMIN)
[docs] def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None: """Create ``vlan`` by writing an EMPTY VLAN_MEMBERS record for it. NSDP has no separate "add VLAN" action: the 802.1Q VLAN table is the set of VLAN ids that have a VLAN_MEMBERS (0x2800) record, so writing one for an id the switch does not yet list is the create. (ngadmin does the same thing -- ``ngadmin_setVLANDotConf`` only ever writes the membership attribute, and its VLAN list is whatever comes back from reading it.) ``name`` is accepted and ignored: the tag carries a VLAN id and two port bitmaps and no name field, and there is no name tag in the measured inventory -- so a name is silently unstorable here rather than pretended. """ del force # creating an empty VLAN moves no port; nothing to protect. del name if any(v.vlan_id == vlan for v in self._reader.get_vlans()): return # already present: creating it again is a no-op, not an error self.client.write( [vlan_members_tlv(vlan, (), (), self.model.port_count)], password=self._password, ) after = self._reader.get_vlans() if not any(v.vlan_id == vlan for v in after): raise WriteVerificationError( f"VLAN {vlan} was not created over NSDP", before=None, after=[v.vlan_id for v in after], )
[docs] def delete_vlan(self, vlan: int, *, force: bool = False) -> None: """Delete ``vlan`` with the VLAN_DESTROY action tag (0x2C00). Grounded in ngadmin's ``ngadmin_VLANDestroy`` -- see ``protocols/nsdp/write.py::vlan_destroy_tlv``. Deleting a VLAN drops every member port out of it, so it is force-gated exactly like the other disruptive writes. """ if not force: raise ProtectedPortError( f"deleting VLAN {vlan} removes every member port from it; " "pass force=True to override" ) before = [v.vlan_id for v in self._reader.get_vlans()] self.client.write([vlan_destroy_tlv(vlan)], password=self._password) after = [v.vlan_id for v in self._reader.get_vlans()] if vlan in after: raise WriteVerificationError( f"VLAN {vlan} was not deleted over NSDP", before=before, after=after, )
[docs] def set_port_speed( self, port: int, speed: PortSpeed, *, force: bool = False ) -> None: """This backend cannot configure a port's speed. Refused by name rather than approximated: NSDP's per-port speed byte is a LINK-STATE code, not a setting -- its own value 0x00 is ``DOWN`` (see ``protocols.nsdp.types.LinkSpeed``), which a configuration field could not mean. No speed/duplex ADMIN tag has been identified in the tag inventory captured from live GS110EMX units. """ raise UnsupportedCapabilityError( f"model {self.model.key!r}: NSDP publishes the negotiated link " "speed only; no speed/duplex admin tag has been identified" )
[docs] def set_flow_control( self, port: int, enabled: bool, *, force: bool = False ) -> None: """This backend cannot configure flow control. Refused by name: NSDP's PORT_STATUS carries a flow-control byte that this library READS, but no write TLV for it has been identified in the tag inventory captured from live GS110EMX units. """ raise UnsupportedCapabilityError( f"model {self.model.key!r}: NSDP reports flow control but no " "write tag for it has been identified" )
[docs] def add_syslog_collector( self, host: str, *, port: int = 514, severity: int = 6, force: bool = False ) -> None: """This backend cannot add a syslog collector. Refused by name: NSDP has no logging surface at all. That is measured absence -- an exhaustive tag sweep of a live GS110EMX turned up no syslog tag of any kind, which is the same finding that keeps NSDP off ``get_syslog``. """ raise UnsupportedCapabilityError( f"model {self.model.key!r}: this backend has no grounded " "syslog-collector row write" )
[docs] def remove_syslog_collector(self, host: str, *, force: bool = False) -> None: """This backend cannot remove a syslog collector. Refused by name: NSDP has no logging surface at all. That is measured absence -- an exhaustive tag sweep of a live GS110EMX turned up no syslog tag of any kind, which is the same finding that keeps NSDP off ``get_syslog``. """ raise UnsupportedCapabilityError( f"model {self.model.key!r}: this backend has no grounded " "syslog-collector row write" )
[docs] def set_syslog_enabled(self, enabled: bool, *, force: bool = False) -> None: """This backend does not serve a remote-logging toggle. Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none. """ raise UnsupportedCapabilityError( f"model {self.model.key!r}: this backend does not expose " "a remote-logging toggle" )
[docs] class AsyncNsdpWriter: """Asynchronous NSDP write facade (mirror of NsdpWriter).""" def __init__( self, client: AsyncNsdpWriteClient, model: SwitchModel, *, password: str, protected_ports: frozenset[int] = frozenset(), ) -> None: _require_nsdp(model) self.client = client self.model = model self._password = password self.protected_ports = protected_ports self._reader = AsyncNsdpReader(client, model) def _guard(self, port: int, force: bool) -> None: if port in self.protected_ports and not force: raise ProtectedPortError( f"port {port} is protected; pass force=True to override" ) async def _vlan(self, vlan: int) -> VLANInfo | None: vlans = await self._reader.get_vlans() return next((v for v in vlans if v.vlan_id == vlan), None) async def _require_vlan_exists(self, vlan: int) -> None: """Async twin of ``NsdpWriter._require_vlan_exists`` (see its docs).""" from .protocols.nsdp.client import NsdpError if await self._vlan(vlan) is None: raise NsdpError(f"VLAN {vlan} does not exist")
[docs] async def set_port_description( self, port: int, description: str, *, force: bool = False ) -> None: """Async twin of ``NsdpWriter.set_port_description`` -- see it.""" self._guard(port, force) before = {p.port: p.description for p in await self._reader.get_ports()} await self.client.write( [port_name_tlv(port, description)], password=self._password ) after = {p.port: p.description for p in await self._reader.get_ports()} want = description or None if after.get(port) != want: raise WriteVerificationError( f"description for port {port} did not read back as {want!r}", before=before.get(port), after=after.get(port), )
[docs] async def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None: # LIVE-VERIFIED over NSDP v2 on a GS110EMX (fw 1.0.2.8): PORT_PVID # (0x3000) IS writable. Constraint observed live: the target VLAN must # be one the port is already a member of, else the switch rejects with # header error 2. verify-after-write below is the runtime guard. self._guard(port, force) # Precondition -- see NsdpWriter._require_vlan_exists. await self._require_vlan_exists(vlan) before = dict(await self._reader.get_pvids()) await self.client.write([pvid_tlv(port, vlan)], password=self._password) after = dict(await self._reader.get_pvids()) if after.get(port) != vlan: raise WriteVerificationError( f"PVID for port {port} did not read back as {vlan}", before=before.get(port), after=after.get(port), )
[docs] async def set_vlan_membership( self, vlan: int, port: int, mode: VlanMode, *, force: bool = False ) -> None: # UNVERIFIED pending a hardware capture: VLAN_MEMBERS (0x2800) is # documented READ-ONLY in the reference spec, so a real switch may reject # this write (verify-after-write below is the guard). Same house style as # snmp_write.py:set_mgmt_ip's unverified-OID note. self._guard(port, force) before = await self._vlan(vlan) members, tagged = _members_after(before, port, mode) await self.client.write( [vlan_members_tlv(vlan, members, tagged, self.model.port_count)], password=self._password, ) after = await self._vlan(vlan) if not _membership_ok(after, port, mode): raise WriteVerificationError( f"VLAN {vlan} membership for port {port} did not read back as {mode}", before=before, after=after, )
[docs] async def set_mgmt_ip( self, address: str, netmask: str, gateway: str, *, force: bool = False ) -> None: # Force-gated: a wrong management-IP write can strand the switch. # The NSDP write+auth path is LIVE-VERIFIED (v2) via set_pvid / # set_vlan_membership on a GS110EMX; IP/netmask/gateway are R/W in the # spec and go through the same authenticated write. NOT live-exercised # here on purpose -- changing a switch's management IP is barred by the # safety rules. verify-after-write below is the runtime guard. if not force: raise ProtectedPortError( "set_mgmt_ip can strand the switch; pass force=True to override" ) before = await self._reader.get_mgmt_ip() await self.client.write( [ ipv4_tlv(Tag.IP_ADDRESS, address), ipv4_tlv(Tag.NETMASK, netmask), ipv4_tlv(Tag.GATEWAY, gateway), ], password=self._password, ) after = await self._reader.get_mgmt_ip() if (after.address, after.netmask, after.gateway) != (address, netmask, gateway): raise WriteVerificationError( "management IP did not read back as written", before=before, after=after, )
[docs] async def set_poe(self, port: int, on: bool, *, force: bool = False) -> None: raise UnsupportedCapabilityError(_NO_POE)
[docs] async def cycle_poe( self, port: int, *, force: bool = False, timeouts: PoeCycleTimeouts | None = None, ) -> None: # timeouts accepted-but-unused: matches AsyncSnmpWriter's signature so # the facade's AsyncSnmpWriter | AsyncNsdpWriter union call site # typechecks; NSDP has no PoE control tag at all, so there is nothing # to time. raise UnsupportedCapabilityError(_NO_POE)
[docs] async def clear_poe_fault( self, port: int, *, force: bool = False, timeouts: PoeCycleTimeouts | None = None, ) -> None: raise UnsupportedCapabilityError(_NO_POE)
[docs] async def set_port_enabled( self, port: int, enabled: bool, *, force: bool = False ) -> None: raise UnsupportedCapabilityError(_NO_PORT_ADMIN)
[docs] async def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None: """Async twin of ``NsdpWriter.create_vlan`` -- see there.""" del force, name if any(v.vlan_id == vlan for v in await self._reader.get_vlans()): return await self.client.write( [vlan_members_tlv(vlan, (), (), self.model.port_count)], password=self._password, ) after = await self._reader.get_vlans() if not any(v.vlan_id == vlan for v in after): raise WriteVerificationError( f"VLAN {vlan} was not created over NSDP", before=None, after=[v.vlan_id for v in after], )
[docs] async def delete_vlan(self, vlan: int, *, force: bool = False) -> None: """Async twin of ``NsdpWriter.delete_vlan`` -- see there.""" if not force: raise ProtectedPortError( f"deleting VLAN {vlan} removes every member port from it; " "pass force=True to override" ) before = [v.vlan_id for v in await self._reader.get_vlans()] await self.client.write([vlan_destroy_tlv(vlan)], password=self._password) after = [v.vlan_id for v in await self._reader.get_vlans()] if vlan in after: raise WriteVerificationError( f"VLAN {vlan} was not deleted over NSDP", before=before, after=after, )
[docs] async def set_hostname(self, name: str, *, force: bool = False) -> None: """This backend does not serve a host-name write. Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none. """ raise UnsupportedCapabilityError( f"model {self.model.key!r}: this backend does not expose a host-name write" )
[docs] async def set_port_speed( self, port: int, speed: PortSpeed, *, force: bool = False ) -> None: """This backend cannot configure a port's speed. Refused by name rather than approximated: NSDP's per-port speed byte is a LINK-STATE code, not a setting -- its own value 0x00 is ``DOWN`` (see ``protocols.nsdp.types.LinkSpeed``), which a configuration field could not mean. No speed/duplex ADMIN tag has been identified in the tag inventory captured from live GS110EMX units. """ raise UnsupportedCapabilityError( f"model {self.model.key!r}: NSDP publishes the negotiated link " "speed only; no speed/duplex admin tag has been identified" )
[docs] async def set_flow_control( self, port: int, enabled: bool, *, force: bool = False ) -> None: """This backend cannot configure flow control. Refused by name: NSDP's PORT_STATUS carries a flow-control byte that this library READS, but no write TLV for it has been identified in the tag inventory captured from live GS110EMX units. """ raise UnsupportedCapabilityError( f"model {self.model.key!r}: NSDP reports flow control but no " "write tag for it has been identified" )
[docs] async def add_syslog_collector( self, host: str, *, port: int = 514, severity: int = 6, force: bool = False ) -> None: """This backend cannot add a syslog collector. Refused by name: NSDP has no logging surface at all. That is measured absence -- an exhaustive tag sweep of a live GS110EMX turned up no syslog tag of any kind, which is the same finding that keeps NSDP off ``get_syslog``. """ raise UnsupportedCapabilityError( f"model {self.model.key!r}: this backend has no grounded " "syslog-collector row write" )
[docs] async def remove_syslog_collector(self, host: str, *, force: bool = False) -> None: """This backend cannot remove a syslog collector. Refused by name: NSDP has no logging surface at all. That is measured absence -- an exhaustive tag sweep of a live GS110EMX turned up no syslog tag of any kind, which is the same finding that keeps NSDP off ``get_syslog``. """ raise UnsupportedCapabilityError( f"model {self.model.key!r}: this backend has no grounded " "syslog-collector row write" )
[docs] async def set_syslog_enabled(self, enabled: bool, *, force: bool = False) -> None: """This backend does not serve a remote-logging toggle. Refused by name rather than returned empty: an empty answer here would be indistinguishable from a switch that genuinely has none. """ raise UnsupportedCapabilityError( f"model {self.model.key!r}: this backend does not expose " "a remote-logging toggle" )