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
+284 -2
View File
@@ -10,7 +10,7 @@ from fritzconnection.core.exceptions import (
)
from napalm.base.exceptions import ConnectionClosedException, ConnectionException
from napalm_fritzbox.fritzbox import FritzBoxDriver
from napalm_fritzbox.fritzbox import FritzBoxDriver, _to_float
# ---------------------------------------------------------------------------
# Sample TR-064 responses
@@ -112,6 +112,71 @@ WLAN2_INFO = {
WLAN2_TOTAL_ASSOCIATIONS = {"NewTotalAssociations": 0}
LAN_INFO_AUTO_RATE = {
"NewEnable": True,
"NewStatus": "Up",
"NewMACAddress": "AA:BB:CC:DD:EE:01",
"NewMaxBitRate": "Auto",
}
PPP_STATUS_INFO = {
"NewConnectionStatus": "Connected",
"NewLastConnectionError": "ERROR_NONE",
"NewUptime": 7200,
}
PPP_EXTERNAL_IP = {"NewExternalIPAddress": "203.0.113.99"}
DSL_INFO = {
"NewEnable": True,
"NewStatus": "Up",
"NewDownstreamCurrRate": 100000,
"NewUpstreamCurrRate": 40000,
"NewDownstreamMaxRate": 120000,
"NewUpstreamMaxRate": 50000,
"NewDownstreamNoiseMargin": 120, # 12.0 dB
"NewUpstreamNoiseMargin": 80, # 8.0 dB
"NewDownstreamAttenuation": 150, # 15.0 dB
"NewUpstreamAttenuation": 90, # 9.0 dB
}
DSL_STATS = {
"NewCRCErrors": 5,
"NewFECErrors": 12,
"NewHECErrors": 0,
}
UI_INFO = {
"NewX_AVM-DE_UpdateState": "Available",
"NewX_AVM-DE_Version": "08.20",
}
UI_INFO_NO_UPDATE = {
"NewX_AVM-DE_UpdateState": "None",
"NewX_AVM-DE_Version": "08.20",
}
DNS_SERVERS = {"NewDNSServers": "1.1.1.1,8.8.8.8"}
TIME_INFO = {
"NewNTPServer1": "ntp.example.com",
"NewNTPServer2": "",
"NewCurrentLocalTime": "2026-06-24T16:00:00+02:00",
}
IPV6_FIREWALL_STATUS = {"NewFirewallEnabled": True, "NewInboundPinholeAllowed": True}
IPV6_PINHOLES = [
{
"NewPinholeDescription": "SSH6",
"NewProtocol": 6,
"NewRemotePort": 0,
"NewInternalIPAddress": "2001:db8::10",
"NewInternalPort": 22,
"NewLeaseTime": 0,
}
]
# ---------------------------------------------------------------------------
# call_action dispatch
@@ -124,11 +189,19 @@ _SIMPLE_RESPONSES = {
("WANCommonInterfaceConfig1", "GetAddonInfos"): WAN_ADDON_INFOS,
("WANIPConnection1", "GetStatusInfo"): WAN_STATUS_INFO,
("WANIPConnection1", "GetExternalIPAddress"): WAN_EXTERNAL_IP,
("WANPPPConnection1", "GetStatusInfo"): PPP_STATUS_INFO,
("WANPPPConnection1", "GetExternalIPAddress"): PPP_EXTERNAL_IP,
("Hosts1", "GetHostNumberOfEntries"): HOSTS_COUNT,
("WLANConfiguration1", "GetInfo"): WLAN1_INFO,
("WLANConfiguration1", "GetTotalAssociations"): WLAN1_TOTAL_ASSOCIATIONS,
("WLANConfiguration2", "GetInfo"): WLAN2_INFO,
("WLANConfiguration2", "GetTotalAssociations"): WLAN2_TOTAL_ASSOCIATIONS,
("WANDSLInterfaceConfig1", "GetInfo"): DSL_INFO,
("WANDSLInterfaceConfig1", "GetStatisticsTotal"): DSL_STATS,
("UserInterface1", "X_AVM-DE_GetInfo"): UI_INFO,
("LANHostConfigManagement1", "GetDNSServers"): DNS_SERVERS,
("Time1", "GetInfo"): TIME_INFO,
("WANIPv6Firewall1", "GetFirewallStatus"): IPV6_FIREWALL_STATUS,
}
@@ -145,6 +218,12 @@ def _call_action(service, action, **kwargs):
raise FritzArrayIndexError("index out of range")
return PORT_FORWARDS[index]
if (service, action) == ("WANIPv6Firewall1", "GetGenericPinholeEntry"):
index = kwargs["NewPinholeIndex"]
if index >= len(IPV6_PINHOLES):
raise FritzArrayIndexError("index out of range")
return IPV6_PINHOLES[index]
if (service, action) == ("WLANConfiguration1", "GetGenericAssociatedDeviceInfo"):
index = kwargs["NewAssociatedDeviceIndex"]
if index >= 1:
@@ -167,7 +246,7 @@ def _call_action(service, action, **kwargs):
@pytest.fixture
def driver():
"""Return a driver instance with a mocked FritzConnection."""
"""Return a driver instance with a mocked FritzConnection (WANIPConnection1 / cable)."""
mock_fc = MagicMock()
mock_fc.services = {
"DeviceInfo1": MagicMock(),
@@ -177,6 +256,31 @@ def driver():
"Hosts1": MagicMock(),
"WLANConfiguration1": MagicMock(),
"WLANConfiguration2": MagicMock(),
"WANDSLInterfaceConfig1": MagicMock(),
"UserInterface1": MagicMock(),
"LANHostConfigManagement1": MagicMock(),
"Time1": MagicMock(),
"WANIPv6Firewall1": MagicMock(),
}
mock_fc.call_action.side_effect = _call_action
drv = FritzBoxDriver(hostname="fritz.box", username="user", password="pass")
drv.fc = mock_fc
yield drv
@pytest.fixture
def pppoe_driver():
"""Return a driver instance simulating a PPPoE FritzBox (WANPPPConnection1)."""
mock_fc = MagicMock()
mock_fc.services = {
"DeviceInfo1": MagicMock(),
"LANEthernetInterfaceConfig1": MagicMock(),
"WANCommonInterfaceConfig1": MagicMock(),
"WANPPPConnection1": MagicMock(),
"Hosts1": MagicMock(),
"WLANConfiguration1": MagicMock(),
"WLANConfiguration2": MagicMock(),
}
mock_fc.call_action.side_effect = _call_action
@@ -462,3 +566,181 @@ class TestGetRadioStatus:
assert radio["enabled"] is False
assert radio["band"] == "2.4GHz"
assert radio["channel"] == 1
# ---------------------------------------------------------------------------
# _to_float helper
# ---------------------------------------------------------------------------
class TestToFloat:
def test_numeric_string(self):
assert _to_float("1000") == 1000.0
def test_auto_string_returns_default(self):
assert _to_float("Auto") == 0.0
def test_none_returns_default(self):
assert _to_float(None) == 0.0
def test_int(self):
assert _to_float(250000000) == 250000000.0
def test_custom_default(self):
assert _to_float("bogus", default=-1.0) == -1.0
# ---------------------------------------------------------------------------
# PPPoE interface (WANPPPConnection1)
# ---------------------------------------------------------------------------
class TestPPPoEInterface:
def test_pppoe0_present_when_ppp_service_available(self, pppoe_driver):
interfaces = pppoe_driver.get_interfaces()
assert "pppoe0" in interfaces
def test_pppoe0_is_up(self, pppoe_driver):
assert pppoe_driver.get_interfaces()["pppoe0"]["is_up"] is True
def test_pppoe0_mtu(self, pppoe_driver):
assert pppoe_driver.get_interfaces()["pppoe0"]["mtu"] == 1492
def test_pppoe0_not_present_without_ppp_service(self, driver):
# driver fixture uses WANIPConnection1 only — no PPPoE
assert "pppoe0" not in driver.get_interfaces()
def test_pppoe_ip_on_pppoe0_key(self, pppoe_driver):
result = pppoe_driver.get_interfaces_ip()
assert "pppoe0" in result
assert "203.0.113.99" in result["pppoe0"]["ipv4"]
def test_cable_ip_on_wan_key(self, driver):
result = driver.get_interfaces_ip()
assert "wan" in result
assert "203.0.113.7" in result["wan"]["ipv4"]
# ---------------------------------------------------------------------------
# Auto/non-numeric MaxBitRate in get_interfaces
# ---------------------------------------------------------------------------
class TestAutoMaxBitRate:
def test_lan_speed_zero_when_auto(self, driver):
driver.fc.call_action.side_effect = lambda svc, act, **kw: (
LAN_INFO_AUTO_RATE if (svc, act) == ("LANEthernetInterfaceConfig1", "GetInfo")
else _call_action(svc, act, **kw)
)
interfaces = driver.get_interfaces()
assert interfaces["lan"]["speed"] == 0.0
# ---------------------------------------------------------------------------
# get_device_warnings
# ---------------------------------------------------------------------------
class TestGetDeviceWarnings:
def test_returns_empty_list(self, driver):
# UI_INFO has UpdateState=Available → one warning expected
warnings = driver.get_device_warnings()
codes = [w["code"] for w in warnings]
assert "firmware_update_available" in codes
def test_no_firmware_warning_when_no_update(self, driver):
driver.fc.call_action.side_effect = lambda svc, act, **kw: (
UI_INFO_NO_UPDATE if (svc, act) == ("UserInterface1", "X_AVM-DE_GetInfo")
else _call_action(svc, act, **kw)
)
warnings = driver.get_device_warnings()
assert not any(w["code"] == "firmware_update_available" for w in warnings)
def test_dsl_snr_low_warning(self, driver):
low_snr = dict(DSL_INFO, NewDownstreamNoiseMargin=40) # 4.0 dB < 6 dB threshold
driver.fc.call_action.side_effect = lambda svc, act, **kw: (
low_snr if (svc, act) == ("WANDSLInterfaceConfig1", "GetInfo")
else _call_action(svc, act, **kw)
)
warnings = driver.get_device_warnings()
assert any(w["code"] == "dsl_snr_low" for w in warnings)
def test_no_dsl_warning_when_snr_ok(self, driver):
warnings = driver.get_device_warnings()
assert not any(w["code"] == "dsl_snr_low" for w in warnings)
# ---------------------------------------------------------------------------
# get_system_config (DSL stats, firmware, DNS, NTP)
# ---------------------------------------------------------------------------
class TestGetSystemConfig:
def test_dsl_downstream_rate(self, driver):
cfg = driver.get_system_config()
assert cfg["dsl_downstream_rate"] == 100000
def test_dsl_upstream_rate(self, driver):
assert driver.get_system_config()["dsl_upstream_rate"] == 40000
def test_dsl_snr_margin(self, driver):
cfg = driver.get_system_config()
assert cfg["dsl_downstream_noise_margin"] == 120
assert cfg["dsl_upstream_noise_margin"] == 80
def test_dsl_attenuation(self, driver):
cfg = driver.get_system_config()
assert cfg["dsl_downstream_attenuation"] == 150
def test_dsl_crc_errors(self, driver):
assert driver.get_system_config()["dsl_crc_errors"] == 5
def test_dsl_fec_errors(self, driver):
assert driver.get_system_config()["dsl_fec_errors"] == 12
def test_firmware_update_available(self, driver):
assert driver.get_system_config()["firmware_update_available"] is True
def test_firmware_not_available_when_none(self, driver):
driver.fc.call_action.side_effect = lambda svc, act, **kw: (
UI_INFO_NO_UPDATE if (svc, act) == ("UserInterface1", "X_AVM-DE_GetInfo")
else _call_action(svc, act, **kw)
)
assert driver.get_system_config()["firmware_update_available"] is False
def test_dns_servers_parsed(self, driver):
cfg = driver.get_system_config()
assert cfg["dns_servers"] == ["1.1.1.1", "8.8.8.8"]
def test_ntp_server(self, driver):
assert driver.get_system_config()["ntp_server"] == "ntp.example.com"
def test_empty_when_no_dsl_service(self, driver):
del driver.fc.services["WANDSLInterfaceConfig1"]
cfg = driver.get_system_config()
assert "dsl_downstream_rate" not in cfg
# ---------------------------------------------------------------------------
# get_port_forwards — IPv6 pinholes
# ---------------------------------------------------------------------------
class TestIPv6Pinholes:
def test_ipv6_pinhole_appended(self, driver):
forwards = driver.get_port_forwards()
protocols = [f["protocol"] for f in forwards]
assert "TCP6" in protocols
def test_ipv6_pinhole_fields(self, driver):
forwards = driver.get_port_forwards()
ssh6 = next(f for f in forwards if f["protocol"] == "TCP6")
assert ssh6["name"] == "SSH6"
assert ssh6["internal_ip"] == "2001:db8::10"
assert ssh6["internal_port"] == 22
assert ssh6["enabled"] is True
def test_no_pinholes_without_ipv6_service(self, driver):
del driver.fc.services["WANIPv6Firewall1"]
forwards = driver.get_port_forwards()
assert all(f["protocol"] not in ("TCP6", "UDP6") for f in forwards)