diff --git a/.github/ISSUE_TEMPLATE/release-template.md b/.github/ISSUE_TEMPLATE/release-template.md new file mode 100644 index 0000000..b65d742 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release-template.md @@ -0,0 +1,16 @@ +--- +name: Release template +about: Checklist to perform a release +title: Release ... +labels: '' +assignees: '' + +--- + +## Release preparation +- [ ] Create distribution files + +## Release testing +- [ ] Execute module tests +- [ ] Execute module "FBPresence.py" +- [ ] Execute module "FBHomeAuto.py" diff --git a/.gitignore b/.gitignore index 894a44c..8456758 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ __pycache__/ .Python build/ develop-eggs/ -dist/ downloads/ eggs/ .eggs/ @@ -102,3 +101,6 @@ venv.bak/ # mypy .mypy_cache/ +/.project +/.pydevproject +/.DS_Store diff --git a/.settings/.gitignore b/.settings/.gitignore new file mode 100644 index 0000000..b012ade --- /dev/null +++ b/.settings/.gitignore @@ -0,0 +1 @@ +/org.eclipse.core.resources.prefs diff --git a/README.md b/README.md index 7bd3a14..4bd4733 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,65 @@ -# FritzBox -Collection of modules for interaction with an AVM FritzBox +## Welcome to the FritzBox project + +This project is derived from my former project [FritzBoxPresenceDetection](https://github.com/gasperphoenix/FritzBoxPresenceDetection) which provided the functionality to connect to an AVM FritzBox to check the presence of WLAN devices. While adding more features and the ability to interact with the FritzBox Home Automation actors (like switch plugs) using HTTP requests I decided to setup a complete new project with a new structure that encapsules the features for presence detection and home automation in separate modules. Furthermore I moved all the FritzBox communication related methods like authentication and lua-page loading to a new core module. + +## Package structure + +### FBCore +This module provides the class *FBCore* that encapsules all methods for communication with the FritzBox like authentication and loading of lua-pages. + +### FBPresence +This module provides the class *FBPresence* that encapsules all methods for determination of the WLAN device connection status on the FritzBox. + +### FBHomeAuto +This module provides the class *FBHomeAuto* that encapsules all methods for interacting with the AVM home automation actors connected to a FritzBox. + +## Using the distribution files +First you need to download a source distribution file from the *dist* subfolder. + +Afterwards you can easily install it on your environment by invoking the following command. As the package name may differ, please adapt it before executing the command. +```bash +pip3 install fritzbox-0.1.tar.gz +``` + +Once successfully installed you can use the package inside your scripts. Please find an example below. +```python +from fritzbox.FBHomeAuto import FBHomeAuto + +fbHA = FBHomeAuto(ip="192.168.0.1", password="pass1234") + +print(fbHA.getSwitchPlugs()) +``` + +## Create source distribution +First you need to install all required dependencies. +``` +pip3 install setuptools +``` + +Before creating the source distribution make sure to adapt the following attributes in the file setup.py to your needs. +``` + name = "fritzbox", + version = "0.2", +``` + +To create the source distribution go to the root folder of the archive and execute the following command +``` +sh create_dist.sh +``` + +This creates a source distribution in both *.zip* and *.tar.gz* format in the subfolder *dist* with the following naming using above attributes: +``` +-.zip +-.tar.gz +``` + +## Execute module tests +First you need to install all required dependencies. +``` +pip3 install openpyxl pytest pytest-cov +``` + +A shell script module has been added to execute all included module tests. To execute the module tests execute the following command in the root folder of the source code: +``` +sh execute_unit_test.sh +``` diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..3397c9a --- /dev/null +++ b/_config.yml @@ -0,0 +1 @@ +theme: jekyll-theme-architect \ No newline at end of file diff --git a/create_dist.sh b/create_dist.sh new file mode 100644 index 0000000..f0b4535 --- /dev/null +++ b/create_dist.sh @@ -0,0 +1,2 @@ +python3 src/fritzbox/setup.py sdist --formats=zip,gztar +rm -rf src/fritzbox.egg-info \ No newline at end of file diff --git a/dist/fritzbox-0.7.1.tar.gz b/dist/fritzbox-0.7.1.tar.gz new file mode 100644 index 0000000..4e2c4ae Binary files /dev/null and b/dist/fritzbox-0.7.1.tar.gz differ diff --git a/dist/fritzbox-0.7.1.zip b/dist/fritzbox-0.7.1.zip new file mode 100644 index 0000000..d655774 Binary files /dev/null and b/dist/fritzbox-0.7.1.zip differ diff --git a/doc/.gitignore b/doc/.gitignore new file mode 100644 index 0000000..9bb88d3 --- /dev/null +++ b/doc/.gitignore @@ -0,0 +1 @@ +/.DS_Store diff --git a/doc/AHA-HTTP-Interface.pdf b/doc/AHA-HTTP-Interface.pdf new file mode 100644 index 0000000..a6f0d4d Binary files /dev/null and b/doc/AHA-HTTP-Interface.pdf differ diff --git a/execute_unit_test.sh b/execute_unit_test.sh new file mode 100644 index 0000000..dc9e89c --- /dev/null +++ b/execute_unit_test.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +#Short description... + +#This module is used to execute the unit tests for this project + +#__author__ = "Dennis Jung" +#__copyright__ = "Copyright 2019, Dennis Jung" +#__credits__ = ["Dennis Jung"] +#__license__ = "GPL Version 3" +#__maintainer__ = "Dennis Jung" +#__email__ = "Dennis.Jung@it-jung.com" + + +#=============================================================================== +# Constant declaration +#=============================================================================== +# SHELL_COLORS +FORMAT_RED_NORMAL="\033[31;1m" +FORMAT_BLUE_NORMAL="\033[34;1m" +FORMAT_YELLOW_NORMAL="\033[33;1m" +FORMAT_GREEN_NORMAL="\033[32;1m" + +FORMAT_DEFAULT="\033[0m" + + +#=============================================================================== +# Determine system type +#=============================================================================== +unameOut="$(uname -s)" + +case "${unameOut}" in + Linux*) machine=Linux;; + Darwin*) machine=Mac;; + CYGWIN*) machine=Cygwin;; + MINGW*) machine=MinGw;; + *) machine="UNKNOWN:${unameOut}" +esac + + +#=============================================================================== +# Adapt system commands to machine type +#=============================================================================== +case "${unameOut}" in + Linux*) ESCAPED_ECHO=$(echo -e);; + Mac*) ESCAPED_ECHO=$(echo);; +esac + + +#=============================================================================== +# Function definitions +#=============================================================================== +# This functions writes a log message on the shell using +# the following parameters +# +# log_message (color, tag, message) +# color: Color from the list SHELL_COLORS +# tag: Will be written in braces in front of the log message; +# provided in quotes +# message: Message to be written on the shell; provided in quotes +log_message () { + color=$1 + tag=$2 + message=$3 + + echo $ESCAPED_ECHO "[$color$tag$FORMAT_DEFAULT] $message" +} + + +#=============================================================================== +# Start of script +#=============================================================================== +# Clear the shell screen +clear + +# Write initial log message +log_message $FORMAT_YELLOW_NORMAL "info" "Start execution of: $0" + +log_message $FORMAT_GREEN_NORMAL "info" "Start unit tests" + +py.test -v --cov-report term-missing --cov FBPresence + +log_message $FORMAT_GREEN_NORMAL "info" "Finalize unit tests" diff --git a/src/fritzbox/FBCore.py b/src/fritzbox/FBCore.py new file mode 100644 index 0000000..2910ae4 --- /dev/null +++ b/src/fritzbox/FBCore.py @@ -0,0 +1,283 @@ +# -*- coding: utf-8 -*- +"""Module for communication with a FritzBox. + +This module provides an interface for communicating with a FritzBox. +""" + +import _info + +__author__ = _info.__author__ +__copyright__ = _info.__copyright__ +__credits__ = _info.__credits__ +__license__ = _info.__license__ +__maintainer__ = _info.__maintainer__ +__email__ = _info.__email__ + + +#=============================================================================== +# Imports +#=============================================================================== +import argparse +import logging +import urllib.request +import hashlib +import re +import json +import time + +from xml.dom import minidom + +import xml.etree.ElementTree as ElementTree + + +#=============================================================================== +# Evaluate parameters +#=============================================================================== +if __name__ == '__main__': + parser = argparse.ArgumentParser(usage="%(prog)s [options]", + description="In case no option is selected the script will " + "connect to the FritzBox and return it's name") + + parser.add_argument('--v1', + help='Debug level INFO', + dest='verbose_INFO', + default=False, + action='store_true') + parser.add_argument('--v2', + help='Debug level ERROR', + dest='verbose_ERROR', + default=False, + action='store_true') + parser.add_argument('--v3', + help='Debug level DEBUG', + dest='verbose_DEBUG', + default=False, + action='store_true') + + parser.add_argument('-i', + '--ip', + help='IP adress of the FritzBox, eg. "192.168.0.1"', + dest='ip', + default="192.168.0.1", + action='store', + required=True) + parser.add_argument('-p', + '--password', + help='Password for accessing the FritzBox, eg. "mysecret123"', + dest='password', + default="password", + action='store', + required=True) + + args = parser.parse_args() + + +#=============================================================================== +# Setup logger +#=============================================================================== +if __name__ == '__main__': + log_level = logging.CRITICAL + + if args.verbose_INFO: + log_level = logging.INFO + + if args.verbose_ERROR: + log_level = logging.ERROR + + if args.verbose_DEBUG: + log_level = logging.DEBUG + +# logging.basicConfig(level=log_level, +# format="[{asctime}] - [{levelname}] - [{process}:{thread}] - [{filename}:{funcName}():{lineno}]: {message}", +# datefmt="%Y-%m-%d %H:%M:%S", +# style="{") + + logging.basicConfig(level=log_level, + format="[{asctime}] - [{levelname}]: {message}", + datefmt="%Y-%m-%d %H:%M:%S", + style="{") + +logger = logging.getLogger(__name__) + + +#=============================================================================== +# Constant declarations +#=============================================================================== +USER_AGENT = "Mozilla/5.0 (U; Windows NT 5.1; rv:5.0) Gecko/20100101 Firefox/5.0" + + +#=============================================================================== +# Exceptions +#=============================================================================== +class InvalidParameterError(Exception): + """Parameter error class""" + pass + + +#=============================================================================== +# Class definitions +#=============================================================================== +class FritzBox(object): + """Interface for communication with a FritzBox. + + This class provides an interface for communication with a FritzBox using LUA pages. + """ + + def __init__(self, ip, password): + self.ip = ip + self.password = password + self.sid = '' + + + def __del__(self): + pass + + + def load_fritzbox_page(self, url, param): + """Method to read out a page from the FritzBox. + + The method reads out the given page from the FritzBox. It automatically includes a session id + between url and param. + + Args: + url (str): URL of the page that shall be read out from the FritzBox. + param (str): Additional parameters that shall be added to the URL. + + Returns: + Requested page as string, None otherwise. + """ + + if self.login(): + page_url = 'http://' + self.ip + ':80' + url + '?sid=' + self.sid.decode('utf-8') + param + + logger.debug("Load the FritzBox page: " + page_url) + + headers = { "Accept" : "application/xml", + "Content-Type" : "text/plain", + "User-Agent" : USER_AGENT} + + request = urllib.request.Request(page_url, headers = headers) + + try: + response = urllib.request.urlopen(request) + except: + logger.error("Loading of the FritzBox page failed: %s" %(page_url)) + + return None + + page = response.read() + + if response.status != 200: + logger.error("Unexpected feedback from FritzBox received: %s %s" % (response.status, response.reason)) + + return None + else: + return page + else: + return None + + + def login(self): + """Authenticate with the FritzBox to access private pages. + + The method authenticates with a FritzBox using the authentication credentials + read out from the configuration file during the class object instantiation. + + Args: + Does not require any arguments. + + Returns: + Does not return any value. + """ + + logger.debug("Login to the FritzBox") + + headers = { "Accept" : "application/xml", + "Content-Type" : "text/plain", + "User-Agent" : USER_AGENT} + + page_url = 'http://' + self.ip + ':80/login_sid.lua' + + request = urllib.request.Request (page_url, headers = headers) + + try: + response = urllib.request.urlopen(request) + except: + logger.error("Loading of the FritzBox page failed: %s" %(page_url)) + + return False + + page = response.read() + + if response.status != 200: + logger.error("Unexpected feedback from FritzBox received: %s %s" % (response.status, response.reason)) + + return False + else: + page_xml = minidom.parseString(page) + + sid_info = page_xml.getElementsByTagName('SID') + + sid = sid_info[0].firstChild.data + + if sid == "0000000000000000": + challenge_info = page_xml.getElementsByTagName('Challenge') + + challenge = challenge_info[0].firstChild.data + + challenge_bf = (challenge + '-' + self.password).encode( 'utf-16le' ) + + m = hashlib.md5() + + m.update(challenge_bf) + + response_bf = challenge + '-' + m.hexdigest().lower() + + else: + logger.debug("Authentication succeeded") + + self.sid = sid + + return True + + headers = { "Accept" : "text/html,application/xhtml+xml,application/xml", + "Content-Type" : "application/x-www-form-urlencoded", + "User-Agent" : USER_AGENT} + + page_url = 'http://' + self.ip + ':80/login_sid.lua?&response=' + response_bf + + request = urllib.request.Request(page_url, headers = headers) + + response = urllib.request.urlopen(request) + + page = response.read() + + if response.status != 200: + logger.error("Unexpected feedback from FritzBox received: %s %s" % (response.status, response.reason)) + + return False + else: + sid = re.search(b'(.*?)', page).group(1) + + if sid == "0000000000000000": + logger.error("Authentication failed due to invalid password") + + return False + else: + logger.debug("Authentication succeeded") + + self.sid = sid + + return True + + +#=============================================================================== +# Main program +#=============================================================================== +def main(): + """Main function for testing purpose""" + fb = FritzBox(ip=args.ip, password=args.password) + + +if __name__ == '__main__': + main() diff --git a/src/fritzbox/FBHomeAuto.py b/src/fritzbox/FBHomeAuto.py new file mode 100644 index 0000000..7ea1da0 --- /dev/null +++ b/src/fritzbox/FBHomeAuto.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- +"""Module for communication with a FritzBox. + +This module provides an interface for communicating with a FritzBox. +""" + +import _info + +__author__ = _info.__author__ +__copyright__ = _info.__copyright__ +__credits__ = _info.__credits__ +__license__ = _info.__license__ +__maintainer__ = _info.__maintainer__ +__email__ = _info.__email__ + + +#=============================================================================== +# Imports +#=============================================================================== +import argparse +import logging +import urllib.request +import hashlib +import re +import json +import time +from xml.dom import minidom +import xml.etree.ElementTree as ElementTree + +import FBCore + + +#=============================================================================== +# Evaluate parameters +#=============================================================================== +if __name__ == '__main__': + parser = argparse.ArgumentParser(usage="%(prog)s [options]", + description="In case no option is selected the script will " + "return the list of all known devices including their WLAN presence status. " + "If --name or --mac is specified it will return 'True' if the device is present, " + " 'False' otherwise. Debouncing of the transitions to absent is not supported if " + "the script is used as command line tool.") + + parser.add_argument('--v1', + help='Debug level INFO', + dest='verbose_INFO', + default=False, + action='store_true') + parser.add_argument('--v2', + help='Debug level ERROR', + dest='verbose_ERROR', + default=False, + action='store_true') + parser.add_argument('--v3', + help='Debug level DEBUG', + dest='verbose_DEBUG', + default=False, + action='store_true') + + parser.add_argument('-i', + '--ip', + help='IP adress of the FritzBox, eg. "192.168.0.1"', + dest='ip', + default="192.168.0.1", + action='store', + required=True) + parser.add_argument('-p', + '--password', + help='Password for accessing the FritzBox, eg. "mysecret123"', + dest='password', + default="password", + action='store', + required=True) + + args = parser.parse_args() + + +#=============================================================================== +# Setup logger +#=============================================================================== +if __name__ == '__main__': + log_level = logging.CRITICAL + + if args.verbose_INFO: + log_level = logging.INFO + + if args.verbose_ERROR: + log_level = logging.ERROR + + if args.verbose_DEBUG: + log_level = logging.DEBUG + +# logging.basicConfig(level=log_level, +# format="[{asctime}] - [{levelname}] - [{process}:{thread}] - [{filename}:{funcName}():{lineno}]: {message}", +# datefmt="%Y-%m-%d %H:%M:%S", +# style="{") + + logging.basicConfig(level=log_level, + format="[{asctime}] - [{levelname}]: {message}", + datefmt="%Y-%m-%d %H:%M:%S", + style="{") + +logger = logging.getLogger(__name__) + + +#=============================================================================== +# Constant declarations +#=============================================================================== + + +#=============================================================================== +# Exceptions +#=============================================================================== +class InvalidParameterError(Exception): + """Parameter error class""" + pass + + +#=============================================================================== +# Class definitions +#=============================================================================== +class FBHomeAuto(object): + """Interface for communication with a FritzBox. + + This class provides an interface for communication with a FritzBox using LUA pages. + """ + + def __init__(self, ip, password): + self.fb = FBCore.FritzBox(ip, password) + + + def __del__(self): + pass + + + def get_switch_plugs(self): + """Load the AINs of all available switch plugs from the FritzBox. + + This method reads out the AINs of all switch plugs registered with the FritzBox and + returns them as a list. + + Args: + Does not require any arguments. + + Returns: + Returns a list with all AINs of registered switch plugs + """ + + logger.debug("Load the AINs of all available switch plugs from the FritzBox") + + page = self.fb.load_fritzbox_page('/webservices/homeautoswitch.lua', '&switchcmd=getswitchlist') + + switch_plugs = page.decode('UTF-8').strip('\n').split(',') + + return switch_plugs + + + def get_switch_plug_state(self, switch_plug_ain): + """Load the switch state of the given switch plug from the FritzBox. + + This method returns the switch state of the requested switch plug. + + Args: + switch_plug_ain (str): The AIN of the switch plug that should be checked + + Returns: + Returns the state of the switch plug + """ + + logger.debug("Load the switch state of the given switch plug from the FritzBox") + + page = self.fb.loadFritzBoxPage('/webservices/homeautoswitch.lua', '&switchcmd=getswitchstate&ain=' + + switch_plug_ain) + + switch_plug_state = page.decode('UTF-8').strip('\n') + + return switch_plug_state + + + def set_switch_plug_state(self, switch_plug_ain, state): + """Set the switch state of the given switch plug. + + This method sets the switch state of the requested switch plug. + + Args: + switch_plug_ain (str): The AIN of the switch plug that should be set + state (str): Target state 'on' or 'off' + + Returns: + Does not return any value. + """ + + logger.debug("Set the switch state for a given switch plug using the FritzBox") + + if state == 'on': + page = self.fb.loadFritzBoxPage('/webservices/homeautoswitch.lua', '&switchcmd=setswitchon&ain=' + + switch_plug_ain) + elif state == 'off': + page = self.fb.loadFritzBoxPage('/webservices/homeautoswitch.lua', '&switchcmd=setswitchoff&ain=' + + switch_plug_ain) + else: + pass + + switch_plug_state = page.decode('UTF-8').strip('\n') + + return switch_plug_state + + + def toggle_switch_plug_state(self, switch_plug_ain): + """Toggle the state of the given switch plug. + + This method toggles the state of the requested switch plug. + + Args: + switch_plug_ain (str): The AIN of the switch plug that should be set + + Returns: + Does not return any value. + """ + + logger.debug("Toggle the switch state for a given switch plug using the FritzBox") + + page = self.fb.loadFritzBoxPage('/webservices/homeautoswitch.lua', '&switchcmd=setswitchtoggle&ain=' + + switch_plug_ain) + + switch_plug_state = page.decode('UTF-8').strip('\n') + + return switch_plug_state + + +#=============================================================================== +# Main program +#=============================================================================== +def main(): + """Main function for testing purpose""" + fb_ha = FBHomeAuto(ip=args.ip, password=args.password) + + switch_plugs = fb_ha.get_switch_plugs() + print(switch_plugs) + + +if __name__ == '__main__': + main() diff --git a/src/fritzbox/FBPresence.py b/src/fritzbox/FBPresence.py new file mode 100644 index 0000000..510bf58 --- /dev/null +++ b/src/fritzbox/FBPresence.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +"""Module for communication with a FritzBox. + +This module provides an interface for communicating with a FritzBox. +""" + +import _info + +__author__ = _info.__author__ +__copyright__ = _info.__copyright__ +__credits__ = _info.__credits__ +__license__ = _info.__license__ +__maintainer__ = _info.__maintainer__ +__email__ = _info.__email__ + + +#=============================================================================== +# Imports +#=============================================================================== +import argparse +import logging +import urllib.request +import hashlib +import re +import json +import time +from xml.dom import minidom +import xml.etree.ElementTree as ElementTree + +import FBCore + + +#=============================================================================== +# Evaluate parameters +#=============================================================================== +if __name__ == '__main__': + parser = argparse.ArgumentParser(usage="%(prog)s [options]", + description="In case no option is selected the script will " + "return the list of all known devices including their WLAN presence status. " + "If --name or --mac is specified it will return 'True' if the device is present, " + "'False' otherwise. Debouncing of the transitions to absent is not supported if " + "the script is used as command line tool.") + + parser.add_argument('--v1', + help='Debug level INFO', + dest='verbose_INFO', + default=False, + action='store_true') + parser.add_argument('--v2', + help='Debug level ERROR', + dest='verbose_ERROR', + default=False, + action='store_true') + parser.add_argument('--v3', + help='Debug level DEBUG', + dest='verbose_DEBUG', + default=False, + action='store_true') + + parser.add_argument('-i', + '--ip', + help='IP adress of the FritzBox, eg. "192.168.0.1"', + dest='ip', + default="192.168.0.1", + action='store', + required=True) + parser.add_argument('-p', + '--password', + help='Password for accessing the FritzBox, eg. "mysecret123"', + dest='password', + default="password", + action='store', + required=True) + + parser.add_argument('-n', + '--name', + help='Check presence of device identified by its name registered on the FritzBox', + dest='name', + action='store') + + args = parser.parse_args() + + +#=============================================================================== +# Setup logger +#=============================================================================== +if __name__ == '__main__': + log_level = logging.CRITICAL + + if args.verbose_INFO: + log_level = logging.INFO + + if args.verbose_ERROR: + log_level = logging.ERROR + + if args.verbose_DEBUG: + log_level = logging.DEBUG + +# logging.basicConfig(level=log_level, +# format="[{asctime}] - [{levelname}] - [{process}:{thread}] - [{filename}:{funcName}():{lineno}]: {message}", +# datefmt="%Y-%m-%d %H:%M:%S", +# style="{") + + logging.basicConfig(level=log_level, + format="[{asctime}] - [{levelname}]: {message}", + datefmt="%Y-%m-%d %H:%M:%S", + style="{") + +logger = logging.getLogger(__name__) + + +#=============================================================================== +# Constant declarations +#=============================================================================== + + +#=============================================================================== +# Exceptions +#=============================================================================== +class InvalidParameterError(Exception): + """Parameter error class""" + pass + + +#=============================================================================== +# Class definitions +#=============================================================================== +class FBPresence(object): + """Interface for communication with a FritzBox. + + This class provides an interface for communication with a FritzBox using LUA pages. + """ + + def __init__(self, ip, password): + self.fb = FBCore.FritzBox(ip, password) + + self.device_list = {} + self.chk_ts = 0 + + + def __del__(self): + pass + + + def is_device_present(self, device_name=None, debounce_off=0): + """Check if the given device is currently in WLAN access range -> device is present. + + The method checks if the specified device is currently in WLAN access range of the FritzBox + to determine if it is present or not. You can optionally specify a debounce time for the transition + to the absent state. This is helpful if you observe sporadic absent detections e.g. for iPhone + devices. + + Args: + device_name (str): Device that shall be checked. + debounce_off (int): Debounce transition to absent by this no. of minutes + + Returns: + If the device is registered with the FritzBox the method will return True if the device is present, + False otherwise. + """ + + devices, chk_ts = self.get_wlan_device_information() + + if device_name is not None: + logger.debug("Check if the device " + device_name + " is present") + + if device_name in devices: + if chk_ts - devices[device_name]['on_ts'] == 0: + # Device is present + return True + elif chk_ts - devices[device_name]['on_ts'] <= 60 * debounce_off: + # Device is absent less than the defined debounce time + return True + else: + # Device is absent for more than the defined debounce time + return False + else: + # Device is not listed and therefore not present + return False + else: + raise InvalidParameterError() + + return False + + + def get_wlan_device_information(self): + """Query WLAN information for all FritzBox known devices. + + The method queries all WLAN related information for all devices known to the FritzBox. + + Args: + None + + Returns: + device_list (List): List with all devices and information as two-dimensional matrix. The + parameters for each device are accessible using the index + FB_WLAN_DEV_INFO elements. + + chk_ts (float): Timestamp of the last presence check + """ + + logger.debug("Load WLAN device information from the FritzBox for all known devices") + + self.chk_ts = time.time() + + page = self.fb.load_fritzbox_page('/data.lua', 'lang=de&no_sidrenew=&page=wSet') + + json_structure = json.loads(page.decode('UTF-8')) + + json_structure_devices = json_structure['data']['net']['devices'] + + for i in range(len(json_structure_devices)): + name = json_structure_devices[i]['name'] + on_ts = self.chk_ts + + self.device_list[name] = {'on_ts' : on_ts} + + return self.device_list, self.chk_ts + + +#=============================================================================== +# Main program +#=============================================================================== +def main(): + """Main function for testing purpose""" + fb_p = FBPresence(ip=args.ip, password=args.password) + + if args.name == None: + devices, chk_ts = fb_p.get_wlan_device_information() + + print(devices) + + elif args.name != None: + print(fb_p.is_device_present(device_name=args.name)) + + +if __name__ == '__main__': + main() diff --git a/src/fritzbox/_info.py b/src/fritzbox/_info.py new file mode 100644 index 0000000..9e20e71 --- /dev/null +++ b/src/fritzbox/_info.py @@ -0,0 +1,14 @@ +__package_name__ = "fritzbox" +__package_desc__ = "This package provides an interface for interaction with an AVM FritzBox" + +__author__ = "Dennis Jung, Dipl.-Ing.(FH)" +__email__ = "Dennis.Jung@mailbox.org" + +__maintainer__ = __author__ +__credits__ = [__author__] + +__copyright__ = "Copyright 2019-2021, " + __author__ +__license__ = "GPL Version 3" + +__url__ = "https://github.com/gasperphoenix/FritzBox" +__version__ = '0.7.1' diff --git a/src/fritzbox/setup.py b/src/fritzbox/setup.py new file mode 100644 index 0000000..88b2707 --- /dev/null +++ b/src/fritzbox/setup.py @@ -0,0 +1,15 @@ +from setuptools import setup, find_packages + +import _info + +setup( + name = _info.__package_name__, + version = _info.__version__, + license = _info.__license__, + author = _info.__author__, + author_email = _info.__email__, + url = _info.__url__, + description = _info.__package_desc__, + package_dir = {"" : "src"}, + py_modules = ["fritzbox.FBCore", "fritzbox.FBPresence", "fritzbox.FBHomeAuto"] + ) diff --git a/src/fritzbox/tests/test_FBPresence.py b/src/fritzbox/tests/test_FBPresence.py new file mode 100644 index 0000000..44425d3 --- /dev/null +++ b/src/fritzbox/tests/test_FBPresence.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +"""Short description. + +This test module will test the functionality of the module FBPresence +""" +from openpyxl.worksheet import page + +__author__ = "Dennis Jung" +__copyright__ = "Copyright 2019, Dennis Jung" +__credits__ = ["Dennis Jung"] +__license__ = "GPL Version 3" +__maintainer__ = "Dennis Jung" +__email__ = "Dennis.Jung@it-jung.com" + + +#=============================================================================== +# Additional information +#=============================================================================== + + +#=============================================================================== +# System imports +#=============================================================================== +import sys +import os +import json +import pytest + +from unittest import mock, TestCase +from unittest.mock import patch, Mock + + +#=============================================================================== +# Include parent folders +#=============================================================================== +dir_up = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(dir_up,'..')) + + +#=============================================================================== +# User imports +#=============================================================================== +from FBPresence import FBPresence, InvalidParameterError + + +#=============================================================================== +# Constant declarations +#=============================================================================== +IP = '192.168.0.1' +PASSWORD = 'abc' +TIMESTAMP_NOW = 123 + + +#=============================================================================== +# Test class definitions +#=============================================================================== +class test_CLASS(TestCase): + """Test class that contains all test cases""" + @patch('FBPresence.FBCore.FritzBox', autospec=True) + def setUp(self, fritzbox_mock): + self.fbP = FBPresence(ip=IP, password=PASSWORD) + fritzbox_mock.assert_called_once_with(IP, PASSWORD) + + + def tearDown(self): + pass + + + def test_init(self): + assert self.fbP.device_list == {} + + + @patch('FBPresence.json.loads', autospec=True) + @patch('FBPresence.time.time', autospec=True) + def test_get_wlan_device_information(self, time_mock, json_mock): + time_mock.return_value = TIMESTAMP_NOW + self.fbP.get_wlan_device_information() + assert json_mock.call_count == 1 + assert self.fbP.device_list == {} + + + @patch('FBPresence.FBPresence.get_wlan_device_information', autospec=True) + def test_is_device_present(self, fbpresence_mock): + debounce_time = 1 + + fbpresence_mock.return_value = {'PresentDevice' : {'on_ts' : 60*debounce_time*10}, + 'DebounceAbsentDevice' : {'on_ts' : 60*debounce_time*10-30}, + 'AbsentDevice' : {'on_ts' : 0}}, 60*debounce_time*10 + + assert self.fbP.is_device_present(device_name='UnknownDevice', debounce_off=debounce_time) == False + + assert self.fbP.is_device_present(device_name='PresentDevice', debounce_off=debounce_time) == True + assert self.fbP.is_device_present(device_name='DebounceAbsentDevice', debounce_off=debounce_time) == True + + assert self.fbP.is_device_present(device_name='AbsentDevice', debounce_off=debounce_time) == False + + with pytest.raises(InvalidParameterError): + self.fbP.is_device_present(debounce_off=1) == False + + +#=============================================================================== +# Start of program +#=============================================================================== +if __name__ == '__main__': + unittest.main()