feat: WAN-Status, DSL-Stats, Portfreigaben, Firmware-Warnings, PPPoE-Interface
CI / test (3.10) (push) Failing after 20s
CI / test (3.11) (push) Failing after 13s
CI / test (3.12) (push) Failing after 7s
CI / test (3.9) (push) Failing after 13s

- get_system_config(): DSL-Leitungsqualität, Firmware-Status, DNS, NTP
- get_device_warnings(): dsl_snr_low + firmware_update_available
- get_port_forwards(): IPv6-Pinholes via WANIPv6Firewall1 (TCP6/UDP6)
- get_wan_status(): robuste Service-Iteration, kein Crash bei "Auto"-Bitraten
- get_interfaces(): pppoe0-Interface, Service-Guards für LAN/WAN, "connected"-Status
- _to_float(): Helper für sichere TR-064 Feldkonvertierung
- get_device_warnings() implementiert (war als Stub vorhanden)
- Tests für alle neuen Methoden, PPPoE-Fixture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Manivong
2026-06-24 22:21:06 +02:00
co-authored by Claude Sonnet 4.6
parent 8eaf277504
commit 3b3469daa9
2 changed files with 557 additions and 76 deletions
+273 -74
View File
@@ -62,6 +62,14 @@ from napalm_device_types.models import (
_OPTIONAL_SERVICE_ERRORS = (FritzServiceError, FritzActionError, FritzConnectionException)
def _to_float(value: Any, default: float = 0.0) -> float:
"""Convert a TR-064 field to float, returning *default* for non-numeric values like 'Auto'."""
try:
return float(value or 0)
except (TypeError, ValueError):
return default
class FritzBoxDriver(ResidentialGatewayDriver):
"""NAPALM driver for AVM FritzBox (read-only, TR-064)."""
@@ -226,34 +234,52 @@ class FritzBoxDriver(ResidentialGatewayDriver):
``WLANConfiguration{1,2,3}.GetInfo``.
"""
interfaces: dict[str, dict[str, Any]] = {}
available = self.fc.services if self.fc else {}
try:
lan = self._call("LANEthernetInterfaceConfig1", "GetInfo")
interfaces["lan"] = {
"is_up": lan.get("NewStatus", "") == "Up",
"is_enabled": bool(lan.get("NewEnable", False)),
"description": "LAN",
"last_flapped": -1.0,
"mac_address": (lan.get("NewMACAddress") or "").lower(),
"speed": float(lan.get("NewMaxBitRate", 0) or 0),
"mtu": 1500,
}
except _OPTIONAL_SERVICE_ERRORS:
pass
if "LANEthernetInterfaceConfig1" in available:
try:
lan = self._call("LANEthernetInterfaceConfig1", "GetInfo")
interfaces["lan"] = {
"is_up": lan.get("NewStatus", "").lower() in ("up", "connected"),
"is_enabled": bool(lan.get("NewEnable", False)),
"description": "LAN",
"last_flapped": -1.0,
"mac_address": (lan.get("NewMACAddress") or "").lower(),
"speed": _to_float(lan.get("NewMaxBitRate")),
"mtu": 1500,
}
except _OPTIONAL_SERVICE_ERRORS:
pass
try:
link = self._call("WANCommonInterfaceConfig1", "GetCommonLinkProperties")
interfaces["wan"] = {
"is_up": link.get("NewPhysicalLinkStatus", "") == "Up",
"is_enabled": True,
"description": link.get("NewWANAccessType", "WAN"),
"last_flapped": -1.0,
"mac_address": "",
"speed": float(link.get("NewLayer1DownstreamMaxBitRate", 0) or 0) / 1000,
"mtu": 1500,
}
except _OPTIONAL_SERVICE_ERRORS:
pass
if "WANCommonInterfaceConfig1" in available:
try:
link = self._call("WANCommonInterfaceConfig1", "GetCommonLinkProperties")
interfaces["wan"] = {
"is_up": link.get("NewPhysicalLinkStatus", "").lower() in ("up", "connected"),
"is_enabled": True,
"description": link.get("NewWANAccessType", "WAN"),
"last_flapped": -1.0,
"mac_address": "",
"speed": _to_float(link.get("NewLayer1DownstreamMaxBitRate")) / 1000,
"mtu": 1500,
}
except _OPTIONAL_SERVICE_ERRORS:
pass
if "WANPPPConnection1" in available:
try:
status = self._call("WANPPPConnection1", "GetStatusInfo")
interfaces["pppoe0"] = {
"is_up": status.get("NewConnectionStatus", "").lower() == "connected",
"is_enabled": True,
"description": "PPPoE",
"last_flapped": -1.0,
"mac_address": "",
"speed": 0.0,
"mtu": 1492,
}
except _OPTIONAL_SERVICE_ERRORS:
pass
for service in self._wlan_services():
try:
@@ -261,12 +287,12 @@ class FritzBoxDriver(ResidentialGatewayDriver):
except _OPTIONAL_SERVICE_ERRORS:
continue
interfaces[service.lower()] = {
"is_up": wlan.get("NewStatus", "") == "Up",
"is_up": wlan.get("NewStatus", "").lower() in ("up", "connected"),
"is_enabled": bool(wlan.get("NewEnable", False)),
"description": wlan.get("NewSSID", ""),
"last_flapped": -1.0,
"mac_address": (wlan.get("NewBSSID") or "").lower(),
"speed": float(wlan.get("NewMaxBitRate", 0) or 0),
"speed": _to_float(wlan.get("NewMaxBitRate")),
"mtu": 1500,
}
@@ -277,7 +303,9 @@ class FritzBoxDriver(ResidentialGatewayDriver):
WAN address comes from ``GetExternalIPAddress`` (and, if available,
``X_AVM-DE_GetExternalIPv6Address``) on the active WAN connection
service. The LAN address is the address used to reach the device
service. For PPPoE connections the IP is reported on ``pppoe0`` to
match the interface name produced by :meth:`get_interfaces`.
The LAN address is the address used to reach the device
(TR-064 does not expose the router's own LAN IP generically).
"""
result: dict[str, dict[str, Any]] = {}
@@ -286,16 +314,127 @@ class FritzBoxDriver(ResidentialGatewayDriver):
service = self._wan_ip_service()
ext_ip = self._call(service, "GetExternalIPAddress").get("NewExternalIPAddress", "")
if ext_ip:
result["wan"] = {"ipv4": {ext_ip: {"prefix_length": 32}}}
wan_key = "pppoe0" if service == "WANPPPConnection1" else "wan"
result[wan_key] = {"ipv4": {ext_ip: {"prefix_length": 32}}}
ipv6 = self._wan_external_ipv6(service)
if ipv6:
result["wan"]["ipv6"] = {ipv6: {"prefix_length": 64}}
result[wan_key]["ipv6"] = {ipv6: {"prefix_length": 64}}
except _OPTIONAL_SERVICE_ERRORS:
pass
result["lan"] = {"ipv4": {self.hostname: {"prefix_length": 24}}}
return result
def get_system_config(self) -> dict[str, Any]:
"""Return a system configuration snapshot for storage as ``system_snapshot``.
Collects DSL line statistics, firmware update status, upstream DNS
servers and NTP settings. All fields are best-effort: missing
TR-064 services are silently skipped.
"""
available = self.fc.services if self.fc else {}
result: dict[str, Any] = {}
if "WANDSLInterfaceConfig1" in available:
try:
info = self._call("WANDSLInterfaceConfig1", "GetInfo")
result["dsl_enabled"] = bool(info.get("NewEnable", False))
result["dsl_status"] = info.get("NewStatus", "")
result["dsl_downstream_rate"] = int(_to_float(info.get("NewDownstreamCurrRate")))
result["dsl_upstream_rate"] = int(_to_float(info.get("NewUpstreamCurrRate")))
result["dsl_downstream_max_rate"] = int(_to_float(info.get("NewDownstreamMaxRate")))
result["dsl_upstream_max_rate"] = int(_to_float(info.get("NewUpstreamMaxRate")))
# SNR margins and attenuation are stored as dB×10 by TR-064
result["dsl_downstream_noise_margin"] = int(_to_float(info.get("NewDownstreamNoiseMargin")))
result["dsl_upstream_noise_margin"] = int(_to_float(info.get("NewUpstreamNoiseMargin")))
result["dsl_downstream_attenuation"] = int(_to_float(info.get("NewDownstreamAttenuation")))
result["dsl_upstream_attenuation"] = int(_to_float(info.get("NewUpstreamAttenuation")))
except _OPTIONAL_SERVICE_ERRORS:
pass
try:
stats = self._call("WANDSLInterfaceConfig1", "GetStatisticsTotal")
result["dsl_crc_errors"] = int(_to_float(stats.get("NewCRCErrors")))
result["dsl_fec_errors"] = int(_to_float(stats.get("NewFECErrors")))
result["dsl_hec_errors"] = int(_to_float(stats.get("NewHECErrors")))
except _OPTIONAL_SERVICE_ERRORS:
pass
if "UserInterface1" in available:
try:
ui_info = self._call("UserInterface1", "X_AVM-DE_GetInfo")
result["firmware_update_available"] = bool(
ui_info.get("NewX_AVM-DE_UpdateState", "") == "Available"
)
result["firmware_version"] = ui_info.get("NewX_AVM-DE_Version", "")
except _OPTIONAL_SERVICE_ERRORS:
try:
ui_info = self._call("UserInterface1", "GetInfo")
result["firmware_update_available"] = bool(
ui_info.get("NewUpgradeAvailable", False)
)
except _OPTIONAL_SERVICE_ERRORS:
pass
if "LANHostConfigManagement1" in available:
try:
dns_resp = self._call("LANHostConfigManagement1", "GetDNSServers")
raw = dns_resp.get("NewDNSServers", "") or ""
result["dns_servers"] = [s.strip() for s in raw.split(",") if s.strip()]
except _OPTIONAL_SERVICE_ERRORS:
pass
if "Time1" in available:
try:
time_info = self._call("Time1", "GetInfo")
result["ntp_server"] = time_info.get("NewNTPServer1", "")
result["ntp_server2"] = time_info.get("NewNTPServer2", "")
result["timezone"] = time_info.get("NewCurrentLocalTime", "")
except _OPTIONAL_SERVICE_ERRORS:
pass
return result
def get_device_warnings(self) -> list[dict[str, Any]]:
"""Return device warnings derived from DSL line quality and firmware state.
Called during the poll cycle via ``_collect_system_snapshots``.
Reads ``UserInterface1`` for firmware updates and
``WANDSLInterfaceConfig1`` for line quality.
"""
warnings: list[dict[str, Any]] = []
available = self.fc.services if self.fc else {}
if "UserInterface1" in available:
try:
ui_info = self._call("UserInterface1", "X_AVM-DE_GetInfo")
if ui_info.get("NewX_AVM-DE_UpdateState", "") == "Available":
warnings.append({
"code": "firmware_update_available",
"severity": "info",
"action": None,
"meta": {"version": ui_info.get("NewX_AVM-DE_Version", "")},
})
except _OPTIONAL_SERVICE_ERRORS:
pass
if "WANDSLInterfaceConfig1" in available:
try:
info = self._call("WANDSLInterfaceConfig1", "GetInfo")
# SNR margin is in dB×10; < 60 means < 6 dB — borderline line quality
snr_down = int(_to_float(info.get("NewDownstreamNoiseMargin")))
if 0 < snr_down < 60:
warnings.append({
"code": "dsl_snr_low",
"severity": "warning",
"action": None,
"meta": {"snr_db_x10": snr_down},
})
except _OPTIONAL_SERVICE_ERRORS:
pass
return warnings
def get_arp_table(self, vrf: str = "") -> list[dict[str, Any]]:
"""Return the ARP table, derived from :meth:`get_hosts`.
@@ -323,66 +462,126 @@ class FritzBoxDriver(ResidentialGatewayDriver):
"""Return WAN/internet connection status.
Calls ``WANCommonInterfaceConfig1.GetCommonLinkProperties`` and
``GetAddonInfos`` for line/traffic stats, plus ``GetStatusInfo`` and
``GetExternalIPAddress`` on the active WAN connection service.
``GetAddonInfos`` for physical link info, then tries each WAN
connection service (WANIPConnection1, WANPPPConnection1) to get the
logical connection status and external IP.
"""
link = self._call("WANCommonInterfaceConfig1", "GetCommonLinkProperties")
available = self.fc.services if self.fc else {}
try:
addon = self._call("WANCommonInterfaceConfig1", "GetAddonInfos")
except _OPTIONAL_SERVICE_ERRORS:
addon = {}
link: dict = {}
if "WANCommonInterfaceConfig1" in available:
try:
link = self._call("WANCommonInterfaceConfig1", "GetCommonLinkProperties")
except _OPTIONAL_SERVICE_ERRORS:
pass
service = self._wan_ip_service()
status = self._call(service, "GetStatusInfo")
ext_ip = self._call(service, "GetExternalIPAddress").get("NewExternalIPAddress", "")
addon: dict = {}
if "WANCommonInterfaceConfig1" in available:
try:
addon = self._call("WANCommonInterfaceConfig1", "GetAddonInfos")
except _OPTIONAL_SERVICE_ERRORS:
pass
# Try each WAN connection service; PPPoE devices may only work with WANPPPConnection1
status: dict = {}
ext_ip = ""
ext_ipv6 = ""
wan_service_used = ""
for svc in self._WAN_IP_SERVICES:
if svc not in available:
continue
try:
status = self._call(svc, "GetStatusInfo")
ext_ip = self._call(svc, "GetExternalIPAddress").get("NewExternalIPAddress", "")
wan_service_used = svc
break
except (FritzArrayIndexError, FritzServiceError, FritzActionError, FritzConnectionException):
continue
except Exception:
continue
if wan_service_used:
ext_ipv6 = self._wan_external_ipv6(wan_service_used)
result: WANStatusDict = {
"connection_type": link.get("NewWANAccessType", ""),
"is_connected": status.get("NewConnectionStatus", "") == "Connected",
"external_ip": ext_ip,
"uptime": int(status.get("NewUptime", 0) or 0),
"bytes_sent": int(addon.get("NewTotalBytesSent", 0) or 0),
"bytes_received": int(addon.get("NewTotalBytesReceived", 0) or 0),
"max_bitrate_up": int(link.get("NewLayer1UpstreamMaxBitRate", 0) or 0) // 1000,
"max_bitrate_down": int(link.get("NewLayer1DownstreamMaxBitRate", 0) or 0) // 1000,
"uptime": int(_to_float(status.get("NewUptime"))),
"bytes_sent": int(_to_float(addon.get("NewTotalBytesSent"))),
"bytes_received": int(_to_float(addon.get("NewTotalBytesReceived"))),
"max_bitrate_up": int(_to_float(link.get("NewLayer1UpstreamMaxBitRate"))) // 1000,
"max_bitrate_down": int(_to_float(link.get("NewLayer1DownstreamMaxBitRate"))) // 1000,
"link_status": link.get("NewPhysicalLinkStatus", ""),
}
ipv6 = self._wan_external_ipv6(service)
if ipv6:
result["external_ipv6"] = ipv6
if ext_ipv6:
result["external_ipv6"] = ext_ipv6
return result
def get_port_forwards(self) -> list[PortForwardDict]:
"""Return configured port forwarding rules.
"""Return configured port forwarding rules (IPv4 + IPv6 pinholes).
Iterates ``GetGenericPortMappingEntry`` on the active WAN connection
service until the device reports an out-of-range index.
service until the device reports an out-of-range index. IPv6 pinholes
from ``WANIPv6Firewall1`` are appended with protocol ``TCP6``/``UDP6``.
"""
service = self._wan_ip_service()
forwards: list[PortForwardDict] = []
index = 0
while True:
try:
entry = self._call(service, "GetGenericPortMappingEntry", NewPortMappingIndex=index)
except (FritzArrayIndexError, FritzConnectionException):
break
forward: PortForwardDict = {
"name": entry.get("NewPortMappingDescription", ""),
"protocol": entry.get("NewProtocol", ""),
"external_port": int(entry.get("NewExternalPort", 0) or 0),
"internal_ip": entry.get("NewInternalClient", ""),
"internal_port": int(entry.get("NewInternalPort", 0) or 0),
"enabled": bool(entry.get("NewPortMappingEnabled", False)),
}
remote_host = entry.get("NewRemoteHost", "")
if remote_host:
forward["remote_host"] = remote_host
forwards.append(forward)
index += 1
try:
service = self._wan_ip_service()
index = 0
while True:
try:
entry = self._call(service, "GetGenericPortMappingEntry", NewPortMappingIndex=index)
except (FritzArrayIndexError, FritzConnectionException):
break
forward: PortForwardDict = {
"name": entry.get("NewPortMappingDescription", ""),
"protocol": entry.get("NewProtocol", ""),
"external_port": int(entry.get("NewExternalPort", 0) or 0),
"internal_ip": entry.get("NewInternalClient", ""),
"internal_port": int(entry.get("NewInternalPort", 0) or 0),
"enabled": bool(entry.get("NewPortMappingEnabled", False)),
}
remote_host = entry.get("NewRemoteHost", "")
if remote_host:
forward["remote_host"] = remote_host
forwards.append(forward)
index += 1
except _OPTIONAL_SERVICE_ERRORS:
pass
available = self.fc.services if self.fc else {}
if "WANIPv6Firewall1" in available:
try:
fw = self._call("WANIPv6Firewall1", "GetFirewallStatus")
if fw.get("NewFirewallEnabled"):
index = 0
while True:
try:
ph = self._call(
"WANIPv6Firewall1",
"GetGenericPinholeEntry",
NewPinholeIndex=index,
)
except (FritzArrayIndexError, FritzConnectionException, FritzActionError):
break
proto_num = int(ph.get("NewProtocol", 0) or 0)
proto = "TCP6" if proto_num == 6 else ("UDP6" if proto_num == 17 else str(proto_num))
forwards.append({
"name": ph.get("NewPinholeDescription", ""),
"protocol": proto,
"external_port": int(ph.get("NewRemotePort", 0) or 0),
"internal_ip": ph.get("NewInternalIPAddress", ""),
"internal_port": int(ph.get("NewInternalPort", 0) or 0),
"enabled": True,
})
index += 1
except _OPTIONAL_SERVICE_ERRORS:
pass
return forwards
@@ -481,9 +680,9 @@ class FritzBoxDriver(ResidentialGatewayDriver):
"mac": (dev.get("NewAssociatedDeviceMACAddress") or "").lower(),
"ssid": ssid,
"radio": service,
"signal": int(dev.get("NewX_AVM-DE_SignalStrength", 0) or 0),
"signal": int(_to_float(dev.get("NewX_AVM-DE_SignalStrength"))),
"noise": 0,
"tx_rate": float(dev.get("NewX_AVM-DE_Speed", 0) or 0),
"tx_rate": _to_float(dev.get("NewX_AVM-DE_Speed")),
"rx_rate": 0.0,
"uptime": 0,
}
@@ -543,7 +742,7 @@ class FritzBoxDriver(ResidentialGatewayDriver):
except _OPTIONAL_SERVICE_ERRORS:
continue
channel = int(info.get("NewChannel", 0) or 0)
channel = int(_to_float(info.get("NewChannel")))
radios[service] = {
"enabled": bool(info.get("NewEnable", False)),
"band": "2.4GHz" if channel <= 14 else "5GHz",