mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-04-06 18:21:46 -07:00
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:
212
front/plugins/fritzbox/README.md
Executable file
212
front/plugins/fritzbox/README.md
Executable 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/)
|
||||||
647
front/plugins/fritzbox/config.json
Executable file
647
front/plugins/fritzbox/config.json
Executable 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
273
front/plugins/fritzbox/fritzbox.py
Executable file
273
front/plugins/fritzbox/fritzbox.py
Executable 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())
|
||||||
@@ -34,3 +34,4 @@ freebox-api
|
|||||||
mcp
|
mcp
|
||||||
psutil
|
psutil
|
||||||
pydantic>=2.0,<3.0
|
pydantic>=2.0,<3.0
|
||||||
|
fritzconnection>=1.15.1
|
||||||
|
|||||||
423
test/plugins/test_fritzbox.py
Normal file
423
test/plugins/test_fritzbox.py
Normal 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
|
||||||
Reference in New Issue
Block a user