diff --git a/.gitignore b/.gitignore index 23e6c9a..31c4dcb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ Pipfile.lock ### PyCharm ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +.idea # User-specific stuff .idea/**/workspace.xml diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 65531ca..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 9cf70ba..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/pyblnet.iml b/.idea/pyblnet.iml deleted file mode 100644 index bf708e3..0000000 --- a/.idea/pyblnet.iml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..c65c273 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,24 @@ +language: python + +python: +# - "2.7" + - "3.4" + - "3.5" + - "3.6" + - "3.7" +dist: xenial +sudo: true + +matrix: + fast_finish: true + +install: + - pip install coverage + - pip install coveralls + +script: + - coverage run setup.py test + +after_success: + - coverage report + - coveralls diff --git a/README.md b/README.md index 8cc7c3e..86cea27 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ # PyBLNET - a very basic python BL-NET bridge +[![Build Status](https://travis-ci.com/nielstron/pyblnet.svg?branch=master)](https://travis-ci.com/nielstron/pyblnet) +[![Coverage Status](https://coveralls.io/repos/github/nielstron/pyblnet/badge.svg?branch=master)](https://coveralls.io/github/nielstron/pyblnet?branch=master) + A package that connects to the BL-NET that is connected itself to a UVR1611 device by Technische Alternative. It is able to read digital and analog values as well as to set switches to ON/OFF/AUTO @@ -27,4 +30,4 @@ blnet = BLNETDirect(ip) print(blnet.get_latest()) # Still inofficial because unexplicably failing often print(blnet._get_data(1)) -``` \ No newline at end of file +``` diff --git a/pyblnet/blnet.py b/pyblnet/blnet.py index f330b3a..770c8b7 100644 --- a/pyblnet/blnet.py +++ b/pyblnet/blnet.py @@ -3,6 +3,11 @@ """ Created on 13.08.2018 +General high-level BLNET interface. + +Abstracts from actual type of connection to the BLNET and provides as much functionality +as possible. + @author: Niels """ from pyblnet.blnet_web import BLNETWeb @@ -13,8 +18,19 @@ class BLNET(object): """ - General high-level BLNET class, using just - what is available and more precise + General high-level BLNET interface. + + Abstracts from actual type of connection to the BLNET and provides as much functionality + as possible. + Attributes: + address ip address of the BLNET + web_port port of the HTTP interface + password password for authenticating on the HTTP interface + ta_port port of the PC-BLNET interface + timeout timeout for requests + max_retries maximum number of connection retries before aborting + use_web boolean about whether to make use of the HTTP interface + use_ta boolean about whether to make use of the (buggy) PC-BLNET interface """ def __init__(self, @@ -42,7 +58,7 @@ def __init__(self, self.blnet_web = None self.blnet_direct = None if use_web: - self.blnet_web = BLNETWeb(address, password, timeout) + self.blnet_web = BLNETWeb("{}:{}".format(address, web_port), password, timeout) if use_ta: # The address might not have a resulting hostname # especially not if not prefixed with http:// @@ -53,6 +69,7 @@ def __init__(self, def fetch(self, node=None): """ Fetch all available data about selected node + (defaults to active node on the device) """ data = { 'analog': {}, @@ -84,12 +101,24 @@ def fetch(self, node=None): return data def turn_on(self, digital_id, can_node=None): + """ + Turn switch with given id on given node on + Return: no error during set operation + """ return self._turn(digital_id, 'EIN', can_node) def turn_off(self, digital_id, can_node=None): + """ + Turn switch with given id on given node off + Return: no error during set operation + """ return self._turn(digital_id, 'AUS', can_node) def turn_auto(self, digital_id, can_node=None): + """ + Turn switch with given id on given node to "AUTO"/ give control over to UVR + Return: no error during set operation + """ return self._turn(digital_id, 'AUTO', can_node) def _turn(self, digital_id, value, can_node=None): diff --git a/pyblnet/blnet_conn.py b/pyblnet/blnet_conn.py index fe1c30e..47552ac 100644 --- a/pyblnet/blnet_conn.py +++ b/pyblnet/blnet_conn.py @@ -36,6 +36,9 @@ class BLNETDirect(object): """ A class for establishing a direct connection to the BLNET (rather than scraping the web interface) + + It uses this protocol and is still very buggy + www.haus-terra.at/heizung/download/Schnittstelle/Schnittstelle_PC_Bootloader.pdf """ def __init__(self, address, port=40000, reset=False): @@ -117,11 +120,13 @@ def get_count(self): else: raise ConnectionError('Could not retreive count') - def _get_data(self, max_count=math.inf): + def _get_data(self, max_count=None): data = [] try: count = self._start_read() - for _ in range(0, min(count, max_count)): + if isinstance(max_count, int): + count = min(max_count, count) + for _ in range(0, count): data.append(self._fetch_data()) self._end_read(True) return data diff --git a/pyblnet/blnet_web.py b/pyblnet/blnet_web.py index bdf35b1..bfe17df 100644 --- a/pyblnet/blnet_web.py +++ b/pyblnet/blnet_web.py @@ -3,6 +3,9 @@ """ Created on 26.09.2017 +A module for connecting with, collecting data from and controlling the BLNet +via it's HTTP-interface + @author: Nielstron """ @@ -11,9 +14,10 @@ import html import re from builtins import int +import pickle -def test_blnet(ip, timeout=5): +def test_blnet(ip, timeout=5, id=0): """ Tests whether an BLNET answers under given ip Attributes: @@ -38,10 +42,12 @@ def test_blnet(ip, timeout=5): class BLNETWeb(object): """ - Interface to communicate with the UVR1611 over his web surface (BL-Net) + Interface for connecting with, collecting data from and controlling the BLNet + via it's HTTP-interface Attributes: - ip The ip/domain of the UVR1611/BL-Net to connect to + ip the ip/domain of the BL-Net to connect to password the password to log into the web interface provided + timeout timeout for http requests """ ip = "" _def_password = "0128" # default password is 0128 @@ -61,8 +67,6 @@ def __init__(self, ip, password=_def_password, timeout=5): raise ValueError( 'No BLNET found under given address: {}'.format(ip)) self.ip = ip - if password is None: - password = self._def_password self.password = password self._timeout = timeout @@ -71,6 +75,8 @@ def logged_in(self): Determines whether the object is still connected to the BLNET / Logged into the web interface """ + if self.password is None: + return True # check if a request to a restricted page returns a cookie if # we have sent one (if our cookie is the current one this # would be the case) @@ -98,7 +104,8 @@ def log_in(self): Return: Login successful """ - if self.logged_in(): return True + if self.logged_in(): + return True payload = { 'blu': 1, # log in as experte 'blp': self.password, @@ -113,10 +120,10 @@ def log_in(self): timeout=self._timeout) except requests.exceptions.RequestException: return False + self.current_taid = r.headers.get('Set-Cookie') # try two times to log in i = 0 while i < 2: - self.current_taid = r.headers.get('Set-Cookie') i += 1 if self.logged_in(): return True @@ -128,6 +135,8 @@ def log_out(self): Return: successful log out """ + if self.password is None: + return True try: requests.get( self.ip + "/main.html?blL=1", @@ -142,7 +151,7 @@ def set_node(self, node): Selects the node at which the UVR of interest lies future requests will be sent at this particular UVR - Return: Successful node change + Return: Still logged in (indicating successful node change) """ # ensure to be logged in if not self.log_in(): @@ -157,7 +166,7 @@ def set_node(self, node): except requests.exceptions.RequestException: return False # return whether we we're still logged in => setting went well - return r.headers.get('Set-Cookie') is not None + return self.password is None or r.headers.get('Set-Cookie') is not None def read_analog_values(self): """ @@ -165,9 +174,9 @@ def read_analog_values(self): and returns list of quadruples of id, name, value, unit of measurement """ # ensure to be logged in - if not self.log_in(): return None + if not self.log_in(): + return None - if not self.logged_in(): self.log_in() try: r = requests.get( self.ip + "/580500.htm", @@ -211,9 +220,9 @@ def read_digital_values(self): (EIN/AUS) """ # ensure to be logged in - if not self.log_in(): return None + if not self.log_in(): + return None - if not self.logged_in(): self.log_in() try: r = requests.get( self.ip + "/580600.htm", @@ -258,24 +267,43 @@ def set_digital_value(self, digital_id, value): Attributes: id id of the device whichs state should be changed value value to change the state to - Return: successful set + Return: still logged in (indicating successful set) """ digital_id = int(digital_id) # throw error for wrong id's if digital_id < 1: - raise ValueError('Device id can\'t be smaller than 1') + raise ValueError('Device id can\'t be smaller than 1, was {}'.format(digital_id)) + if digital_id > 15: + raise ValueError('Device id can\'t be larger than 15, was {}'.format(digital_id)) # ensure to be logged in if not self.log_in(): return False # transform input value to 'EIN' or 'AUS' - if value == 'AUTO': - value = '3' # 3 means auto - elif value != 'AUS' and value: - value = '2' # 2 means turn on + if isinstance(value, str): + if value.lower() == 'AUTO'.lower() or value == '3': + value = '3' # 3 means auto + elif value.lower() == 'EIN'.lower() or value == '2' or value.lower() == 'on'.lower(): + value = '2' # 2 means turn on + elif value.lower() == 'AUS'.lower() or value == '1' or value.lower() == 'off'.lower(): + value = '1' # 1 means turn off + else: + raise ValueError("Illegal input string {}".format(value)) + elif isinstance(value, int) and not isinstance(value, bool): + if value is 3 or value is 2 or value is 1: + value = str(value) + elif value is 0: + value = '1' + else: + raise ValueError("Illegal input integer {}".format(value)) else: - value = '1' # 1 means turn off + # value can be interpreted as a true value + if value: + value = '2' # 2 means turn on + else: + value = '1' # 1 means turn off + assert(value in ['1', '2', '3']) # convert id to hexvalue so that 10 etc become A... hex_repr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 'A', 'B', 'C', 'D', 'E', 'F'] @@ -293,4 +321,4 @@ def set_digital_value(self, digital_id, value): return False # return whether we we're still logged in => setting went well - return r.headers.get('Set-Cookie') is not None + return self.password is None or r.headers.get('Set-Cookie') is not None diff --git a/pyblnet/tests/__init__.py b/pyblnet/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyblnet/tests/test_structure/.error.html b/pyblnet/tests/test_structure/.error.html new file mode 100644 index 0000000..2662e31 --- /dev/null +++ b/pyblnet/tests/test_structure/.error.html @@ -0,0 +1,10 @@ + + + + BL-Net Zugang verweigert +Sie sind nicht befugt auf diese Seite zuzugreifen!!!
+ +
+ \ No newline at end of file diff --git a/pyblnet/tests/test_structure/580500.htm b/pyblnet/tests/test_structure/580500.htm new file mode 100644 index 0000000..176c64a Binary files /dev/null and b/pyblnet/tests/test_structure/580500.htm differ diff --git a/pyblnet/tests/test_structure/580600.htm b/pyblnet/tests/test_structure/580600.htm new file mode 100644 index 0000000..9b098ca Binary files /dev/null and b/pyblnet/tests/test_structure/580600.htm differ diff --git a/pyblnet/tests/test_structure/__init__.py b/pyblnet/tests/test_structure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyblnet/tests/test_structure/blnet_mock_server.py b/pyblnet/tests/test_structure/blnet_mock_server.py new file mode 100644 index 0000000..38f80a6 --- /dev/null +++ b/pyblnet/tests/test_structure/blnet_mock_server.py @@ -0,0 +1,226 @@ +import os +import urllib.parse +from http.server import SimpleHTTPRequestHandler, HTTPServer +try: + from http import HTTPStatus +except ImportError: + # Backwards compatability + import http.client as HTTPStatus +import re +import posixpath +from pathlib import Path + +SERVER_DIR = Path(__file__).parent or Path('.') + + +class BLNETServer(HTTPServer): + + # Currently allowed login cookie + # or none if no one logged in + logged_in = True + # no login necessary + password = None + # access to digital nodes + nodes = {} + + def set_password(self, password): + self.password = password + self.logged_in = None + + def set_logged_in(self, cookie): + self.logged_in = cookie + + def set_node(self, id, value): + self.nodes[id] = value + + def get_node(self, id): + return self.nodes.get(id) + + +class BLNETRequestHandler(SimpleHTTPRequestHandler): + + def do_GET(self): + """ + Handle get request, but check for errors in protocol + :return: + """ + path = self.translate_path(self.path) + # Only access that is allowed without login is main.html + if ( + not Path(path) == SERVER_DIR + and not Path(path) == SERVER_DIR.joinpath('main.htm') + and not Path(path) == SERVER_DIR.joinpath('main.html') + ): + if not self.server.logged_in: + self.send_error(403, "Not logged in, access denied") + return + # Parse node sets + node_reg = re.compile(r'[?&]blw91A1200(?P[0-9a-fA-F])=(?P[1-3])') + for match in node_reg.finditer(self.path): + self.server.set_node( + match.group('node'), + match.group('value') + ) + + # print(path) + super().do_GET() + + def do_POST(self): + # Result of self.rfile.read() if correct POST request: + # b'blu=1&blp=0123&bll=Login' + perfect = b'blu=1&blp=0123&bll=Login' + request_raw = self.rfile.read(len(perfect)) + request_string = request_raw.decode(encoding='utf-8') + request_data = request_string.split("&") + + blu = False + blp = False + bll = False + for query in request_data: + if query.startswith("blu"): + if query.split("=")[1] != "1": + self.send_error(403, "Wrong user set: expected blu=1, got {}".format(query)) + return + blu = True + elif query.startswith("blp"): + if query.split("=")[1] != self.server.password: + self.send_error(403, "Wrong password: expected blp={}, got {}".format(self.server.password, query)) + return + blp = True + elif query.startswith("bll"): + if query.split("=")[1] != "Login": + self.send_error(403, "Wrong bll spec, expected bll=Login, got {}".format(request_string)) + return + bll = True + if not (blu and blp and bll): + self.send_error(403, "Missing query param in {}".format(request_string)) + return + # Check for content type + if not self.headers.get('Content-Type') == 'application/x-www-form-urlencoded': + self.send_error(403, "Wrong content-type") + return + # All checks passed? set Set-Cookie header and do_GET + # random cookie - do not hardcode + self.server.set_logged_in('C1A3') + self.headers.add_header('Cookie', 'C1A3') + self.do_GET() + + def translate_path(self, path): + """Translate a /-separated PATH to the local filename syntax. + + Components that mean special things to the local file system + (e.g. drive or directory names) are ignored. (XXX They should + probably be diagnosed.) + + only slightly changed method of the standard library + """ + # abandon query parameters + path = path.split('?',1)[0] + path = path.split('#',1)[0] + # Don't forget explicit trailing slash when normalizing. Issue17324 + trailing_slash = path.rstrip().endswith('/') + try: + path = urllib.parse.unquote(path, errors='surrogatepass') + except UnicodeDecodeError: + path = urllib.parse.unquote(path) + path = posixpath.normpath(path) + words = path.split('/') + words = filter(None, words) + path = str(SERVER_DIR.absolute()) + for word in words: + if os.path.dirname(word) or word in (os.curdir, os.pardir): + # Ignore components that are not a simple file/directory name + continue + path = os.path.join(path, word) + if trailing_slash: + path += '/' + return path + + def send_head(self): + """Common code for GET and HEAD commands. + + This sends the response code and MIME headers. + + Return value is either a file object (which has to be copied + to the outputfile by the caller unless the command was HEAD, + and must be closed by the caller under all circumstances), or + None, in which case the caller has nothing further to do. + + only slightly changed method of the standard library + """ + path = self.translate_path(self.path) + f = None + if os.path.isdir(path): + parts = urllib.parse.urlsplit(self.path) + if not parts.path.endswith('/'): + # redirect browser - doing basically what apache does + self.send_response(HTTPStatus.MOVED_PERMANENTLY) + new_parts = (parts[0], parts[1], parts[2] + '/', + parts[3], parts[4]) + new_url = urllib.parse.urlunsplit(new_parts) + self.send_header("Location", new_url) + self.end_headers() + return None + # BLNET has main.html/main.htm + for index in "main.html", "main.htm": + index = os.path.join(path, index) + if os.path.exists(index): + path = index + break + else: + return self.list_directory(path) + ctype = self.guess_type(path) + try: + f = open(path, 'rb') + except OSError: + self.send_error(HTTPStatus.NOT_FOUND, "File not found") + return None + try: + self.send_response(HTTPStatus.OK) + self.send_header("Content-type", ctype) + fs = os.fstat(f.fileno()) + self.send_header("Content-Length", str(fs[6])) + self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) + # Addition: send cookie + if ( + self.server.logged_in is not None + and self.server.logged_in == self.headers.get('Cookie') + ): + self.send_header('Set-Cookie', 'C1A3') + self.end_headers() + return f + except: + f.close() + raise + + def send_error(self, code, message=None, explain=None): + """ + Send blnet zugang verweigert page + :param code: + :param message: + :param explain: + :return: + """ + self.log_error("code %d, message %s", code, message) + self.send_response(code, message) + self.send_header('Connection', 'close') + + # Message body is omitted for cases described in: + # - RFC7230: 3.3. 1xx, 204(No Content), 304(Not Modified) + # - RFC7231: 6.3.6. 205(Reset Content) + body = None + if (code >= 200 and + code not in (HTTPStatus.NO_CONTENT, + HTTPStatus.RESET_CONTENT, + HTTPStatus.NOT_MODIFIED)): + # HTML encode to prevent Cross Site Scripting attacks + # (see bug #1100201) + # Specialized error method for BLNET + with SERVER_DIR.joinpath(".error.html").open('rb') as file: + body = file.read() + self.send_header("Content-Type", self.error_content_type) + self.send_header('Content-Length', int(len(body))) + self.end_headers() + + if self.command != 'HEAD' and body: + self.wfile.write(body) diff --git a/pyblnet/tests/test_structure/main.htm b/pyblnet/tests/test_structure/main.htm new file mode 100644 index 0000000..e76e25a --- /dev/null +++ b/pyblnet/tests/test_structure/main.htm @@ -0,0 +1,63 @@ + + + + + Bl-Net + + + + +
+
+ +
+ +
+
Login +

+
+
+
+
+ +
+
+ +
+ +
+
+ + \ No newline at end of file diff --git a/pyblnet/tests/test_structure/main.html b/pyblnet/tests/test_structure/main.html new file mode 100644 index 0000000..e76e25a --- /dev/null +++ b/pyblnet/tests/test_structure/main.html @@ -0,0 +1,63 @@ + + + + + Bl-Net + + + + +
+
+ +
+ +
+
Login +

+
+
+
+
+ +
+
+ +
+ +
+
+ + \ No newline at end of file diff --git a/pyblnet/tests/test_structure/par.htm b/pyblnet/tests/test_structure/par.htm new file mode 100644 index 0000000..f79aa58 Binary files /dev/null and b/pyblnet/tests/test_structure/par.htm differ diff --git a/pyblnet/tests/test_structure/server_control.py b/pyblnet/tests/test_structure/server_control.py new file mode 100644 index 0000000..f4087ba --- /dev/null +++ b/pyblnet/tests/test_structure/server_control.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import threading + + +class Server: + + def __init__(self, server): + """ + Expects subclass of TCPServer as argument + """ + self._server = server + self._server_started_event = threading.Event() + self._server_running = False + + def _run_server(self): + + print("Server started, serving on port {}".format(self.get_port())) + + # notify about start + self._server_started_event.set() + self._server_running = True + + try: + self._server.serve_forever() + finally: + self._cleanup_server() + + def _cleanup_server(self): + self._server_running = False + self._server.server_close() + # Here, server was stopped + print("Server stopped") + + def stop_server(self): + """ + Close server forcibly + :return: + """ + print("Stopping server") + if self._server_running: + self._server.shutdown() + + def start_server(self, timeout=10): + """ + Start server thread as daemon + As such the program will automatically close the thread on exit of all non-daemon threads + :return: + """ + self._server_started_event.clear() + # start webserver as daemon => will automatically be closed when non-daemon threads are closed + t = threading.Thread(target=self._run_server, daemon=True) + # Start webserver + t.start() + # wait (non-busy) for successful start + self._server_started_event.wait(timeout=timeout) + + def get_port(self): + return self._server.server_address[1] diff --git a/pyblnet/tests/test_web.py b/pyblnet/tests/test_web.py new file mode 100644 index 0000000..19cb9c8 --- /dev/null +++ b/pyblnet/tests/test_web.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# general requirements +import unittest +from .test_structure.server_control import Server +from .test_structure.blnet_mock_server import BLNETServer, BLNETRequestHandler + +# For the server in this case +import time + +# For the tests +from pyblnet import BLNET, test_blnet, BLNETWeb +from .web_raw.web_state import STATE, STATE_ANALOG, STATE_DIGITAL + + +ADDRESS = 'localhost' +PASSWORD = '0123' + + +class SetupTest(unittest.TestCase): + + server = None + server_control = None + port = 0 + url = 'http://localhost:80' + + def setUp(self): + # Create an arbitrary subclass of TCP Server as the server to be started + # Here, it is an Simple HTTP file serving server + handler = BLNETRequestHandler + + max_retries = 10 + r = 0 + while not self.server: + try: + # Connect to any open port + self.server = BLNETServer((ADDRESS, 0), handler) + except OSError: + if r < max_retries: + r += 1 + else: + raise + time.sleep(1) + + self.server_control = Server(self.server) + self.server.set_password(PASSWORD) + self.port = self.server_control.get_port() + self.url = "http://{}:{}".format(ADDRESS, self.port) + # Start test server before running any tests + self.server_control.start_server() + + def test_blnet(self): + """ Test finding the blnet """ + self.assertTrue(test_blnet(self.url, timeout=10)) + + def test_blnet_login(self): + """ Test logging in """ + self.assertTrue(BLNETWeb(self.url, password=PASSWORD, timeout=10).log_in()) + + def test_blnet_fetch(self): + """ Test fetching data in higher level class """ + self.assertEqual( + BLNET(ADDRESS, password=PASSWORD, timeout=10, use_ta=False, web_port=self.port).fetch(), + STATE + ) + + def test_blnet_web_analog(self): + """ Test reading analog values """ + self.assertEqual( + BLNETWeb(self.url, password=PASSWORD, timeout=10).read_analog_values(), + STATE_ANALOG + ) + + def test_blnet_web_digital(self): + """ Test reading digital values""" + self.assertEqual( + BLNETWeb(self.url, password=PASSWORD, timeout=10).read_digital_values(), + STATE_DIGITAL + ) + + def test_blnet_web_set_digital(self): + """ Test setting digital values """ + blnet = BLNETWeb(self.url, password=PASSWORD, timeout=10) + blnet.set_digital_value(10, '2') + self.assertEqual(self.server.get_node('A'), '2') + blnet.set_digital_value(9, 'EIN') + self.assertEqual(self.server.get_node('9'), '2') + blnet.set_digital_value(8, 'auto') + self.assertEqual(self.server.get_node('8'), '3') + blnet.set_digital_value(1, 'on') + self.assertEqual(self.server.get_node('1'), '2') + blnet.set_digital_value(1, 'AUS') + self.assertEqual(self.server.get_node('1'), '1') + blnet.set_digital_value(5, 3) + self.assertEqual(self.server.get_node('5'), '3') + blnet.set_digital_value(4, True) + self.assertEqual(self.server.get_node('4'), '2') + blnet.set_digital_value(6, False) + self.assertEqual(self.server.get_node('6'), '1') + try: + blnet.set_digital_value(0, 'EIN') + self.fail("Didn't catch wrong id 0") + except ValueError: + pass + try: + blnet.set_digital_value(16, 'EIN') + self.fail("Didn't catch wrong id 16") + except ValueError: + pass + + def tearDown(self): + self.server_control.stop_server() + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/pyblnet/tests/web_raw/__init__.py b/pyblnet/tests/web_raw/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyblnet/tests/web_raw/analog_values b/pyblnet/tests/web_raw/analog_values new file mode 100644 index 0000000..2f1a5a9 Binary files /dev/null and b/pyblnet/tests/web_raw/analog_values differ diff --git a/pyblnet/tests/web_raw/digital_values b/pyblnet/tests/web_raw/digital_values new file mode 100644 index 0000000..3988b72 Binary files /dev/null and b/pyblnet/tests/web_raw/digital_values differ diff --git a/pyblnet/tests/web_raw/logged_in b/pyblnet/tests/web_raw/logged_in new file mode 100644 index 0000000..de62d33 Binary files /dev/null and b/pyblnet/tests/web_raw/logged_in differ diff --git a/pyblnet/tests/web_raw/main_alt_one b/pyblnet/tests/web_raw/main_alt_one new file mode 100644 index 0000000..d592e40 Binary files /dev/null and b/pyblnet/tests/web_raw/main_alt_one differ diff --git a/pyblnet/tests/web_raw/web_state.py b/pyblnet/tests/web_raw/web_state.py new file mode 100644 index 0000000..fc9f9fc --- /dev/null +++ b/pyblnet/tests/web_raw/web_state.py @@ -0,0 +1,38 @@ +STATE = {'analog': {2: {'id': '2', 'name': 'TSP.oben', 'value': '47.1', 'unit_of_measurement': '°C'}, + 3: {'id': '3', 'name': 'TSP.unten', 'value': '57.8', 'unit_of_measurement': '°C'}, + 4: {'id': '4', 'name': 'THeizkr.VL', 'value': '45.8', 'unit_of_measurement': '°C'}, + 5: {'id': '5', 'name': 'Temp.Aussen', 'value': '1.1', 'unit_of_measurement': '°C'}, + 6: {'id': '6', 'name': 'Temp.Raum', 'value': '24.1', 'unit_of_measurement': '°C'}, + 7: {'id': '7', 'name': 'T\xa0Kaminofen', 'value': '55.8', 'unit_of_measurement': '°C'}, + 9: {'id': '9', 'name': 'TZirku.RL', 'value': '22.4', 'unit_of_measurement': '°C'}}, + 'digital': {1: {'id': '1', 'name': 'Pumpe-Solar', 'mode': 'AUTO', 'value': 'AUS'}, + 2: {'id': '2', 'name': 'Pumpe-Hzkr', 'mode': 'AUTO', 'value': 'EIN'}, + 5: {'id': '5', 'name': 'Anf.Kessel', 'mode': 'AUTO', 'value': 'AUS'}, + 6: {'id': '6', 'name': 'Vent.Solar', 'mode': 'HAND', 'value': 'AUS'}, + 7: {'id': '7', 'name': 'P\xa0Kaminofen', 'mode': 'AUTO', 'value': 'EIN'}, + 10: {'id': '10', 'name': 'WW-Pumpe1', 'mode': 'HAND', 'value': 'AUS'}}, 'speed': {}, 'energy': {}, + 'power': {}} + +STATE_ANALOG = [{'id': '2', 'name': 'TSP.oben', 'unit_of_measurement': '°C', 'value': '47.1'}, + {'id': '3', 'name': 'TSP.unten', 'unit_of_measurement': '°C', 'value': '57.8'}, + {'id': '4', + 'name': 'THeizkr.VL', + 'unit_of_measurement': '°C', + 'value': '45.8'}, + {'id': '5', + 'name': 'Temp.Aussen', + 'unit_of_measurement': '°C', + 'value': '1.1'}, + {'id': '6', 'name': 'Temp.Raum', 'unit_of_measurement': '°C', 'value': '24.1'}, + {'id': '7', + 'name': 'T\xa0Kaminofen', + 'unit_of_measurement': '°C', + 'value': '55.8'}, + {'id': '9', 'name': 'TZirku.RL', 'unit_of_measurement': '°C', 'value': '22.4'}] + +STATE_DIGITAL = [{'id': '1', 'mode': 'AUTO', 'name': 'Pumpe-Solar', 'value': 'AUS'}, + {'id': '2', 'mode': 'AUTO', 'name': 'Pumpe-Hzkr', 'value': 'EIN'}, + {'id': '5', 'mode': 'AUTO', 'name': 'Anf.Kessel', 'value': 'AUS'}, + {'id': '6', 'mode': 'HAND', 'name': 'Vent.Solar', 'value': 'AUS'}, + {'id': '7', 'mode': 'AUTO', 'name': 'P\xa0Kaminofen', 'value': 'EIN'}, + {'id': '10', 'mode': 'HAND', 'name': 'WW-Pumpe1', 'value': 'AUS'}] diff --git a/setup.py b/setup.py index 4c34361..2417a72 100644 --- a/setup.py +++ b/setup.py @@ -15,13 +15,14 @@ setup( name='PyBLNET', - version='0.7.1', - description='Automate web based communication with BL-NET to UVR1611', + version='0.7.3', + description='Automate wireless communication to UVR1611 via BL-NET', author='Niels Mündler', author_email='n.muendler@web.de', url='https://github.com/nielstron/pyblnet/', py_modules=['pyblnet'], packages=find_packages(), + package_data={'': ['*.html', '*.htm']}, install_requires=['htmldom', 'requests'], long_description=long_description, long_description_content_type='text/markdown', @@ -37,8 +38,12 @@ # Specify the Python versions you support here. In particular, ensure # that you indicate whether you support Python 2, Python 3 or both. - 'Programming Language :: Python :: 3.6' + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', ], - keywords='python uvr1611 blnet technische alternative', + keywords='python uvr1611 blnet technische alternative home automation iot', python_requires='>=3', + test_suite='pyblnet.tests', )