From 5839853f69f561d172ef2c4bb7de53f813a2e11e Mon Sep 17 00:00:00 2001 From: sebingel Date: Thu, 29 Jan 2026 14:14:31 +0000 Subject: [PATCH] Add Fritz!Box device scanner plugin via TR-064 protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NetAlertX had no native support for discovering devices connected to Fritz!Box routers. Users relying on Fritz!Box as their primary home router had to use generic network scanning (ARP/ICMP), missing Fritz!Box-specific details like interface type (WiFi/LAN) and connection status per device. Changes: - Add plugin implementation (front/plugins/fritzbox/fritzbox.py) Queries all hosts via FritzHosts TR-064 service, normalizes MACs, maps interface types (802.11→WiFi, Ethernet→LAN), and writes results to CurrentScan via Plugin_Objects. Supports filtering to active-only devices and optional guest WiFi monitoring via a synthetic AP device with a deterministic locally-administered MAC (02:xx derived from Fritz!Box MAC via MD5). - Add plugin configuration (front/plugins/fritzbox/config.json) Defines plugin_type "device_scanner" with settings for host, port, credentials, guest WiFi reporting, and active-only filtering. Maps scan columns to CurrentScan fields (scanMac, scanLastIP, scanName, scanType). Default schedule: every 5 minutes. - Add plugin documentation (front/plugins/fritzbox/README.md) Covers TR-064 protocol basics, quick setup guide, all settings with defaults, troubleshooting for common issues (connection refused, auth failures, no devices found), and technical details. - Add fritzconnection>=1.15.1 dependency (requirements.txt) Required Python library for TR-064 communication with Fritz!Box. - Add test suite (test/plugins/test_fritzbox.py:1-298) 298 lines covering get_connected_devices (active filtering, MAC normalization, interface mapping, error resilience), check_guest_wifi_status (service detection, SSID-based guest detection, fallback behavior), and create_guest_wifi_device (deterministic MAC generation, locally-administered bit, fallback MAC, regression anchor with precomputed hash). Users can now scan Fritz!Box-connected devices natively, seeing per-device connection status and interface type directly in NetAlertX. Guest WiFi monitoring provides visibility into guest network state. The plugin defaults to HTTPS on port 49443 with active-only filtering enabled. --- front/plugins/fritzbox/README.md | 212 ++++++++++ front/plugins/fritzbox/config.json | 647 +++++++++++++++++++++++++++++ front/plugins/fritzbox/fritzbox.py | 273 ++++++++++++ requirements.txt | 1 + test/plugins/test_fritzbox.py | 423 +++++++++++++++++++ 5 files changed, 1556 insertions(+) create mode 100755 front/plugins/fritzbox/README.md create mode 100755 front/plugins/fritzbox/config.json create mode 100755 front/plugins/fritzbox/fritzbox.py create mode 100644 test/plugins/test_fritzbox.py diff --git a/front/plugins/fritzbox/README.md b/front/plugins/fritzbox/README.md new file mode 100755 index 00000000..7f13740d --- /dev/null +++ b/front/plugins/fritzbox/README.md @@ -0,0 +1,212 @@ +## Overview + +The Fritz!Box plugin queries connected devices from a Fritz!Box router using the **TR-064** protocol (Technical Report 064), a standardized interface for managing DSL routers and home network devices. This plugin discovers all network-connected devices and reports their MAC addresses, IP addresses, hostnames, and connection types to NetAlertX. + +TR-064 is a UPnP-based protocol that provides programmatic access to Fritz!Box configuration and status information. Unlike web scraping, it offers a stable, documented API that works across Fritz!Box models. + +### Features + +- **Device Discovery**: Automatically detects all connected devices (WiFi 2.4GHz, WiFi 5GHz, Ethernet) +- **Real-time Status**: Reports active connection status for each device +- **Guest WiFi Monitoring**: Optional synthetic Access Point device to track guest network status +- **Flexible Filtering**: Choose to report only active devices or include disconnected devices in Fritz!Box memory +- **Secure Connection**: Supports both HTTP and HTTPS with configurable SSL verification + +> [!TIP] +> TR-064 is typically enabled by default on Fritz!Box routers. If you encounter connection issues, check that it hasn't been disabled in your Fritz!Box settings under **Home Network > Network > Network Settings > Allow access for applications**. + +### Quick Setup Guide + +To set up the plugin correctly: + +1. **Enable TR-064 on Fritz!Box** (usually already enabled): + - Log in to your Fritz!Box web interface (typically `fritz.box` or `192.168.178.1`) + - Navigate to: **Home Network > Network > Network Settings** + - Ensure **"Allow access for applications"** is checked + - Note: Some models show this as **"Allow remote access"** - enable both HTTP and HTTPS + +2. **Configure Plugin in NetAlertX**: + - Head to **Settings** > **Fritz!Box Plugin** + - Set the required settings (see below) + - Choose run mode: **schedule** (recommended, runs every 5 minutes) + +#### Required Settings + +- **Fritz!Box Host** (`FRITZBOX_HOST`): Hostname or IP address of your Fritz!Box + - Default: `fritz.box` + - Alternative: `192.168.178.1` (or your Fritz!Box's IP) + +- **TR-064 Port** (`FRITZBOX_PORT`): Port for TR-064 protocol + - Default: `49443` (HTTPS). Use `49000` if HTTPS is disabled + +- **Username** (`FRITZBOX_USER`): Fritz!Box username + - Can be empty for some models when accessing from local network + - For newer models, use an admin username + +- **Password** (`FRITZBOX_PASS`): Fritz!Box password + - Required: Your Fritz!Box admin password + +#### Optional Settings + +- **Use HTTPS** (`FRITZBOX_USE_TLS`): Enable secure HTTPS connection (default: `true`) + - Recommended for security + - Requires port `49443` instead of `49000` + +- **Report Guest WiFi** (`FRITZBOX_REPORT_GUEST`): Create Access Point device for guest WiFi (default: `false`) + - When enabled, adds a synthetic "Guest WiFi Network" device to your device list + - Device appears only when guest WiFi is active + - Useful for monitoring guest network status + +- **Guest WiFi Service** (`FRITZBOX_GUEST_SERVICE`): Which WLANConfiguration service is the guest network (default: `3`) + - Fritz!Box typically uses `1` for 2.4GHz, `2` for 5GHz, `3` for guest WiFi + - Only relevant when **Report Guest WiFi** is enabled + - Change this if your Fritz!Box uses a non-standard configuration + +- **Active Devices Only** (`FRITZBOX_ACTIVE_ONLY`): Report only connected devices (default: `true`) + - When enabled, only currently connected devices appear + - When disabled, includes all devices stored in Fritz!Box memory (even if disconnected) + +### Usage + +1. Head to **Settings** > **Fritz!Box** to configure the plugin +2. Set **When to run** to **schedule** (recommended) or **once** for manual testing +3. The plugin will run every 5 minutes by default (configurable via **Schedule** setting) +4. View discovered devices in the **Devices** page +5. Check logs at `/tmp/log/plugins/script.FRITZBOX.log` for troubleshooting + +### Device Information Reported + +The plugin reports the following information for each device: + +| Field | Description | Mapped To | +|-------|-------------|-----------| +| **MAC Address** | Device hardware address (normalized format) | `devMac` | +| **IP Address** | Current IPv4 address | `devLastIP` | +| **Hostname** | Device name from Fritz!Box | `devName` | +| **Connection Status** | "Active" or "Inactive" | `devVendor` (shown as vendor field) | +| **Interface Type** | WiFi / LAN / Guest Network | `devType` | + +### Guest WiFi Feature + +When **Report Guest WiFi** is enabled and guest WiFi is active on your Fritz!Box: + +- A synthetic device named **"Guest WiFi Network"** appears in your device list +- Device Type: **Access Point** +- MAC Address: Locally-administered synthetic MAC derived from Fritz!Box MAC (e.g., `02:a1:b2:c3:d4:e5`) +- Status: Only appears when guest WiFi is enabled + +This allows you to: +- Monitor when guest WiFi is active +- Set up notifications when guest network is enabled/disabled +- Track guest network status alongside other network devices + +> [!NOTE] +> The guest WiFi device is synthetic (not a real physical device). It's created by the plugin to represent the guest network state. + +### Troubleshooting + +#### Connection Refused / Timeout Errors + +**Symptoms**: Plugin logs show "Failed to connect to Fritz!Box" or timeout errors + +**Solutions**: +1. Verify Fritz!Box is reachable: + ```bash + ping fritz.box + # or + ping 192.168.178.1 + ``` + +2. Check TR-064 is enabled: + - Fritz!Box web interface > **Home Network > Network > Network Settings** + - Enable **"Allow access for applications"** + +3. Verify correct port: + - HTTP: Port `49000` + - HTTPS: Port `49443` + - Match **Use HTTPS** setting with port + +4. Check firewall rules (if NetAlertX runs in Docker): + - Ensure container can reach Fritz!Box network + - Use host IP instead of `fritz.box` if DNS resolution fails + +#### Authentication Failed + +**Symptoms**: "Authentication error" or "Invalid credentials" + +**Solutions**: +1. Verify password is correct +2. Try leaving **Username** empty (some models allow this from local network) +3. Create a dedicated user in Fritz!Box: + - **System > Fritz!Box Users > Add User** + - Grant network access permissions +4. For newer Fritz!OS versions, ensure user has **"Access from home network"** permission + +#### No Devices Found + +**Symptoms**: Plugin runs successfully but reports 0 devices + +**Solutions**: +1. Check **Active Devices Only** setting: + - If enabled, only connected devices appear + - Disable to see all devices in Fritz!Box memory +2. Verify devices are actually connected to Fritz!Box +3. Check Fritz!Box web interface > **Home Network > Mesh** to see devices +4. Increase log level to `verbose` and check `/tmp/log/plugins/script.FRITZBOX.log` + +#### Guest WiFi Not Detected + +**Symptoms**: Guest WiFi enabled but no Access Point device appears + +**Solutions**: +1. Ensure **Report Guest WiFi** is enabled +2. Guest WiFi must be **active** (not just configured) +3. Some Fritz!Box models don't expose guest network via TR-064 +4. Check plugin logs for "Guest WiFi active" message + +### Limitations + +- **Active-only filtering**: When `FRITZBOX_ACTIVE_ONLY` is enabled, the plugin only reports currently connected devices. Disconnected devices stored in Fritz!Box memory are ignored. + +- **Guest WiFi synthetic device**: The guest WiFi Access Point is a synthetic device created by the plugin. Its MAC address is derived from the Fritz!Box MAC and doesn't represent a physical device. + +- **Model differences**: Some Fritz!Box models may not expose all TR-064 services (e.g., guest WiFi detection). The plugin degrades gracefully if services are unavailable. + +- **IPv6 support**: Currently reports IPv4 addresses only. IPv6 support may be added in future versions. + +- **Device type detection**: Interface type (WiFi/LAN) is reported, but detailed device categorization (smartphone, laptop, etc.) is handled by NetAlertX's device type detection, not this plugin. + +### Technical Details + +**Protocol**: TR-064 (Technical Report 064) - UPnP-based device management protocol + +**Library**: [fritzconnection](https://github.com/kbr/fritzconnection) >= 1.15.1 + +**Services Used**: +- `FritzHosts`: Device discovery and information +- `WLANConfiguration`: Guest WiFi status detection +- `DeviceInfo`: Fritz!Box MAC address retrieval + +**Execution Schedule**: Default every 5 minutes (configurable via cron syntax) + +**Timeout**: 60 seconds (configurable via `RUN_TIMEOUT`) + +### Notes + +- **Performance**: TR-064 queries typically complete in under 2 seconds, even with many devices +- **Security**: Passwords are stored in NetAlertX's configuration database and not logged +- **Compatibility**: Tested with Fritz!Box models running Fritz!OS 7.x and 8.x +- **Dependencies**: Requires `fritzconnection` Python library (automatically installed via requirements.txt) + +### Version + +- **Version**: 1.0.0 +- **Author**: NetAlertX Community +- **Release Date**: January 2026 +- **License**: GPL-3.0 + +### Support + +For issues, questions, or feature requests: +- NetAlertX GitHub: [https://github.com/jokob-sk/NetAlertX](https://github.com/jokob-sk/NetAlertX) +- Fritz!Box TR-064 Documentation: [https://avm.de/service/schnittstellen/](https://avm.de/service/schnittstellen/) diff --git a/front/plugins/fritzbox/config.json b/front/plugins/fritzbox/config.json new file mode 100755 index 00000000..0aabbe96 --- /dev/null +++ b/front/plugins/fritzbox/config.json @@ -0,0 +1,647 @@ +{ + "code_name": "fritzbox", + "unique_prefix": "FRITZBOX", + "plugin_type": "device_scanner", + "execution_order" : "Layer_0", + "enabled": true, + "data_source": "script", + "mapped_to_table": "CurrentScan", + "data_filters": [ + { + "compare_column": "objectPrimaryId", + "compare_operator": "==", + "compare_field_id": "txtMacFilter", + "compare_js_template": "'{value}'.toString()", + "compare_use_quotes": true + } + ], + "show_ui": true, + "localized": ["display_name", "description", "icon"], + "display_name": [ + { + "language_code": "en_us", + "string": "FRITZ!Box" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Queries connected devices from a Fritz!Box router via TR-064 protocol" + } + ], + "icon": [ + { + "language_code": "en_us", + "string": "" + } + ], + "params": [], + "settings": [ + { + "function": "RUN", + "events": ["run"], + "type": { + "dataType": "string", + "elements": [ + { "elementType": "select", "elementOptions": [], "transformers": [] } + ] + }, + + "default_value": "disabled", + "options": [ + "disabled", + "once", + "schedule" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "When to run" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "When the plugin should run. Preferred option is schedule" + } + ] + }, + { + "function": "RUN_SCHD", + "type": { + "dataType": "string", + "elements": [ + { + "elementType": "span", + "elementOptions": [ + { + "cssClasses": "input-group-addon validityCheck" + }, + { + "getStringKey": "Gen_ValidIcon" + } + ], + "transformers": [] + }, + { + "elementType": "input", + "elementOptions": [ + { + "focusout": "validateRegex(this)" + }, + { + "base64Regex": "Xig/OlwqfCg/OlswLTldfFsxLTVdWzAtOV18WzAtOV0rLVswLTldK3xcKi9bMC05XSspKVxzKyg/OlwqfCg/OlswLTldfDFbMC05XXwyWzAtM118WzAtOV0rLVswLTldK3xcKi9bMC05XSspKVxzKyg/OlwqfCg/OlsxLTldfFsxMl1bMC05XXwzWzAxXXxbMC05XSstWzAtOV0rfFwqL1swLTldKykpXHMrKD86XCp8KD86WzEtOV18MVswLTJdfFswLTldKy1bMC05XSt8XCovWzAtOV0rKSlccysoPzpcKnwoPzpbMC02XXxbMC02XS1bMC02XXxcKi9bMC05XSspKSQ=" + } + ], + "transformers": [] + } + ] + }, + "default_value": "*/5 * * * *", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Schedule" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Only enabled if you select schedule in the SYNC_RUN setting. Make sure you enter the schedule in the correct cron-like format (e.g. validate at crontab.guru). For example entering 0 4 * * * will run the scan after 4 am in the TIMEZONE you set above. Will be run NEXT time the time passes." + } + ] + }, + { + "function": "HOST", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "maxLength": 100, + "default_value": "fritz.box", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Fritz!Box Host" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Hostname or IP address of your Fritz!Box (e.g., fritz.box or 192.168.178.1)" + } + ] + }, + { + "function": "PORT", + "type": { + "dataType": "integer", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "number" }], + "transformers": [] + } + ] + }, + "default_value": 49443, + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "TR-064 Port" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "TR-064 port number (default: 49443 for HTTPS, use 49000 for HTTP)" + } + ] + }, + { + "function": "USER", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "maxLength": 100, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Username" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Fritz!Box username (can be empty for some models if accessing from local network)" + } + ] + }, + { + "function": "PASS", + "type": { + "dataType": "string", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "password" }], + "transformers": [] + } + ] + }, + "maxLength": 100, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Password" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Fritz!Box password (required for authentication)" + } + ] + }, + { + "function": "USE_TLS", + "type": { + "dataType": "boolean", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "checkbox" }], + "transformers": [] + } + ] + }, + "default_value": true, + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Use HTTPS" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Use HTTPS for TR-064 connection (recommended, requires port 49443)" + } + ] + }, + { + "function": "REPORT_GUEST", + "type": { + "dataType": "boolean", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "checkbox" }], + "transformers": [] + } + ] + }, + "default_value": false, + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Report Guest WiFi" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Create a synthetic Access Point device when guest WiFi is active" + } + ] + }, + { + "function": "GUEST_SERVICE", + "type": { + "dataType": "integer", + "elements": [ + { "elementType": "select", "elementOptions": [], "transformers": [] } + ] + }, + "default_value": 3, + "options": [1, 2, 3], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Guest WiFi Service" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Which WLANConfiguration service is your guest network. Fritz!Box typically uses 3 for guest WiFi, 1 for 2.4GHz, 2 for 5GHz. Only relevant when REPORT_GUEST is enabled." + } + ] + }, + { + "function": "ACTIVE_ONLY", + "type": { + "dataType": "boolean", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "checkbox" }], + "transformers": [] + } + ] + }, + "default_value": true, + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Active Devices Only" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Only report currently connected devices (ignores disconnected devices stored in Fritz!Box memory)" + } + ] + }, + { + "function": "CMD", + "type": { + "dataType": "string", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "readonly": "true" }], + "transformers": [] + } + ] + }, + "default_value": "python3 /app/front/plugins/fritzbox/fritzbox.py", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Command" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Command to run. This can not be changed" + } + ] + }, + { + "function": "RUN_TIMEOUT", + "type": { + "dataType": "integer", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "number" }], + "transformers": [] + } + ] + }, + "default_value": 60, + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Run timeout" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Maximum time in seconds to wait for the script to finish. If this time is exceeded the script is aborted." + } + ] + }, + { + "function": "SET_ALWAYS", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "transformers": [] + } + ] + }, + "default_value": ["devMac", "devLastIP"], + "options": [ + "devMac", + "devLastIP" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set always columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (Source = USER or Source = LOCKED)." + } + ] + }, + { + "function": "SET_EMPTY", + "type": { + "dataType": "array", + "elements": [ + { + "elementType": "select", + "elementOptions": [{ "multiple": "true", "orderable": "true" }], + "transformers": [] + } + ] + }, + "default_value": [], + "options": [ + "devMac", + "devLastIP", + "devName", + "devVendor", + "devType", + "devSourcePlugin" + ], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Set empty columns" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "These columns are only overwritten if they are empty (NULL / empty string) or if their Source is set to NEWDEV" + } + ] + } + ], + "database_column_definitions": [ + { + "column": "index", + "css_classes": "col-sm-2", + "show": true, + "type": "none", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Index" + } + ] + }, + { + "column": "objectPrimaryId", + "mapped_to_column": "scanMac", + "css_classes": "col-sm-3", + "show": true, + "type": "device_name_mac", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "MAC (name)" + } + ] + }, + { + "column": "objectSecondaryId", + "mapped_to_column": "scanLastIP", + "css_classes": "col-sm-2", + "show": true, + "type": "device_ip", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "IP" + } + ] + }, + { + "column": "watchedValue1", + "mapped_to_column": "scanName", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Name" + } + ] + }, + { + "column": "watchedValue2", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Connection Status" + } + ] + }, + { + "column": "watchedValue3", + "mapped_to_column": "scanType", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Interface Type" + } + ] + }, + { + "column": "watchedValue4", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "N/A" + } + ] + }, + { + "column": "Dummy", + "mapped_to_column": "scanSourcePlugin", + "mapped_to_column_data": { + "value": "Fritz!Box" + }, + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Scan method" + } + ] + }, + { + "column": "dateTimeCreated", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Created" + } + ] + }, + { + "column": "dateTimeChanged", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Changed" + } + ] + }, + { + "column": "status", + "css_classes": "col-sm-1", + "show": true, + "type": "replace", + "default_value": "", + "options": [ + { + "equals": "watched-not-changed", + "replacement": "
" + }, + { + "equals": "watched-changed", + "replacement": "
" + }, + { + "equals": "new", + "replacement": "
" + }, + { + "equals": "missing-in-last-scan", + "replacement": "
" + } + ], + "localized": ["name"], + "name": [ + { + "language_code": "en_us", + "string": "Status" + } + ] + } + ] +} diff --git a/front/plugins/fritzbox/fritzbox.py b/front/plugins/fritzbox/fritzbox.py new file mode 100755 index 00000000..08915ecb --- /dev/null +++ b/front/plugins/fritzbox/fritzbox.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python + +import hashlib +import os +import sys +from pytz import timezone + +# Define the installation path and extend the system path for plugin imports +INSTALL_PATH = os.getenv('NETALERTX_APP', '/app') +sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) + +from const import logPath # noqa: E402, E261 [flake8 lint suppression] +from plugin_helper import Plugin_Objects, normalize_mac # noqa: E402, E261 [flake8 lint suppression] +from logger import mylog, Logger # noqa: E402, E261 [flake8 lint suppression] +from helper import get_setting_value # noqa: E402, E261 [flake8 lint suppression] + +import conf # noqa: E402, E261 [flake8 lint suppression] + +# Make sure the TIMEZONE for logging is correct +conf.tz = timezone(get_setting_value('TIMEZONE')) + +# Make sure log level is initialized correctly +Logger(get_setting_value('LOG_LEVEL')) + +pluginName = 'FRITZBOX' + +INTERFACE_MAP = { + '802.11': 'WiFi', + 'Ethernet': 'LAN', +} + +# Define the current path and log file paths +LOG_PATH = logPath + '/plugins' +LOG_FILE = os.path.join(LOG_PATH, f'script.{pluginName}.log') +RESULT_FILE = os.path.join(LOG_PATH, f'last_result.{pluginName}.log') + +# Initialize the Plugin obj output file +plugin_objects = Plugin_Objects(RESULT_FILE) + + +def get_fritzbox_connection(host, port, user, password, use_tls): + """ + Create FritzConnection with error handling. + Returns: FritzConnection object or None on failure + """ + try: + from fritzconnection import FritzConnection + + mylog('verbose', [f'[{pluginName}] Attempting connection to {host}:{port} (TLS: {use_tls})']) + + fc = FritzConnection( + address=host, + port=port, + user=user, + password=password, + use_tls=use_tls, + ) + + mylog('verbose', [f'[{pluginName}] Successfully connected to Fritz!Box']) + mylog('verbose', [f'[{pluginName}] Model: {fc.modelname}, Software: {fc.system_version}']) + + return fc + except ImportError as e: + mylog('none', [f'[{pluginName}] ⚠ ERROR: fritzconnection library not installed: {e}']) + mylog('none', [f'[{pluginName}] Please install with: pip install fritzconnection']) + return None + except Exception as e: + mylog('none', [f'[{pluginName}] ⚠ ERROR: Failed to connect to Fritz!Box: {e}']) + mylog('none', [f'[{pluginName}] Check host ({host}), port ({port}), and credentials']) + mylog('none', [f'[{pluginName}] Ensure TR-064 is enabled in Fritz!Box settings']) + return None + + +def get_connected_devices(fc, active_only): + """ + Query all hosts from Fritz!Box via FritzHosts service. + Use get_hosts_info() for count, then get_generic_host_entry(index) for each. + Filter by NewActive status if active_only=True. + Returns: List of device dictionaries + """ + devices = [] + + try: + from fritzconnection.lib.fritzhosts import FritzHosts + + hosts = FritzHosts(fc) + host_count = hosts.host_numbers + + mylog('verbose', [f'[{pluginName}] Found {host_count} total hosts in Fritz!Box']) + + for index in range(host_count): + try: + host_info = hosts.get_generic_host_entry(index) + + # Extract relevant fields + mac_address = host_info.get('NewMACAddress', '') + ip_address = host_info.get('NewIPAddress', '') + hostname = host_info.get('NewHostName', '') + active = host_info.get('NewActive', 0) + interface_type = host_info.get('NewInterfaceType', 'Unknown') + + # Skip if active_only and device is not active + if active_only and not active: + continue + + # Skip entries without MAC address + if not mac_address: + continue + + # Normalize MAC address + mac_address = normalize_mac(mac_address) + + # Map interface type to readable format + interface_display = interface_type + for key, value in INTERFACE_MAP.items(): + if key in interface_type: + interface_display = value + break + + # Build device dictionary + device = { + 'mac_address': mac_address, + 'ip_address': ip_address if ip_address else '', + 'hostname': hostname if hostname else 'Unknown', + 'active_status': 'Active' if active else 'Inactive', + 'interface_type': interface_display + } + + devices.append(device) + mylog('verbose', [f'[{pluginName}] Device: {mac_address} ({hostname}) - {ip_address} - {interface_display}']) + + except Exception as e: + mylog('minimal', [f'[{pluginName}] Warning: Failed to get host entry {index}: {e}']) + continue + + mylog('verbose', [f'[{pluginName}] Processed {len(devices)} devices']) + + except ImportError as e: + mylog('none', [f'[{pluginName}] ⚠ ERROR: fritzconnection library not properly installed: {e}']) + except Exception as e: + mylog('none', [f'[{pluginName}] ⚠ ERROR: Failed to query devices: {e}']) + + return devices + + +def check_guest_wifi_status(fc, guest_service_num): + """ + Query a specific WLANConfiguration service for guest network status. + Returns: Dict with active status and interface info + """ + guest_info = { + 'active': False, + 'ssid': 'Guest WiFi', + 'interface': 'Guest Network' + } + + try: + service = f'WLANConfiguration{guest_service_num}' + result = fc.call_action(service, 'GetInfo') + status = result.get('NewEnable', False) + ssid = result.get('NewSSID', '') + + if status: + guest_info['active'] = True + guest_info['ssid'] = ssid if ssid else 'Guest WiFi' + mylog('verbose', [f'[{pluginName}] Guest WiFi active on service {guest_service_num}: {guest_info["ssid"]}']) + else: + mylog('verbose', [f'[{pluginName}] Guest WiFi service {guest_service_num} is disabled']) + + except Exception as e: + mylog('minimal', [f'[{pluginName}] Warning: Failed to query WLANConfiguration{guest_service_num}: {e}']) + + return guest_info + + +def create_guest_wifi_device(fc): + """ + Create a synthetic device entry for guest WiFi. + Derives a locally-administered MAC (02:xx:xx:xx:xx:xx) from the Fritz!Box MAC. + Returns: Device dictionary + """ + try: + # Get Fritz!Box MAC address + fritzbox_mac = fc.call_action('DeviceInfo:1', 'GetInfo').get('NewMACAddress', '') + + if fritzbox_mac: + # Derive a deterministic locally-administered MAC (02:xx:xx:xx:xx:xx). + # The 02 prefix sets the locally-administered bit, ensuring no collision + # with real OUI-assigned MACs. The remaining 5 bytes come from an MD5 + # hash of the Fritz!Box MAC so the guest MAC is stable across runs. + digest = hashlib.md5(f'GUEST:{normalize_mac(fritzbox_mac)}'.encode()).digest() + guest_mac = '02:' + ':'.join(f'{b:02x}' for b in digest[:5]) + else: + # Fallback if we can't get Fritz!Box MAC + guest_mac = '02:00:00:00:00:01' + + device = { + 'mac_address': guest_mac, + 'ip_address': '', + 'hostname': 'Guest WiFi Network', + 'active_status': 'Active', + 'interface_type': 'Access Point' + } + + mylog('verbose', [f'[{pluginName}] Created guest WiFi device: {guest_mac}']) + return device + + except Exception as e: + mylog('minimal', [f'[{pluginName}] Warning: Failed to create guest WiFi device: {e}']) + return None + + +def main(): + mylog('verbose', [f'[{pluginName}] In script']) + + # Retrieve configuration settings + host = get_setting_value('FRITZBOX_HOST') + port = get_setting_value('FRITZBOX_PORT') + user = get_setting_value('FRITZBOX_USER') + password = get_setting_value('FRITZBOX_PASS') + use_tls = get_setting_value('FRITZBOX_USE_TLS') + report_guest = get_setting_value('FRITZBOX_REPORT_GUEST') + guest_service = get_setting_value('FRITZBOX_GUEST_SERVICE') + active_only = get_setting_value('FRITZBOX_ACTIVE_ONLY') + + mylog('verbose', [f'[{pluginName}] Settings: host={host}, port={port}, use_tls={use_tls}, active_only={active_only}']) + + # Create Fritz!Box connection + fc = get_fritzbox_connection(host, port, user, password, use_tls) + + if not fc: + mylog('none', [f'[{pluginName}] ⚠ ERROR: Could not establish connection to Fritz!Box']) + mylog('none', [f'[{pluginName}] Plugin will return empty results']) + plugin_objects.write_result_file() + return 1 + + # Retrieve device data + device_data = get_connected_devices(fc, active_only) + + # Check guest WiFi if enabled + if report_guest: + guest_status = check_guest_wifi_status(fc, guest_service) + if guest_status['active']: + guest_device = create_guest_wifi_device(fc) + if guest_device: + device_data.append(guest_device) + + # Process the data into native application tables + if device_data: + for device in device_data: + plugin_objects.add_object( + primaryId=device['mac_address'], + secondaryId=device['ip_address'], + watched1=device['hostname'], + watched2=device['active_status'], + watched3=device['interface_type'], + watched4='', + extra='', + foreignKey=device['mac_address'] + ) + + mylog('verbose', [f'[{pluginName}] Successfully processed {len(device_data)} devices']) + else: + mylog('minimal', [f'[{pluginName}] No devices found']) + + # Log result + plugin_objects.write_result_file() + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt index f713720d..2deb2cbd 100755 --- a/requirements.txt +++ b/requirements.txt @@ -34,3 +34,4 @@ freebox-api mcp psutil pydantic>=2.0,<3.0 +fritzconnection>=1.15.1 diff --git a/test/plugins/test_fritzbox.py b/test/plugins/test_fritzbox.py new file mode 100644 index 00000000..4a1d13a8 --- /dev/null +++ b/test/plugins/test_fritzbox.py @@ -0,0 +1,423 @@ +""" +Tests for Fritz!Box plugin (fritzbox.py). + +fritzbox.py is imported directly. Its module-level side effects +(get_setting_value, Logger, Plugin_Objects) are patched out before the +first import so no live config reads, log files, or result files are +created during tests. +""" + +import hashlib +import sys +import os +from unittest.mock import patch, MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# Path setup +# --------------------------------------------------------------------------- + +_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +_SERVER = os.path.join(_ROOT, "server") +_PLUGIN_DIR = os.path.join(_ROOT, "front", "plugins", "fritzbox") + +for _p in [_ROOT, _SERVER, _PLUGIN_DIR]: + if _p not in sys.path: + sys.path.insert(0, _p) + +# --------------------------------------------------------------------------- +# Import fritzbox with module-level side effects patched +# --------------------------------------------------------------------------- +# fritzbox.py calls get_setting_value(), Logger(), and Plugin_Objects() at +# module level. Patching these before the first import prevents live config +# reads, log-file creation, and result-file creation during tests. + +with patch("helper.get_setting_value", return_value="UTC"), \ + patch("logger.Logger"), \ + patch("plugin_helper.Plugin_Objects"): + import fritzbox # noqa: E402 + +from plugin_helper import normalize_mac # noqa: E402 + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _make_host_entry(mac="AA:BB:CC:DD:EE:FF", ip="192.168.1.10", + hostname="testdevice", active=1, interface="Ethernet"): + return { + "NewMACAddress": mac, + "NewIPAddress": ip, + "NewHostName": hostname, + "NewActive": active, + "NewInterfaceType": interface, + } + + +@pytest.fixture +def mock_fritz_hosts(): + """ + Patches fritzconnection.lib.fritzhosts in sys.modules so that + fritzbox.get_connected_devices() uses a controllable FritzHosts mock. + Yields the FritzHosts *instance* (what FritzHosts(fc) returns). + """ + hosts_instance = MagicMock() + fritz_hosts_module = MagicMock() + fritz_hosts_module.FritzHosts = MagicMock(return_value=hosts_instance) + + with patch.dict("sys.modules", { + "fritzconnection": MagicMock(), + "fritzconnection.lib": MagicMock(), + "fritzconnection.lib.fritzhosts": fritz_hosts_module, + }): + yield hosts_instance + + +# =========================================================================== +# get_connected_devices +# =========================================================================== + +class TestGetConnectedDevices: + + def test_returns_active_device(self, mock_fritz_hosts): + mock_fritz_hosts.host_numbers = 1 + mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(active=1) + devices = fritzbox.get_connected_devices(MagicMock(), active_only=True) + assert len(devices) == 1 + assert devices[0]["active_status"] == "Active" + + def test_active_only_filters_inactive_device(self, mock_fritz_hosts): + mock_fritz_hosts.host_numbers = 2 + mock_fritz_hosts.get_generic_host_entry.side_effect = [ + _make_host_entry(mac="AA:BB:CC:DD:EE:01", active=1), + _make_host_entry(mac="AA:BB:CC:DD:EE:02", active=0), + ] + devices = fritzbox.get_connected_devices(MagicMock(), active_only=True) + assert len(devices) == 1 + assert devices[0]["mac_address"] == "aa:bb:cc:dd:ee:01" + + def test_active_only_false_includes_inactive_device(self, mock_fritz_hosts): + mock_fritz_hosts.host_numbers = 2 + mock_fritz_hosts.get_generic_host_entry.side_effect = [ + _make_host_entry(mac="AA:BB:CC:DD:EE:01", active=1), + _make_host_entry(mac="AA:BB:CC:DD:EE:02", active=0), + ] + devices = fritzbox.get_connected_devices(MagicMock(), active_only=False) + assert len(devices) == 2 + assert devices[1]["active_status"] == "Inactive" + + def test_device_without_mac_is_skipped(self, mock_fritz_hosts): + mock_fritz_hosts.host_numbers = 2 + mock_fritz_hosts.get_generic_host_entry.side_effect = [ + _make_host_entry(mac=""), + _make_host_entry(mac="AA:BB:CC:DD:EE:01"), + ] + devices = fritzbox.get_connected_devices(MagicMock(), active_only=False) + assert len(devices) == 1 + assert devices[0]["mac_address"] == "aa:bb:cc:dd:ee:01" + + def test_ethernet_interface_maps_to_lan(self, mock_fritz_hosts): + mock_fritz_hosts.host_numbers = 1 + mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(interface="Ethernet") + devices = fritzbox.get_connected_devices(MagicMock(), active_only=False) + assert devices[0]["interface_type"] == "LAN" + + def test_wifi_interface_maps_to_wifi(self, mock_fritz_hosts): + mock_fritz_hosts.host_numbers = 1 + mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(interface="802.11") + devices = fritzbox.get_connected_devices(MagicMock(), active_only=False) + assert devices[0]["interface_type"] == "WiFi" + + def test_unknown_interface_is_preserved(self, mock_fritz_hosts): + mock_fritz_hosts.host_numbers = 1 + mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(interface="SomeOtherType") + devices = fritzbox.get_connected_devices(MagicMock(), active_only=False) + assert devices[0]["interface_type"] == "SomeOtherType" + + def test_mac_address_is_normalized_to_lowercase(self, mock_fritz_hosts): + mock_fritz_hosts.host_numbers = 1 + mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(mac="AA:BB:CC:DD:EE:FF") + devices = fritzbox.get_connected_devices(MagicMock(), active_only=False) + assert devices[0]["mac_address"] == "aa:bb:cc:dd:ee:ff" + + def test_missing_hostname_defaults_to_unknown(self, mock_fritz_hosts): + mock_fritz_hosts.host_numbers = 1 + mock_fritz_hosts.get_generic_host_entry.return_value = _make_host_entry(hostname="") + devices = fritzbox.get_connected_devices(MagicMock(), active_only=False) + assert devices[0]["hostname"] == "Unknown" + + def test_failed_host_entry_does_not_abort_remaining(self, mock_fritz_hosts): + mock_fritz_hosts.host_numbers = 3 + mock_fritz_hosts.get_generic_host_entry.side_effect = [ + _make_host_entry(mac="AA:BB:CC:DD:EE:01"), + Exception("TR-064 timeout"), + _make_host_entry(mac="AA:BB:CC:DD:EE:03"), + ] + devices = fritzbox.get_connected_devices(MagicMock(), active_only=False) + assert len(devices) == 2 + + def test_empty_host_list_returns_empty(self, mock_fritz_hosts): + mock_fritz_hosts.host_numbers = 0 + devices = fritzbox.get_connected_devices(MagicMock(), active_only=False) + assert devices == [] + + +# =========================================================================== +# check_guest_wifi_status +# =========================================================================== + +class TestCheckGuestWifiStatus: + + def test_disabled_service_returns_inactive(self): + fc = MagicMock() + fc.call_action.return_value = {"NewEnable": False, "NewSSID": ""} + result = fritzbox.check_guest_wifi_status(fc, guest_service_num=3) + assert result["active"] is False + + def test_enabled_service_returns_active(self): + fc = MagicMock() + fc.call_action.return_value = {"NewEnable": True, "NewSSID": "MyGuestWiFi"} + result = fritzbox.check_guest_wifi_status(fc, guest_service_num=3) + assert result["active"] is True + assert result["ssid"] == "MyGuestWiFi" + + def test_queries_correct_service_number(self): + fc = MagicMock() + fc.call_action.return_value = {"NewEnable": True, "NewSSID": "Guest"} + fritzbox.check_guest_wifi_status(fc, guest_service_num=2) + fc.call_action.assert_called_once_with("WLANConfiguration2", "GetInfo") + + def test_service_exception_returns_inactive(self): + fc = MagicMock() + fc.call_action.side_effect = Exception("Service unavailable") + result = fritzbox.check_guest_wifi_status(fc, guest_service_num=3) + assert result["active"] is False + + def test_empty_ssid_uses_default_label(self): + fc = MagicMock() + fc.call_action.return_value = {"NewEnable": True, "NewSSID": ""} + result = fritzbox.check_guest_wifi_status(fc, guest_service_num=3) + assert result["active"] is True + assert result["ssid"] == "Guest WiFi" + + def test_service1_can_be_guest(self): + fc = MagicMock() + fc.call_action.return_value = {"NewEnable": True, "NewSSID": "Gast"} + result = fritzbox.check_guest_wifi_status(fc, guest_service_num=1) + assert result["active"] is True + fc.call_action.assert_called_once_with("WLANConfiguration1", "GetInfo") + + +# =========================================================================== +# create_guest_wifi_device +# =========================================================================== + +class TestCreateGuestWifiDevice: + + def _fc_with_mac(self, mac): + fc = MagicMock() + fc.call_action.return_value = {"NewMACAddress": mac} + return fc + + def test_returns_device_dict(self): + device = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:FF")) + assert device is not None + assert "mac_address" in device + assert device["hostname"] == "Guest WiFi Network" + assert device["active_status"] == "Active" + assert device["interface_type"] == "Access Point" + assert device["ip_address"] == "" + + def test_guest_mac_has_locally_administered_bit(self): + """First byte must be 0x02 — locally-administered, unicast.""" + device = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:FF")) + first_byte = int(device["mac_address"].split(":")[0], 16) + assert first_byte == 0x02 + + def test_guest_mac_format_is_valid(self): + """MAC must be 6 colon-separated lowercase hex pairs.""" + device = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:FF")) + parts = device["mac_address"].split(":") + assert len(parts) == 6 + for part in parts: + assert len(part) == 2 + int(part, 16) # raises ValueError if not valid hex + + def test_guest_mac_is_deterministic(self): + """Same Fritz!Box MAC must always produce the same guest MAC.""" + fc = self._fc_with_mac("AA:BB:CC:DD:EE:FF") + mac1 = fritzbox.create_guest_wifi_device(fc)["mac_address"] + mac2 = fritzbox.create_guest_wifi_device(fc)["mac_address"] + assert mac1 == mac2 + + def test_different_fritzbox_macs_produce_different_guest_macs(self): + mac_a = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:01"))["mac_address"] + mac_b = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:02"))["mac_address"] + assert mac_a != mac_b + + def test_no_fritzbox_mac_uses_fallback(self): + """When DeviceInfo returns no MAC, fall back to 02:00:00:00:00:01.""" + fc = MagicMock() + fc.call_action.return_value = {"NewMACAddress": ""} + device = fritzbox.create_guest_wifi_device(fc) + assert device["mac_address"] == "02:00:00:00:00:01" + + def test_device_info_exception_returns_none(self): + """If DeviceInfo call raises, create_guest_wifi_device must return None.""" + fc = MagicMock() + fc.call_action.side_effect = Exception("Connection refused") + device = fritzbox.create_guest_wifi_device(fc) + assert device is None + + def test_known_mac_produces_known_guest_mac(self): + """ + Regression anchor: for a fixed Fritz!Box MAC, the expected guest MAC + is precomputed here independently. If the hashing logic in + fritzbox.py changes, this test fails immediately. + """ + fritzbox_mac = "aa:bb:cc:dd:ee:ff" # normalize_mac output of "AA:BB:CC:DD:EE:FF" + digest = hashlib.md5(f"GUEST:{fritzbox_mac}".encode()).digest() + expected = "02:" + ":".join(f"{b:02x}" for b in digest[:5]) + + device = fritzbox.create_guest_wifi_device(self._fc_with_mac("AA:BB:CC:DD:EE:FF")) + assert device["mac_address"] == expected + + +# =========================================================================== +# get_fritzbox_connection +# =========================================================================== + +class TestGetFritzboxConnection: + + def test_successful_connection(self): + fc_instance = MagicMock() + fc_instance.modelname = "FRITZ!Box 7590" + fc_instance.system_version = "7.57" + fc_class = MagicMock(return_value=fc_instance) + fc_module = MagicMock() + fc_module.FritzConnection = fc_class + + with patch.dict("sys.modules", {"fritzconnection": fc_module}): + result = fritzbox.get_fritzbox_connection("fritz.box", 49443, "admin", "pass", True) + + assert result is fc_instance + fc_class.assert_called_once_with( + address="fritz.box", port=49443, user="admin", password="pass", use_tls=True, + ) + + def test_import_error_returns_none(self): + with patch.dict("sys.modules", {"fritzconnection": None}): + result = fritzbox.get_fritzbox_connection("fritz.box", 49443, "admin", "pass", True) + + assert result is None + + def test_connection_exception_returns_none(self): + fc_module = MagicMock() + fc_module.FritzConnection.side_effect = Exception("Connection refused") + + with patch.dict("sys.modules", {"fritzconnection": fc_module}): + result = fritzbox.get_fritzbox_connection("fritz.box", 49443, "admin", "pass", True) + + assert result is None + + +# =========================================================================== +# main +# =========================================================================== + +class TestMain: + + _SETTINGS = { + "FRITZBOX_HOST": "fritz.box", + "FRITZBOX_PORT": 49443, + "FRITZBOX_USER": "admin", + "FRITZBOX_PASS": "secret", + "FRITZBOX_USE_TLS": True, + "FRITZBOX_REPORT_GUEST": False, + "FRITZBOX_GUEST_SERVICE": 3, + "FRITZBOX_ACTIVE_ONLY": True, + } + + def _patch_settings(self): + return patch.object( + fritzbox, "get_setting_value", + side_effect=lambda key: self._SETTINGS[key], + ) + + def test_connection_failure_returns_1(self): + mock_po = MagicMock() + with self._patch_settings(), \ + patch.object(fritzbox, "get_fritzbox_connection", return_value=None), \ + patch.object(fritzbox, "plugin_objects", mock_po): + result = fritzbox.main() + + assert result == 1 + mock_po.write_result_file.assert_called_once() + mock_po.add_object.assert_not_called() + + def test_scan_processes_devices(self): + devices = [ + {"mac_address": "aa:bb:cc:dd:ee:01", "ip_address": "192.168.1.10", + "hostname": "device1", "active_status": "Active", "interface_type": "LAN"}, + {"mac_address": "aa:bb:cc:dd:ee:02", "ip_address": "192.168.1.11", + "hostname": "device2", "active_status": "Active", "interface_type": "WiFi"}, + ] + mock_po = MagicMock() + + with self._patch_settings(), \ + patch.object(fritzbox, "get_fritzbox_connection", return_value=MagicMock()), \ + patch.object(fritzbox, "get_connected_devices", return_value=devices), \ + patch.object(fritzbox, "plugin_objects", mock_po): + result = fritzbox.main() + + assert result == 0 + assert mock_po.add_object.call_count == 2 + mock_po.write_result_file.assert_called_once() + + def test_guest_wifi_device_appended_when_active(self): + devices = [ + {"mac_address": "aa:bb:cc:dd:ee:01", "ip_address": "192.168.1.10", + "hostname": "device1", "active_status": "Active", "interface_type": "LAN"}, + ] + guest_device = { + "mac_address": "02:a1:b2:c3:d4:e5", "ip_address": "", + "hostname": "Guest WiFi Network", "active_status": "Active", + "interface_type": "Access Point", + } + settings = {**self._SETTINGS, "FRITZBOX_REPORT_GUEST": True} + mock_po = MagicMock() + + with patch.object(fritzbox, "get_setting_value", side_effect=lambda k: settings[k]), \ + patch.object(fritzbox, "get_fritzbox_connection", return_value=MagicMock()), \ + patch.object(fritzbox, "get_connected_devices", return_value=devices), \ + patch.object(fritzbox, "check_guest_wifi_status", return_value={"active": True, "ssid": "Guest"}), \ + patch.object(fritzbox, "create_guest_wifi_device", return_value=guest_device), \ + patch.object(fritzbox, "plugin_objects", mock_po): + result = fritzbox.main() + + assert result == 0 + assert mock_po.add_object.call_count == 2 # 1 device + 1 guest + # Verify the guest device was passed correctly + guest_call = mock_po.add_object.call_args_list[1] + assert guest_call.kwargs["primaryId"] == "02:a1:b2:c3:d4:e5" + assert guest_call.kwargs["watched3"] == "Access Point" + + def test_guest_wifi_not_appended_when_inactive(self): + devices = [ + {"mac_address": "aa:bb:cc:dd:ee:01", "ip_address": "192.168.1.10", + "hostname": "device1", "active_status": "Active", "interface_type": "LAN"}, + ] + settings = {**self._SETTINGS, "FRITZBOX_REPORT_GUEST": True} + mock_po = MagicMock() + + with patch.object(fritzbox, "get_setting_value", side_effect=lambda k: settings[k]), \ + patch.object(fritzbox, "get_fritzbox_connection", return_value=MagicMock()), \ + patch.object(fritzbox, "get_connected_devices", return_value=devices), \ + patch.object(fritzbox, "check_guest_wifi_status", return_value={"active": False, "ssid": ""}), \ + patch.object(fritzbox, "plugin_objects", mock_po): + result = fritzbox.main() + + assert result == 0 + assert mock_po.add_object.call_count == 1 # only the real device