Ergänzt DRIVER_NAME, HTTP_FINGERPRINT, SNMP_FINGERPRINT, SSH_FINGERPRINT, PORT_SPECS und SNMP_OBJECT_ID_PREFIX gemäß docs/DISCOVERY_FINGERPRINTING.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
488 lines
16 KiB
Python
488 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
import socket
|
|
from typing import Any
|
|
|
|
from napalm.base.exceptions import ConnectionException
|
|
|
|
from napalm_device_types import AccessPointDriver, FingerprintRule, PortSpec
|
|
from napalm_device_types.models import DeviceActionResultDict
|
|
|
|
"""NAPALM driver for Sonos speakers.
|
|
|
|
Provides network-automation-style access to Sonos speakers via the
|
|
SoCo library, exposing NAPALM standard methods alongside Sonos-specific
|
|
media-playback controls.
|
|
"""
|
|
|
|
_VOLUME_RE = re.compile(r"^\d+$")
|
|
|
|
|
|
class SonosDriver(AccessPointDriver):
|
|
"""NAPALM driver for controlling Sonos speakers.
|
|
|
|
Implements the standard NAPALM interface plus media-playback methods
|
|
(play, pause, volume, etc.) using the SoCo library.
|
|
"""
|
|
|
|
VENDOR = "Sonos"
|
|
DRIVER_NAME = "sonos"
|
|
PORT_SPECS = [
|
|
PortSpec("http", 1400, ("/xml/device_description.xml",), weight=9.0, mandatory=True),
|
|
PortSpec("https", 1443, weight=4.0),
|
|
]
|
|
HTTP_FINGERPRINT = [
|
|
FingerprintRule("sonos", weight=8.0, mandatory=True),
|
|
FingerprintRule("rincon", weight=5.0),
|
|
]
|
|
|
|
def __init__(
|
|
self,
|
|
hostname: str,
|
|
username: str = "",
|
|
password: str = "",
|
|
timeout: int = 60,
|
|
optional_args: dict[str, Any] | None = None,
|
|
) -> None:
|
|
"""Initialise the driver with connection details."""
|
|
self.hostname = hostname
|
|
self.username = username
|
|
self.password = password
|
|
self.timeout = timeout
|
|
self.optional_args = optional_args or {}
|
|
|
|
self._device: Any = None
|
|
self._speaker_info: dict[str, Any] | None = None
|
|
|
|
def open(self) -> None:
|
|
"""Open a connection to the Sonos speaker."""
|
|
try:
|
|
from soco import SoCo
|
|
|
|
self._device = SoCo(self.hostname)
|
|
self._speaker_info = self._device.get_speaker_info()
|
|
except Exception as e:
|
|
# Broad catch: SoCo can raise a variety of transport/XML errors
|
|
raise ConnectionException(f"Cannot connect to Sonos at {self.hostname}: {e}")
|
|
|
|
def close(self) -> None:
|
|
"""Close the connection to the Sonos speaker."""
|
|
self._device = None
|
|
self._speaker_info = None
|
|
|
|
def is_alive(self) -> dict[str, bool]:
|
|
"""Check whether the speaker is reachable on the network."""
|
|
if self._device is None:
|
|
return {"is_alive": False}
|
|
try:
|
|
sock = socket.create_connection(
|
|
(self.hostname, 1400), timeout=5
|
|
)
|
|
sock.close()
|
|
return {"is_alive": True}
|
|
except OSError:
|
|
return {"is_alive": False}
|
|
|
|
def get_facts(self) -> dict[str, Any]:
|
|
"""Return device facts gathered during connection."""
|
|
if not self._speaker_info:
|
|
return {}
|
|
info = self._speaker_info
|
|
return {
|
|
"vendor": self.VENDOR,
|
|
"model": info.get("model_name", "Unknown"),
|
|
"serial_number": info.get("serial_number", ""),
|
|
"os_version": info.get("software_version", ""),
|
|
"hostname": info.get("player_name", self.hostname),
|
|
"fqdn": info.get("ip_address", self.hostname),
|
|
"uptime": 0,
|
|
"interface_list": self._detect_interfaces(info),
|
|
}
|
|
|
|
def _detect_interfaces(self, info: dict[str, Any]) -> list[str]:
|
|
ifaces: list[str] = []
|
|
has_eth = self._check_wired_connection()
|
|
if has_eth:
|
|
ifaces.append("eth0")
|
|
ifaces.append("wlan0")
|
|
return ifaces
|
|
|
|
def _check_wired_connection(self) -> bool:
|
|
if self._device is None:
|
|
return False
|
|
try:
|
|
props = self._device.device_properties
|
|
if isinstance(props, dict):
|
|
wifimode = props.get("WifiMode", "")
|
|
return wifimode in ("0", "", None)
|
|
except Exception:
|
|
# Broad catch: SoCo device_properties may fail for many reasons
|
|
pass
|
|
return False
|
|
|
|
def get_interfaces(self) -> dict[str, dict[str, Any]]:
|
|
"""Return interface information for this speaker."""
|
|
if not self._speaker_info:
|
|
return {}
|
|
info = self._speaker_info
|
|
mac = info.get("mac_address", "")
|
|
is_up = self.is_alive().get("is_alive", False)
|
|
has_eth = self._check_wired_connection()
|
|
|
|
interfaces: dict[str, dict[str, Any]] = {}
|
|
if has_eth:
|
|
interfaces["eth0"] = {
|
|
"is_up": is_up,
|
|
"is_enabled": True,
|
|
"description": "Ethernet",
|
|
"last_flapped": -1.0,
|
|
"speed": 100,
|
|
"mtu": 1500,
|
|
"mac_address": mac,
|
|
}
|
|
interfaces["wlan0"] = {
|
|
"is_up": is_up,
|
|
"is_enabled": True,
|
|
"description": "WiFi",
|
|
"last_flapped": -1.0,
|
|
"speed": 0,
|
|
"mtu": 1500,
|
|
"mac_address": mac,
|
|
}
|
|
return interfaces
|
|
|
|
def get_interfaces_ip(self) -> dict[str, dict[str, Any]]:
|
|
"""Return IP address information for the speaker interfaces."""
|
|
if not self._speaker_info:
|
|
return {}
|
|
info = self._speaker_info
|
|
ip = info.get("ip_address", self.hostname)
|
|
has_eth = self._check_wired_connection()
|
|
|
|
ipv4_entry = {
|
|
ip: {
|
|
"prefix_length": 24,
|
|
}
|
|
}
|
|
interfaces: dict[str, Any] = {}
|
|
if has_eth:
|
|
interfaces["eth0"] = {"ipv4": ipv4_entry, "ipv6": {}}
|
|
interfaces["wlan0"] = {"ipv4": ipv4_entry, "ipv6": {}}
|
|
return interfaces
|
|
|
|
def get_arp_table(self) -> list[dict[str, Any]]:
|
|
"""Return the ARP table (not supported on Sonos)."""
|
|
return []
|
|
|
|
def get_lldp_neighbors(self) -> dict[str, Any]:
|
|
"""Return LLDP neighbors (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_lldp_neighbors_detail(self, interface: str = "") -> dict[str, Any]:
|
|
"""Return detailed LLDP neighbors (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_config(self) -> dict[str, Any]:
|
|
"""Return the device config (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_environment(self) -> dict[str, Any]:
|
|
"""Return environment/power info (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_optics(self) -> dict[str, Any]:
|
|
"""Return optics info (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_ntp_peers(self) -> dict[str, str | None]:
|
|
"""Return NTP peers (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_ntp_servers(self) -> dict[str, str | None]:
|
|
"""Return NTP servers (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_ntp_stats(self) -> list[dict[str, Any]]:
|
|
"""Return NTP stats (not supported on Sonos)."""
|
|
return []
|
|
|
|
def ping(
|
|
self,
|
|
destination: str,
|
|
source: str = "",
|
|
ttl: int = 255,
|
|
timeout: int = 2,
|
|
size: int = 64,
|
|
count: int = 5,
|
|
vrf: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Ping a destination (not supported on Sonos)."""
|
|
return {
|
|
"success": {},
|
|
"error": "Not supported on Sonos",
|
|
}
|
|
|
|
def traceroute(
|
|
self,
|
|
destination: str,
|
|
source: str = "",
|
|
ttl: int = 255,
|
|
timeout: int = 2,
|
|
vrf: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Run traceroute (not supported on Sonos)."""
|
|
return {
|
|
"success": {},
|
|
"error": "Not supported on Sonos",
|
|
}
|
|
|
|
def get_route_to(self, destination: str) -> list[dict[str, Any]]:
|
|
"""Return routing table (not supported on Sonos)."""
|
|
return []
|
|
|
|
def get_snmp_information(self) -> dict[str, Any]:
|
|
"""Return SNMP info (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_users(self) -> dict[str, Any]:
|
|
"""Return user accounts (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_network_instances(self, name: str = "") -> dict[str, Any]:
|
|
"""Return network instances / VRFs (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_bgp_config(self, group: str = "") -> dict[str, Any]:
|
|
"""Return BGP config (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_bgp_neighbors(self) -> dict[str, Any]:
|
|
"""Return BGP neighbors (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_bgp_neighbors_detail(self, neighbor_address: str = "") -> dict[str, Any]:
|
|
"""Return detailed BGP neighbor info (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_probes_config(self) -> dict[str, Any]:
|
|
"""Return probe config (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_probes_results(self) -> dict[str, Any]:
|
|
"""Return probe results (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def get_mac_address_table(self) -> list[dict[str, Any]]:
|
|
"""Return MAC address table (not supported on Sonos)."""
|
|
return []
|
|
|
|
def get_firewall_policies(self) -> dict[str, Any]:
|
|
"""Return firewall policies (not supported on Sonos)."""
|
|
return {}
|
|
|
|
def cli(self, commands: list[str]) -> list[dict[str, Any]]:
|
|
"""Run CLI commands (not supported on Sonos)."""
|
|
return []
|
|
|
|
def commit_config(self) -> None:
|
|
"""Commit the candidate config (no-op on Sonos)."""
|
|
pass
|
|
|
|
def discard_config(self) -> None:
|
|
"""Discard the candidate config (no-op on Sonos)."""
|
|
pass
|
|
|
|
def load_replace_candidate(self, filename: str = "", config: str = "") -> None:
|
|
"""Load a replacement config (not supported on Sonos)."""
|
|
raise NotImplementedError("Sonos does not support config replacement")
|
|
|
|
def load_merge_candidate(self, filename: str = "", config: str = "") -> None:
|
|
"""Load a merge candidate (not supported on Sonos)."""
|
|
raise NotImplementedError("Sonos does not support config merge")
|
|
|
|
def compare_config(self) -> str:
|
|
"""Compare candidate with active config (no-op on Sonos)."""
|
|
return ""
|
|
|
|
def rollback(self) -> None:
|
|
"""Roll back to the previous config (no-op on Sonos)."""
|
|
pass
|
|
|
|
def get_playback_state(self) -> str:
|
|
"""Return the current playback state (PLAYING, PAUSED_PLAYBACK, STOPPED)."""
|
|
if self._device is None:
|
|
return "STOPPED"
|
|
try:
|
|
return self._device.get_playback_state()
|
|
except Exception:
|
|
# Broad catch: SoCo transport errors are unpredictable
|
|
return "STOPPED"
|
|
|
|
def get_current_track(self) -> dict[str, Any]:
|
|
"""Return information about the currently playing track."""
|
|
if self._device is None:
|
|
return {}
|
|
try:
|
|
return self._device.get_current_track_info()
|
|
except Exception:
|
|
# Broad catch: SoCo transport errors are unpredictable
|
|
return {}
|
|
|
|
def get_volume(self) -> int:
|
|
"""Return the current volume (0-100)."""
|
|
if self._device is None:
|
|
return 0
|
|
try:
|
|
return self._device.volume
|
|
except Exception:
|
|
# Broad catch: SoCo transport errors are unpredictable
|
|
return 0
|
|
|
|
def set_volume(self, volume: int) -> None:
|
|
"""Set the volume (0-100)."""
|
|
if self._device is None:
|
|
return
|
|
volume = max(0, min(100, volume))
|
|
self._device.volume = volume
|
|
|
|
def get_mute(self) -> bool:
|
|
"""Return whether the speaker is muted."""
|
|
if self._device is None:
|
|
return False
|
|
try:
|
|
return bool(self._device.mute)
|
|
except Exception:
|
|
# Broad catch: SoCo transport errors are unpredictable
|
|
return False
|
|
|
|
def set_mute(self, muted: bool) -> None:
|
|
"""Mute or unmute the speaker."""
|
|
if self._device is None:
|
|
return
|
|
self._device.mute = muted
|
|
|
|
def play(self) -> None:
|
|
"""Start playback."""
|
|
if self._device is None:
|
|
return
|
|
self._device.play()
|
|
|
|
def pause(self) -> None:
|
|
"""Pause playback."""
|
|
if self._device is None:
|
|
return
|
|
self._device.pause()
|
|
|
|
def stop(self) -> None:
|
|
"""Stop playback."""
|
|
if self._device is None:
|
|
return
|
|
self._device.stop()
|
|
|
|
def next_track(self) -> None:
|
|
"""Skip to the next track."""
|
|
if self._device is None:
|
|
return
|
|
self._device.next()
|
|
|
|
def previous_track(self) -> None:
|
|
"""Go back to the previous track."""
|
|
if self._device is None:
|
|
return
|
|
self._device.previous()
|
|
|
|
def get_group_info(self) -> dict[str, Any]:
|
|
"""Return information about the Sonos group this speaker belongs to."""
|
|
if self._device is None:
|
|
return {}
|
|
try:
|
|
group = self._device.group
|
|
coordinator = group.coordinator
|
|
members = group.members
|
|
return {
|
|
"coordinator": coordinator.player_name if coordinator else "",
|
|
"coordinator_uid": coordinator.uid if coordinator else "",
|
|
"members": [
|
|
{
|
|
"name": m.player_name,
|
|
"uid": m.uid,
|
|
"ip_address": m.ip_address,
|
|
}
|
|
for m in members
|
|
],
|
|
}
|
|
except Exception:
|
|
# Broad catch: SoCo group/network operations can fail in many ways
|
|
return {}
|
|
|
|
def get_zone_info(self) -> dict[str, Any]:
|
|
"""Return detailed zone/speaker information."""
|
|
if self._device is None:
|
|
return {}
|
|
try:
|
|
info = self._speaker_info or {}
|
|
return {
|
|
"player_name": info.get("player_name", ""),
|
|
"zone_name": info.get("zone_name", ""),
|
|
"uid": info.get("uid", ""),
|
|
"model_name": info.get("model_name", ""),
|
|
"software_version": info.get("software_version", ""),
|
|
"hardware_version": info.get("hardware_version", ""),
|
|
"serial_number": info.get("serial_number", ""),
|
|
"mac_address": info.get("mac_address", ""),
|
|
"ip_address": info.get("ip_address", self.hostname),
|
|
}
|
|
except Exception:
|
|
# Broad catch: speaker_info dict access is safe, but keep for safety
|
|
return {}
|
|
|
|
def run_device_action(self, action: str) -> DeviceActionResultDict:
|
|
"""Run a device action (play, pause, stop, next_track, previous_track, set_volume:N, toggle_mute)."""
|
|
result: DeviceActionResultDict = {
|
|
"success": False,
|
|
"action": action,
|
|
"message": "",
|
|
}
|
|
try:
|
|
if action == "play":
|
|
self.play()
|
|
result["success"] = True
|
|
result["message"] = "Playback started"
|
|
elif action == "pause":
|
|
self.pause()
|
|
result["success"] = True
|
|
result["message"] = "Playback paused"
|
|
elif action == "stop":
|
|
self.stop()
|
|
result["success"] = True
|
|
result["message"] = "Playback stopped"
|
|
elif action == "next_track":
|
|
self.next_track()
|
|
result["success"] = True
|
|
result["message"] = "Skipped to next track"
|
|
elif action == "previous_track":
|
|
self.previous_track()
|
|
result["success"] = True
|
|
result["message"] = "Skipped to previous track"
|
|
elif action.startswith("set_volume:"):
|
|
parts = action.split(":", 1)
|
|
if len(parts) == 2 and _VOLUME_RE.match(parts[1]):
|
|
vol = int(parts[1])
|
|
self.set_volume(vol)
|
|
result["success"] = True
|
|
result["message"] = f"Volume set to {vol}"
|
|
else:
|
|
result["message"] = f"Invalid volume value: {action}"
|
|
elif action == "toggle_mute":
|
|
current = self.get_mute()
|
|
self.set_mute(not current)
|
|
result["success"] = True
|
|
result["message"] = f"Mute toggled to {not current}"
|
|
else:
|
|
result["message"] = f"Unknown action: {action}"
|
|
except Exception as e:
|
|
# Broad catch: action methods wrap SoCo calls that can fail variously
|
|
result["message"] = f"Action '{action}' failed: {e}"
|
|
return result
|