Add Fritz!Box device scanner plugin via TR-064 protocol

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.
This commit is contained in:
sebingel
2026-01-29 14:14:31 +00:00
parent 83de79bf94
commit 5839853f69
5 changed files with 1556 additions and 0 deletions

212
front/plugins/fritzbox/README.md Executable file
View File

@@ -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/)

View File

@@ -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": "<i class=\"fa fa-search\"></i>"
}
],
"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 <code>schedule</code>"
}
]
},
{
"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 <code>schedule</code> in the <a href=\"#SYNC_RUN\"><code>SYNC_RUN</code> setting</a>. Make sure you enter the schedule in the correct cron-like format (e.g. validate at <a href=\"https://crontab.guru/\" target=\"_blank\">crontab.guru</a>). For example entering <code>0 4 * * *</code> will run the scan after 4 am in the <a onclick=\"toggleAllSettings()\" href=\"#TIMEZONE\"><code>TIMEZONE</code> you set above</a>. 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., <code>fritz.box</code> or <code>192.168.178.1</code>)"
}
]
},
{
"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: <code>49443</code> for HTTPS, use <code>49000</code> 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 <code>49443</code>)"
}
]
},
{
"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 <code>3</code> for guest WiFi, <code>1</code> for 2.4GHz, <code>2</code> for 5GHz. Only relevant when <a href=\"#FRITZBOX_REPORT_GUEST\"><code>REPORT_GUEST</code></a> 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 (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
}
]
},
{
"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 (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
}
]
}
],
"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": "<div style='text-align:center'><i class='fa-solid fa-square-check'></i><div></div>"
},
{
"equals": "watched-changed",
"replacement": "<div style='text-align:center'><i class='fa-solid fa-triangle-exclamation'></i></div>"
},
{
"equals": "new",
"replacement": "<div style='text-align:center'><i class='fa-solid fa-circle-plus'></i></div>"
},
{
"equals": "missing-in-last-scan",
"replacement": "<div style='text-align:center'><i class='fa-solid fa-question'></i></div>"
}
],
"localized": ["name"],
"name": [
{
"language_code": "en_us",
"string": "Status"
}
]
}
]
}

View File

@@ -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())

View File

@@ -34,3 +34,4 @@ freebox-api
mcp mcp
psutil psutil
pydantic>=2.0,<3.0 pydantic>=2.0,<3.0
fritzconnection>=1.15.1

View File

@@ -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