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