├── .github └── workflows │ └── python-fhem-test.yaml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── fhem ├── fhem │ └── __init__.py └── setup.py ├── publish.sh ├── selftest ├── README.md ├── fhem-config-addon.cfg └── selftest.py └── test_mod.sh /.github/workflows/python-fhem-test.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ "master", "dev_0.7.0" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.8", "3.10", "3.11"] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Get perl stuff 28 | run: | 29 | sudo apt install perl libio-socket-ssl-perl 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | python -m pip install twine build 34 | - name: Build python-fhem package 35 | run: | 36 | cd fhem 37 | cp ../README.md . 38 | python -m build 39 | - name: Install python-fhem 40 | run: | 41 | python -m pip install ./fhem/dist/*.gz 42 | rm ./fhem/dist/* 43 | # - name: Install fhem server 44 | # run: | 45 | # cd selftest 46 | # wget -nv https://fhem.de/fhem-6.0.tar.gz 47 | # mkdir fhem 48 | # cd fhem 49 | # tar -xzf ../fhem-6.0.tar.gz 50 | # cd fhem-6.0 51 | # mkdir certs 52 | # cd certs 53 | # openssl req -newkey rsa:2048 -nodes -keyout server-key.pem -x509 -days 36500 -out server-cert.pem -subj "/C=DE/ST=NRW/L=Earth/O=CompanyName/OU=IT/CN=www.example.com/emailAddress=email@example.com" 54 | # cd .. 55 | # cp ../../fhem-config-addon.cfg fhem.cfg 56 | # perl fhem.pl fhem.cfg 57 | - name: Test with selftest.py 58 | run: | 59 | cd selftest 60 | python selftest.py 61 | # python selftest.py --reuse 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled python modules. 2 | *.pyc 3 | 4 | # Setuptools distribution folder. 5 | /fhem/dist/ 6 | /fhem/build/ 7 | 8 | # Python egg metadata, regenerated from source files by setuptools. 9 | /fhem/*.egg-info 10 | 11 | # Just a copy created for PyPI bundling 12 | /fhem/README.md 13 | 14 | /tests/ 15 | /test/ 16 | 17 | .vscode 18 | 19 | /selftest/fhem 20 | /selftest/*.tar.gz 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | matrix: 2 | include: 3 | 4 | - language: python 5 | python: 6 | - "3.6" 7 | 8 | cache: 9 | directories: 10 | - "~/selftest" 11 | 12 | install: 13 | - sudo apt-get install -y libio-socket-ssl-perl 14 | - cd fhem 15 | - cp ../README.md . 16 | - python setup.py install 17 | - cd .. 18 | 19 | script: 20 | - cd selftest && python selftest.py 21 | 22 | - language: python 23 | python: 24 | - "2.7" 25 | 26 | cache: 27 | directories: 28 | - "~/selftest" 29 | 30 | install: 31 | - sudo apt-get install -y libio-socket-ssl-perl 32 | - cd fhem 33 | - cp ../README.md . 34 | - python setup.py install 35 | - cd .. 36 | 37 | script: 38 | - cd selftest && python selftest.py 39 | 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2023 Dominik Schlösser, dsc@dosc.net 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI version](https://badge.fury.io/py/fhem.svg)](https://badge.fury.io/py/fhem) 2 | [![Python package](https://github.com/domschl/python-fhem/actions/workflows/python-fhem-test.yaml/badge.svg)](https://github.com/domschl/python-fhem/actions/workflows/python-fhem-test.yaml) 3 | [![License](http://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat)](LICENSE) 4 | [![Docs](https://img.shields.io/badge/docs-stable-blue.svg)](https://domschl.github.io/python-fhem/index.html) 5 | 6 | # python-fhem 7 | 8 | Python FHEM (home automation server) API 9 | 10 | Simple API to connect to the [FHEM home automation server](https://fhem.de/) via sockets or http(s), using the telnet or web port on FHEM with optional SSL (TLS) and password or basicAuth support. 11 | 12 | **Note:** Starting with verson 0.7.0, Python 2.x is no longer supported with `python-fhem`. If you still require support for Python 2, use versions 0.6.5. 13 | 14 | ## Installation 15 | 16 | ### PIP installation (PyPI) 17 | 18 | See the [PyPI page](https://pypi.python.org/pypi?:action=display&name=fhem) for additional information about the package. 19 | 20 | ```bash 21 | pip install [-U] fhem 22 | ``` 23 | 24 | ### From source 25 | 26 | To build your own package, install `python-build` and run: 27 | 28 | ```bash 29 | cd fhem 30 | python -m build 31 | ``` 32 | 33 | This will create a `dist` directory with the package. Install with: 34 | 35 | ```bash 36 | pip install [-U] dist/fhem-.tar.gz 37 | ``` 38 | 39 | ## History 40 | 41 | * 0.7.0 (2023-08-17): Moved Travis CI -> Github actions, Python 2.x support removed, modernized python packaging, global states for SSL and authentication removed (support for multiple sessions). 42 | * 0.6.6 (2022-11-09): [unpublished] Fix for new option that produces fractional seconds in event data. 43 | * 0.6.5 (2020-03-24): New option `raw_value` for `FhemEventQueue`. Default `False` (old behavior), on `True`, the full, unparsed reading is returned, without looking for a unit. 44 | * 0.6.4 (2020-03-24): Bug fix for [#21](https://github.com/domschl/python-fhem/issues/21), Index out-of-range in event loop background thread for non-standard event formats. 45 | * 0.6.3 (2019-09-26): Bug fixes for socket connection exceptions [#18](https://github.com/domschl/python-fhem/issues/18) by [TK67](https://forum.fhem.de/index.php/topic,63816.msg968089.html#msg968089) [FHEM forum] and EventQueue crashes in datetime parsing [#19](https://github.com/domschl/python-fhem/issues/19) by party-pansen. Self-test now also covers FhemEventQueue() class. 46 | * 0.6.2 (2019-06-06): Bug fix, get_device_reading() could return additional unrelated readings. [#14](https://github.com/domschl/python-fhem/issues/14). Default blocking mode for telnet has been set to non-blocking. This can be changed with parameter `blocking=True` (telnet only). Use of HTTP(S) is recommended (superior 47 | performance and faster) 48 | * [build environment] (2019-07-22): Initial support for TravisCI automated self-tests. 49 | * 0.6.1 (2018-12-26): New API used telnet non-blocking on get which caused problems (d1nd141, [#12](https://github.com/domschl/python-fhem/issues/12)), fixed 50 | by using blocking telnet i/o. 51 | * 0.6.0 (2018-12-16): Enhanced and expanded get-API (Andre0512 [#10](https://github.com/domschl/python-fhem/pull/10)). See [online documentation](https://domschl.github.io/python-fhem/doc/_build/html/index.html), especially the new get() method for details on the new functionality. Proprietary logging functions marked deprecated. 52 | * 0.5.5 (2018-08-26): Documentation cleanup, automatic documentation with sphinx. 53 | * 0.5.3 (2018-08-26): Fix syntax in exception handler 54 | * 0.5.2 (2018-06-09): Fix for crash on invalid csrf-return 55 | * 0.5.1 (2018-01-29): Removed call to logging.basicConfig(), since it was unnecessary and causes breakage if other modules use this too. (heilerich [#8](https://github.com/domschl/python-fhem/issues/8)) 56 | * 0.5: API cleanup (breaking change!). Removed deprecated functions: sendCmd, sendRcvCmd, getDevState, getDevReading (replaced with PEP8 conform names, s.b.). Renamed parameter ssl= -> use_ssl= 57 | * 0.4.4: Merged python logger support (ChuckMoe, [#6](https://github.com/domschl/python-fhem/commit/25843d79986031cd654f87781f37d1266d0b116b)) 58 | * 0.4.3: Merged API extensions for getting time of last reading change (logi85, [#5](https://github.com/domschl/python-fhem/commit/11719b41b29a8c2c6192210e3848d9d8aedc5337)) 59 | * 0.4.2: deprecation error message fixed (Ivermue, [#4](https://github.com/domschl/python-fhem/commit/098cd774f2f714267645adbf2ee4556edf426229)) 60 | * 0.4.0: csrf token support (FHEM 5.8 requirement) 61 | 62 | ## Usage 63 | 64 | ### Set and get transactions 65 | 66 | Default telnet connection without password and without encryption: 67 | 68 | ```python 69 | import logging 70 | import fhem 71 | 72 | logging.basicConfig(level=logging.DEBUG) 73 | 74 | ## Connect via HTTP, port 8083: 75 | fh = fhem.Fhem("myserver.home.org", protocol="http", port=8083) 76 | # Send a command to FHEM (this automatically connects() in case of telnet) 77 | fh.send_cmd("set lamp on") 78 | # Get temperatur of LivingThermometer 79 | temp = fh.get_device_reading("LivingThermometer", "temperature") 80 | # return a dictionary with reading-value and time of last change: 81 | # {'Value': 25.6, 'Time': datetime.datetime(2019, 7, 27, 8, 19, 24)} 82 | print("The living-room temperature is {}, measured at {}".format(temp["Value"], temp["Time"])) 83 | # Output: The living-room temperature is 25.6, measured at 2019-07-27 08:19:24 84 | 85 | # Get a dict of kitchen lights with light on: 86 | lights = fh.get_states(group="Kitchen", state="on", device_type="light", value_only=True) 87 | # Get all data of specific tvs 88 | tvs = fh.get(device_type=["LGTV", "STV"]) 89 | # Get indoor thermometers with low battery 90 | low = fh.get_readings(name=".*Thermometer", not_room="outdoor", filter={"battery!": "ok"}) 91 | # Get temperature readings from all devices that have a temperature reading: 92 | all_temps = fh.get_readings('temperature') 93 | ``` 94 | 95 | HTTPS connection: 96 | 97 | ```python 98 | fh = fhem.Fhem('myserver.home.org', port=8085, protocol='https') 99 | ``` 100 | 101 | Self-signed certs are accepted (since no `cafile` option is given). 102 | 103 | To connect via https with SSL and basicAuth: 104 | 105 | ```python 106 | fh = fhem.Fhem('myserver.home.org', port=8086, protocol='https', 107 | cafile=mycertfile, username="myuser", password="secretsauce") 108 | ``` 109 | 110 | If no public certificate `cafile` is given, then self-signed certs are accepted. 111 | 112 | ### Connect via default protocol telnet, default port 7072: (deprecated) 113 | 114 | *Note*: Connection via telnet is not reliable for large requests, which 115 | includes everything that uses wildcard-funcionality. 116 | 117 | ```python 118 | fh = fhem.Fhem("myserver.home.org") 119 | ``` 120 | 121 | To connect via telnet with SSL and password: 122 | 123 | ```python 124 | fh = fhem.Fhem("myserver.home.org", port=7073, use_ssl=True, password='mysecret') 125 | fh.connect() 126 | if fh.connected(): 127 | # Do things 128 | ``` 129 | 130 | It is recommended to use HTTP(S) to connect to Fhem instead. 131 | 132 | ## Event queues (currently telnet only) 133 | 134 | The library can create an event queue that uses a background thread to receive 135 | and dispatch FHEM events: 136 | 137 | ```python 138 | import queue 139 | import fhem 140 | 141 | que = queue.Queue() 142 | fhemev = fhem.FhemEventQueue("myserver.home.org", que) 143 | 144 | while True: 145 | ev = que.get() 146 | # FHEM events are parsed into a Python dictionary: 147 | print(ev) 148 | que.task_done() 149 | ``` 150 | 151 | ## Selftest 152 | 153 | For a more complete example, you can look at [`selftest/selftest.py`](https://github.com/domschl/python-fhem/tree/master/selftest). This automatically installs an FHEM server, and runs a number of tests, 154 | creating devices and checking their state using the various different transports. 155 | 156 | # Documentation 157 | 158 | see: [python-fhem documentation](https://domschl.github.io/python-fhem/index.html) 159 | 160 | # References 161 | 162 | * [Fhem home automation project page](https://fhem.de/) 163 | * [Fhem server wiki](https://wiki.fhem.de/) 164 | -------------------------------------------------------------------------------- /fhem/fhem/__init__.py: -------------------------------------------------------------------------------- 1 | """API for FHEM homeautomation server, supporting telnet or HTTP/HTTPS connections with authentication and CSRF-token support.""" 2 | import datetime 3 | import json 4 | import logging 5 | import re 6 | import socket 7 | import errno 8 | import ssl 9 | import threading 10 | import time 11 | 12 | from urllib.parse import quote 13 | from urllib.parse import urlencode 14 | from urllib.request import urlopen 15 | from urllib.error import URLError 16 | from urllib.request import HTTPSHandler 17 | from urllib.request import HTTPPasswordMgrWithDefaultRealm 18 | from urllib.request import HTTPBasicAuthHandler 19 | from urllib.request import build_opener 20 | from urllib.request import install_opener 21 | 22 | # needs to be in sync with setup.py and documentation (conf.py, branch gh-pages) 23 | __version__ = "0.7.0" 24 | 25 | 26 | class Fhem: 27 | """Connects to FHEM via socket communication with optional SSL and password 28 | support""" 29 | 30 | def __init__( 31 | self, 32 | server, 33 | port=7072, 34 | use_ssl=False, 35 | protocol="telnet", 36 | username="", 37 | password="", 38 | csrf=True, 39 | cafile="", 40 | loglevel=1, 41 | ): 42 | """ 43 | Instantiate connector object. 44 | 45 | :param server: address of FHEM server 46 | :param port: telnet/http(s) port of server 47 | :param use_ssl: boolean for SSL (TLS) [https as protocol sets use_ssl=True] 48 | :param protocol: 'telnet', 'http' or 'https' 49 | :param username: username for http(s) basicAuth validation 50 | :param password: (global) telnet or http(s) password 51 | :param csrf: (http(s)) use csrf token (FHEM 5.8 and newer), default True 52 | :param cafile: path to public certificate of your root authority, if left empty, https protocol will ignore certificate checks. 53 | :param loglevel: deprecated, will be removed. Please use standard python logging API with logger 'Fhem'. 54 | """ 55 | self.log = logging.getLogger("Fhem") 56 | 57 | validprots = ["http", "https", "telnet"] 58 | self.server = server 59 | self.port = port 60 | self.ssl = use_ssl 61 | self.csrf = csrf 62 | self.csrftoken = "" 63 | self.username = username 64 | self.password = password 65 | self.loglevel = loglevel 66 | self.connection = False 67 | self.cafile = cafile 68 | self.nolog = False 69 | self.bsock = None 70 | self.sock = None 71 | self.https_handler = None 72 | self.opener = None 73 | 74 | # Set LogLevel 75 | # self.set_loglevel(loglevel) 76 | 77 | # Check if protocol is supported 78 | if protocol in validprots: 79 | self.protocol = protocol 80 | else: 81 | self.log.error("Invalid protocol: {}".format(protocol)) 82 | 83 | # Set authenticication values if# 84 | # the protocol is http(s) or use_ssl is True 85 | if protocol != "telnet": 86 | tmp_protocol = "http" 87 | if (protocol == "https") or (use_ssl is True): 88 | self.ssl = True 89 | tmp_protocol = "https" 90 | 91 | self.baseurlauth = "{}://{}:{}/".format(tmp_protocol, server, port) 92 | self.baseurltoken = "{}fhem".format(self.baseurlauth) 93 | self.baseurl = "{}fhem?XHR=1&cmd=".format(self.baseurlauth) 94 | 95 | self._install_opener() 96 | 97 | def connect(self): 98 | """create socket connection to server (telnet protocol only)""" 99 | if self.protocol == "telnet": 100 | # try: 101 | self.log.debug("Creating socket...") 102 | if self.ssl: 103 | self.bsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 104 | context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 105 | context.check_hostname = False 106 | context.verify_mode = ssl.CERT_NONE 107 | self.sock = context.wrap_socket(self.bsock) 108 | self.log.info( 109 | "Connecting to {}:{} with SSL (TLS)".format(self.server, self.port) 110 | ) 111 | else: 112 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 113 | self.log.info( 114 | "Connecting to {}:{} without SSL".format(self.server, self.port) 115 | ) 116 | # except Exception as e: 117 | # self.connection = False 118 | # self.log.error( 119 | # "Failed to create socket to {}:{}: {}".format(self.server, self.port, e) 120 | # ) 121 | # return 122 | 123 | self.log.debug("pre-connect (no try/except)") 124 | # try: 125 | # self.sock.timeout = 5.0 126 | self.sock.connect((self.server, self.port)) 127 | self.log.debug("post-connect") 128 | # except Exception as e: 129 | # self.connection = False 130 | # self.log.error( 131 | # "Failed to connect to {}:{}: {}".format(self.server, self.port, e) 132 | # ) 133 | # return 134 | self.connection = True 135 | self.log.info("Connected to {}:{}".format(self.server, self.port)) 136 | 137 | if self.password != "": 138 | # time.sleep(1.0) 139 | # self.send_cmd("\n") 140 | # prmpt = self._recv_nonblocking(4.0) 141 | prmpt = self.sock.recv(32000) 142 | self.log.debug("auth-prompt: {}".format(prmpt)) 143 | 144 | self.nolog = True 145 | self.send_cmd(self.password) 146 | self.nolog = False 147 | time.sleep(0.1) 148 | 149 | try: 150 | po1 = self.sock.recv(32000) 151 | self.log.debug("auth-repl1: {}".format(po1)) 152 | except socket.error: 153 | self.log.error("Failed to recv auth reply") 154 | self.connection = False 155 | return 156 | self.log.info("Auth password sent to {}".format(self.server)) 157 | else: # http(s) 158 | if self.csrf: 159 | dat = self.send("") 160 | if dat is not None: 161 | dat = dat.decode("UTF-8") 162 | stp = dat.find("csrf_") 163 | if stp != -1: 164 | token = dat[stp:] 165 | token = token[: token.find("'")] 166 | self.csrftoken = token 167 | self.connection = True 168 | else: 169 | self.log.error( 170 | "CSRF token requested for server that doesn't know CSRF" 171 | ) 172 | else: 173 | self.log.error("No valid answer on send when expecting csrf.") 174 | else: 175 | self.connection = True 176 | 177 | def connected(self): 178 | """Returns True if socket/http(s) session is connected to server.""" 179 | return self.connection 180 | 181 | def set_loglevel(self, level): 182 | """Set logging level. [Deprecated, will be removed, use python logging.setLevel] 183 | 184 | :param level: 0: critical, 1: errors, 2: info, 3: debug 185 | """ 186 | self.log.warning( 187 | "Deprecation: please set logging levels using python's standard logging for logger 'Fhem'" 188 | ) 189 | if level == 0: 190 | self.log.setLevel(logging.CRITICAL) 191 | elif level == 1: 192 | self.log.setLevel(logging.ERROR) 193 | elif level == 2: 194 | self.log.setLevel(logging.INFO) 195 | elif level == 3: 196 | self.log.setLevel(logging.DEBUG) 197 | 198 | def close(self): 199 | """Closes socket connection. (telnet only)""" 200 | if self.protocol == "telnet": 201 | if self.connected(): 202 | time.sleep(0.2) 203 | self.sock.close() 204 | self.connection = False 205 | self.log.info("Disconnected from fhem-server") 206 | else: 207 | self.log.error("Cannot disconnect, not connected") 208 | else: 209 | self.connection = False 210 | 211 | def _install_opener(self): 212 | self.opener = None 213 | self.auth_handler = None 214 | self.password_mgr = None 215 | self.context = None 216 | if self.username != "": 217 | self.password_mgr = HTTPPasswordMgrWithDefaultRealm() 218 | self.password_mgr.add_password( 219 | None, self.baseurlauth, self.username, self.password 220 | ) 221 | self.auth_handler = HTTPBasicAuthHandler(self.password_mgr) 222 | if self.ssl is True: 223 | if self.cafile == "": 224 | self.context = ssl.create_default_context() 225 | self.context.check_hostname = False 226 | self.context.verify_mode = ssl.CERT_NONE 227 | else: 228 | self.context = ssl.create_default_context() 229 | self.context.load_verify_locations(cafile=self.cafile) 230 | self.context.verify_mode = ssl.CERT_REQUIRED 231 | self.https_handler = HTTPSHandler(context=self.context) 232 | if self.username != "": 233 | self.opener = build_opener(self.https_handler, self.auth_handler) 234 | else: 235 | self.opener = build_opener(self.https_handler) 236 | else: 237 | if self.username != "": 238 | self.opener = build_opener(self.auth_handler) 239 | # if self.opener is not None: 240 | # self.log.debug("Setting up opener on: {}".format(self.baseurlauth)) 241 | # install_opener(self.opener) 242 | 243 | def send(self, buf, timeout=10): 244 | """Sends a buffer to server 245 | 246 | :param buf: binary buffer""" 247 | if len(buf) > 0: 248 | if not self.connected(): 249 | self.log.debug("Not connected, trying to connect...") 250 | self.connect() 251 | if self.protocol == "telnet": 252 | if self.connected(): 253 | self.log.debug("Connected, sending...") 254 | try: 255 | self.sock.sendall(buf) 256 | self.log.info("Sent msg, len={}".format(len(buf))) 257 | return None 258 | except OSError as err: 259 | self.log.error( 260 | "Failed to send msg, len={}. Exception raised: {}".format( 261 | len(buf), err 262 | ) 263 | ) 264 | self.connection = None 265 | return None 266 | else: 267 | self.log.error( 268 | "Failed to send msg, len={}. Not connected.".format(len(buf)) 269 | ) 270 | return None 271 | else: # HTTP(S) 272 | paramdata = None 273 | # if self.opener is not None: 274 | # install_opener(self.opener) 275 | 276 | if self.csrf and len(buf) > 0: 277 | if len(self.csrftoken) == 0: 278 | self.log.error("CSRF token not available!") 279 | self.connection = False 280 | else: 281 | datas = {"fwcsrf": self.csrftoken} 282 | paramdata = urlencode(datas).encode("UTF-8") 283 | 284 | # try: 285 | if len(buf) > 0: 286 | self.log.debug("Cmd: {}".format(buf)) 287 | cmd = quote(buf) 288 | self.log.debug("Cmd-enc: {}".format(cmd)) 289 | else: 290 | cmd = "" 291 | if len(cmd) > 0: 292 | ccmd = self.baseurl + cmd 293 | else: 294 | ccmd = self.baseurltoken 295 | 296 | self.log.info("Request: {}".format(ccmd)) 297 | if ccmd.lower().startswith("http"): 298 | if self.opener is not None: 299 | ans = self.opener.open(ccmd, paramdata, timeout=timeout) 300 | else: 301 | if self.context is None: 302 | ans = urlopen(ccmd, paramdata, timeout=timeout) 303 | else: 304 | ans = urlopen( 305 | ccmd, paramdata, timeout=timeout, context=self.context 306 | ) 307 | else: 308 | self.log.error( 309 | "Invalid URL {}, Failed to send msg, len={}, {}".format( 310 | ccmd, len(buf), err 311 | ) 312 | ) 313 | return None 314 | data = ans.read() 315 | return data 316 | # except URLError as err: 317 | # self.connection = False 318 | # self.log.error("Failed to send msg, len={}, {}".format(len(buf), err)) 319 | # return None 320 | # except socket.timeout as err: 321 | # # Python 2.7 fix 322 | # self.log.error("Failed to send msg, len={}, {}".format(len(buf), err)) 323 | # return None 324 | 325 | def send_cmd(self, msg, timeout=10.0): 326 | """Sends a command to server. 327 | 328 | :param msg: string with FHEM command, e.g. 'set lamp on' 329 | :param timeout: timeout on send (sec). 330 | """ 331 | if not self.connected(): 332 | self.connect() 333 | if not self.nolog: 334 | self.log.debug("Sending: {}".format(msg)) 335 | if self.protocol == "telnet": 336 | if self.connection: 337 | msg = "{}\n".format(msg) 338 | cmd = msg.encode("utf-8") 339 | return self.send(cmd) 340 | else: 341 | self.log.error( 342 | "Failed to send msg, len={}. Not connected.".format(len(msg)) 343 | ) 344 | return None 345 | else: 346 | return self.send(msg, timeout=timeout) 347 | 348 | def _recv_nonblocking(self, timeout=0.1): 349 | if not self.connected(): 350 | self.connect() 351 | data = b"" 352 | if self.connection: 353 | self.sock.setblocking(False) 354 | data = b"" 355 | try: 356 | data = self.sock.recv(32000) 357 | except socket.error as err: 358 | # Resource temporarily unavailable, operation did not complete are expected 359 | if err.errno != errno.EAGAIN and err.errno != errno.ENOENT: 360 | self.log.debug( 361 | "Exception in non-blocking (1). Error: {}".format(err) 362 | ) 363 | time.sleep(timeout) 364 | 365 | wok = 1 366 | while len(data) > 0 and wok > 0: 367 | time.sleep(timeout) 368 | datai = b"" 369 | try: 370 | datai = self.sock.recv(32000) 371 | if len(datai) == 0: 372 | wok = 0 373 | else: 374 | data += datai 375 | except socket.error as err: 376 | # Resource temporarily unavailable, operation did not complete are expected 377 | if err.errno != errno.EAGAIN and err.errno != errno.ENOENT: 378 | self.log.debug( 379 | "Exception in non-blocking (2). Error: {}".format(err) 380 | ) 381 | wok = 0 382 | self.sock.setblocking(True) 383 | return data 384 | 385 | def send_recv_cmd(self, msg, timeout=0.1, blocking=False): 386 | """ 387 | Sends a command to the server and waits for an immediate reply. 388 | 389 | :param msg: FHEM command (e.g. 'set lamp on') 390 | :param timeout: waiting time for reply 391 | :param blocking: (telnet only) on True: use blocking socket communication (bool) 392 | """ 393 | data = b"" 394 | if not self.connected(): 395 | self.connect() 396 | if self.protocol == "telnet": 397 | if self.connection: 398 | self.send_cmd(msg) 399 | time.sleep(timeout) 400 | data = [] 401 | if blocking is True: 402 | try: 403 | # This causes failures if reply is larger! 404 | data = self.sock.recv(64000) 405 | except socket.error: 406 | self.log.error("Failed to recv msg. {}".format(data)) 407 | return {} 408 | else: 409 | data = self._recv_nonblocking(timeout) 410 | 411 | self.sock.setblocking(True) 412 | else: 413 | self.log.error( 414 | "Failed to send msg, len={}. Not connected.".format(len(msg)) 415 | ) 416 | else: 417 | data = self.send_cmd(msg) 418 | if data is None: 419 | return None 420 | 421 | if len(data) == 0: 422 | return {} 423 | 424 | try: 425 | sdata = data.decode("utf-8") 426 | jdata = json.loads(sdata) 427 | except Exception as err: 428 | self.log.error( 429 | "Failed to decode json, exception raised. {} {}".format(data, err) 430 | ) 431 | return {} 432 | if len(jdata["Results"]) == 0: 433 | self.log.error("Query had no result.") 434 | return {} 435 | else: 436 | self.log.info("JSON answer received.") 437 | return jdata 438 | 439 | def get_dev_state(self, dev, timeout=0.1): 440 | self.log.warning( 441 | "Deprecation: use get_device('device') instead of get_dev_state" 442 | ) 443 | return self.get_device(dev, timeout=timeout, raw_result=True) 444 | 445 | def get_dev_reading(self, dev, reading, timeout=0.1): 446 | self.log.warning( 447 | "Deprecation: use get_device_reading('device', 'reading') instead of get_dev_reading" 448 | ) 449 | return self.get_device_reading(dev, reading, value_only=True, timeout=timeout) 450 | 451 | def getDevReadings(self, dev, reading, timeout=0.1): 452 | self.log.warning( 453 | "Deprecation: use get_device_reading('device', ['reading']) instead of getDevReadings" 454 | ) 455 | return self.get_device_reading( 456 | dev, timeout=timeout, value_only=True, raw_result=True 457 | ) 458 | 459 | def get_dev_readings(self, dev, readings, timeout=0.1): 460 | self.log.warning( 461 | "Deprecation: use get_device_reading('device', ['reading']) instead of get_dev_readings" 462 | ) 463 | return self.get_device_reading( 464 | dev, readings, timeout=timeout, value_only=True, raw_result=True 465 | ) 466 | 467 | def get_dev_reading_time(self, dev, reading, timeout=0.1): 468 | self.log.warning( 469 | "Deprecation: use get_device_reading('device', 'reading', time_only=True) instead of get_dev_reading_time" 470 | ) 471 | return self.get_device_reading(dev, reading, timeout=timeout, time_only=True) 472 | 473 | def get_dev_readings_time(self, dev, readings, timeout=0.1): 474 | self.log.warning( 475 | "Deprecation: use get_device_reading('device', ['reading'], time_only=True) instead of get_dev_reading_time" 476 | ) 477 | return self.get_device_reading(dev, readings, timeout=timeout, time_only=True) 478 | 479 | def getFhemState(self, timeout=0.1): 480 | self.log.warning( 481 | "Deprecation: use get() without parameters instead of getFhemState" 482 | ) 483 | return self.get(timeout=timeout, raw_result=True) 484 | 485 | def get_fhem_state(self, timeout=0.1): 486 | self.log.warning( 487 | "Deprecation: use get() without parameters instead of get_fhem_state" 488 | ) 489 | return self.get(timeout=timeout, raw_result=True) 490 | 491 | @staticmethod 492 | def _sand_down(value): 493 | return value if len(value.values()) - 1 else list(value.values())[0] 494 | 495 | @staticmethod 496 | def _append_filter(name, value, compare, string, filter_list): 497 | value_list = [value] if isinstance(value, str) else value 498 | values = ",".join(value_list) 499 | filter_list.append(string.format(name, compare, values)) 500 | 501 | def _response_filter(self, response, arg, value, value_only=None, time_only=None): 502 | if len(arg) > 2: 503 | self.log.error("Too many positional arguments") 504 | return {} 505 | result = {} 506 | for r in ( 507 | response if "totalResultsReturned" not in response else response["Results"] 508 | ): 509 | arg = [arg[0]] if len(arg) and isinstance(arg[0], str) else arg 510 | if value_only: 511 | result[r["Name"]] = { 512 | k: v["Value"] 513 | for k, v in r[value].items() 514 | if "Value" in v and (not len(arg) or (len(arg) and k == arg[0])) 515 | } # k in arg[0]))} fixes #14 516 | elif time_only: 517 | result[r["Name"]] = { 518 | k: v["Time"] 519 | for k, v in r[value].items() 520 | if "Time" in v and (not len(arg) or (len(arg) and k == arg[0])) 521 | } # k in arg[0]))} 522 | else: 523 | result[r["Name"]] = { 524 | k: v 525 | for k, v in r[value].items() 526 | if (not len(arg) or (len(arg) and k == arg[0])) 527 | } # k in arg[0]))} 528 | if not result[r["Name"]]: 529 | result.pop(r["Name"], None) 530 | elif len(result[r["Name"]].values()) == 1: 531 | result[r["Name"]] = list(result[r["Name"]].values())[0] 532 | return result 533 | 534 | def _parse_filters(self, name, value, not_value, filter_list, case_sensitive): 535 | compare = "=" if case_sensitive else "~" 536 | if value: 537 | self._append_filter(name, value, compare, "{}{}{}", filter_list) 538 | elif not_value: 539 | self._append_filter(name, not_value, compare, "{}!{}{}", filter_list) 540 | 541 | def _convert_data(self, response, k, v): 542 | try: 543 | test_type = unicode 544 | except NameError: 545 | test_type = str 546 | if isinstance(v, test_type): 547 | if re.findall("^[0-9]+$", v): 548 | response[k] = int(v) 549 | elif re.findall(r"^[0-9]+\.[0-9]+$", v): 550 | response[k] = float(v) 551 | elif re.findall( 552 | "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", v 553 | ): 554 | response[k] = datetime.datetime.strptime(v, "%Y-%m-%d %H:%M:%S") 555 | if isinstance(v, dict): 556 | self._parse_data_types(response[k]) 557 | if isinstance(v, list): 558 | self._parse_data_types(response[k]) 559 | 560 | def _parse_data_types(self, response): 561 | if isinstance(response, dict): 562 | for k, v in response.items(): 563 | self._convert_data(response, k, v) 564 | if isinstance(response, list): 565 | for i, v in enumerate(response): 566 | self._convert_data(response, i, v) 567 | 568 | def get( 569 | self, 570 | name=None, 571 | state=None, 572 | group=None, 573 | room=None, 574 | device_type=None, 575 | not_name=None, 576 | not_state=None, 577 | not_group=None, 578 | not_room=None, 579 | not_device_type=None, 580 | case_sensitive=None, 581 | filters=None, 582 | timeout=0.1, 583 | blocking=False, 584 | raw_result=None, 585 | ): 586 | """ 587 | Get FHEM data of devices, can filter by parameters or custom defined filters. 588 | All filters use regular expressions (except full match), so don't forget escaping. 589 | Filters can be used by all other get functions. 590 | For more information about filters, see https://FHEM.de/commandref.html#devspec 591 | 592 | :param name: str or list, device name in FHEM 593 | :param state: str or list, state in FHEM 594 | :param group: str or list, filter FHEM groups 595 | :param room: str or list, filter FHEM room 596 | :param device_type: str or list, FHEM device type 597 | :param not_name: not name 598 | :param not_state: not state 599 | :param not_group: not group 600 | :param not_room: not room 601 | :param not_device_type: not device_type 602 | :param case_sensitive: bool, use case_sensitivity for all filter functions 603 | :param filters: dict of filters - key=attribute/internal/reading, value=regex for value, e.g. {"battery": "ok"} 604 | :param raw_result: On True: Don't convert to python types and send full FHEM response 605 | :param timeout: timeout for reply 606 | :param blocking: telnet socket mode, default blocking=False 607 | :return: dict of FHEM devices 608 | """ 609 | if not self.connected(): 610 | self.connect() 611 | if self.connected(): 612 | filter_list = [] 613 | self._parse_filters("NAME", name, not_name, filter_list, case_sensitive) 614 | self._parse_filters("STATE", state, not_state, filter_list, case_sensitive) 615 | self._parse_filters("group", group, not_group, filter_list, case_sensitive) 616 | self._parse_filters("room", room, not_room, filter_list, case_sensitive) 617 | self._parse_filters( 618 | "TYPE", device_type, not_device_type, filter_list, case_sensitive 619 | ) 620 | if filters: 621 | for key, value in filters.items(): 622 | filter_list.append( 623 | "{}{}{}".format(key, "=" if case_sensitive else "~", value) 624 | ) 625 | cmd = "jsonlist2 {}".format(":FILTER=".join(filter_list)) 626 | if self.protocol == "telnet": 627 | result = self.send_recv_cmd(cmd, blocking=blocking, timeout=timeout) 628 | else: 629 | result = self.send_recv_cmd(cmd, blocking=False, timeout=timeout) 630 | if not result or raw_result: 631 | return result 632 | result = result["Results"] 633 | self._parse_data_types(result) 634 | return result 635 | else: 636 | self.log.error("Failed to get fhem state. Not connected.") 637 | return {} 638 | 639 | def get_states(self, **kwargs): 640 | """ 641 | Return only device states, can use filters from get(). 642 | 643 | :param kwargs: Use keyword arguments from :py:meth:`Fhem.get` function 644 | :return: dict of FHEM devices with states 645 | """ 646 | response = self.get(**kwargs) 647 | if not response: 648 | return response 649 | return { 650 | r["Name"]: r["Readings"]["state"]["Value"] 651 | for r in response 652 | if "state" in r["Readings"] 653 | } 654 | 655 | def get_readings(self, *arg, **kwargs): 656 | """ 657 | Return readings of a device, can use filters from get(). 658 | 659 | :param arg: str, Get only a specified reading, return all readings of device when parameter not given 660 | :param value_only: return only value of reading, not timestamp 661 | :param time_only: return only timestamp of reading 662 | :param kwargs: use keyword arguments from :py:meth:`Fhem.get` function 663 | :return: dict of FHEM devices with readings 664 | """ 665 | value_only = kwargs["value_only"] if "value_only" in kwargs else None 666 | time_only = kwargs["time_only"] if "time_only" in kwargs else None 667 | kwargs.pop("value_only", None) 668 | kwargs.pop("time_only", None) 669 | response = self.get(**kwargs) 670 | return self._response_filter( 671 | response, arg, "Readings", value_only=value_only, time_only=time_only 672 | ) 673 | 674 | def get_attributes(self, *arg, **kwargs): 675 | """ 676 | Return attributes of a device, can use filters from get() 677 | 678 | :param arg: str, Get only specified attribute, return all attributes of device when parameter not given 679 | :param kwargs: use keyword arguments from :py:meth:`Fhem.get` function 680 | :return: dict of FHEM devices with attributes 681 | """ 682 | response = self.get(**kwargs) 683 | return self._response_filter(response, arg, "Attributes") 684 | 685 | def get_internals(self, *arg, **kwargs): 686 | """ 687 | Return internals of a device, can use filters from get() 688 | 689 | :param arg: str, Get only specified internal, return all internals of device when parameter not given 690 | :param kwargs: use keyword arguments from :py:meth:`Fhem.get` function 691 | :return: dict of FHEM devices with internals 692 | """ 693 | response = self.get(**kwargs) 694 | return self._response_filter(response, arg, "Internals") 695 | 696 | def get_device(self, device, **kwargs): 697 | """ 698 | Get all data from a device 699 | 700 | :param device: str or list, 701 | :param kwargs: use keyword arguments from :py:meth:`Fhem.get` function 702 | :return: dict with data of specific FHEM device 703 | """ 704 | return self.get(name=device, **kwargs) 705 | 706 | def get_device_state(self, device, **kwargs): 707 | """ 708 | Get state of one device 709 | 710 | :param device: str or list, 711 | :param kwargs: use keyword arguments from :py:meth:`Fhem.get` and :py:meth:`Fhem.get_states` functions 712 | :return: str, int, float when only specific value requested else dict 713 | """ 714 | result = self.get_states(name=device, **kwargs) 715 | return self._sand_down(result) 716 | 717 | def get_device_reading(self, device, *arg, **kwargs): 718 | """ 719 | Get reading(s) of one device 720 | 721 | :param device: str or list, 722 | :param arg: str for one reading, list for special readings, empty for all readings 723 | :param kwargs: use keyword arguments from :py:meth:`Fhem.get` and :py:meth:`Fhem.get_readings` functions 724 | :return: str, int, float when only specific value requested else dict 725 | """ 726 | result = self.get_readings(*arg, name=device, **kwargs) 727 | return self._sand_down(result) 728 | 729 | def get_device_attribute(self, device, *arg, **kwargs): 730 | """ 731 | Get attribute(s) of one device 732 | 733 | :param device: str or list, 734 | :param arg: str for one attribute, list for special attributes, empty for all attributes 735 | :param kwargs: use keyword arguments from :py:meth:`Fhem.get` function 736 | :return: str, int, float when only specific value requested else dict 737 | """ 738 | result = self.get_attributes(*arg, name=device, **kwargs) 739 | return self._sand_down(result) 740 | 741 | def get_device_internal(self, device, *arg, **kwargs): 742 | """ 743 | Get internal(s) of one device 744 | 745 | :param device: str or list, 746 | :param arg: str for one internal value, list for special internal values, empty for all internal values 747 | :param kwargs: use keyword arguments from :py:meth:`Fhem.get` function 748 | :return: str, int, float when only specific value requested else dict 749 | """ 750 | result = self.get_internals(*arg, name=device, **kwargs) 751 | return self._sand_down(result) 752 | 753 | 754 | class FhemEventQueue: 755 | """Creates a thread that listens to FHEM events and dispatches them to 756 | a Python queue.""" 757 | 758 | def __init__( 759 | self, 760 | server, 761 | que, 762 | port=7072, 763 | protocol="telnet", 764 | use_ssl=False, 765 | username="", 766 | password="", 767 | csrf=True, 768 | cafile="", 769 | filterlist=None, 770 | timeout=0.1, 771 | eventtimeout=60, 772 | serverregex=None, 773 | loglevel=1, 774 | raw_value=False, 775 | ): 776 | """ 777 | Construct an event queue object, FHEM events will be queued into the queue given at initialization. 778 | 779 | :param server: FHEM server address 780 | :param que: Python Queue object, receives FHEM events as dictionaries 781 | :param port: FHEM telnet port 782 | :param protocol: 'telnet', 'http' or 'https'. NOTE: for FhemEventQueue, currently only 'telnet' is supported! 783 | :param use_ssl: boolean for SSL (TLS) 784 | :param username: http(s) basicAuth username 785 | :param password: (global) telnet password or http(s) basicAuth password 786 | :param csrf: (http(s)) use csrf token (FHEM 5.8 and newer), default True (currently not used, since telnet-only) 787 | :param cafile: path to public certificate of your root authority, if left empty, https protocol will ignore certificate checks. 788 | :param filterlist: array of filter dictionaires [{"dev"="lamp1"}, {"dev"="livingtemp", "reading"="temperature"}]. A filter dictionary can contain devstate (type of FHEM device), dev (FHEM device name) and/or reading conditions. The filterlist works on client side. 789 | :param timeout: internal timeout for socket receive (should be short) 790 | :param eventtimeout: larger timeout for server keep-alive messages 791 | :param serverregex: FHEM regex to restrict event messages on server side. 792 | :param loglevel: deprecated, will be removed. Use standard python logging function for logger 'FhemEventQueue', old: 0: no log, 1: errors, 2: info, 3: debug 793 | :param raw_value: default False. On True, the value of a reading is not parsed for units, and returned as-is. 794 | """ 795 | # self.set_loglevel(loglevel) 796 | self.log = logging.getLogger("FhemEventQueue") 797 | self.informcmd = "inform timer" 798 | self.timeout = timeout 799 | if serverregex is not None: 800 | self.informcmd += " " + serverregex 801 | if protocol != "telnet": 802 | self.log.error("ONLY TELNET is currently supported for EventQueue") 803 | return 804 | self.fhem = Fhem( 805 | server=server, 806 | port=port, 807 | use_ssl=use_ssl, 808 | username=username, 809 | password=password, 810 | cafile=cafile, 811 | loglevel=loglevel, 812 | ) 813 | self.fhem.connect() 814 | time.sleep(timeout) 815 | self.EventThread = threading.Thread( 816 | target=self._event_worker_thread, 817 | args=(que, filterlist, timeout, eventtimeout, raw_value), 818 | ) 819 | self.EventThread.setDaemon(True) 820 | self.EventThread.start() 821 | 822 | def set_loglevel(self, level): 823 | """ 824 | Set logging level, [Deprecated, will be removed, use python's logging.setLevel] 825 | 826 | :param level: 0: critical, 1: errors, 2: info, 3: debug 827 | """ 828 | self.log.warning( 829 | "Deprecation: please set logging levels using python's standard logging for logger 'FhemEventQueue'" 830 | ) 831 | if level == 0: 832 | self.log.setLevel(logging.CRITICAL) 833 | elif level == 1: 834 | self.log.setLevel(logging.ERROR) 835 | elif level == 2: 836 | self.log.setLevel(logging.INFO) 837 | elif level == 3: 838 | self.log.setLevel(logging.DEBUG) 839 | 840 | def _event_worker_thread( 841 | self, que, filterlist, timeout=0.1, eventtimeout=120, raw_value=False 842 | ): 843 | self.log.debug("FhemEventQueue worker thread starting...") 844 | if self.fhem.connected() is not True: 845 | self.log.warning("EventQueueThread: Fhem is not connected!") 846 | time.sleep(timeout) 847 | self.fhem.send_cmd(self.informcmd) 848 | data = "" 849 | first = True 850 | lastreceive = time.time() 851 | self.eventThreadActive = True 852 | while self.eventThreadActive is True: 853 | while self.fhem.connected() is not True: 854 | self.fhem.connect() 855 | if self.fhem.connected(): 856 | time.sleep(timeout) 857 | lastreceive = time.time() 858 | self.fhem.send_cmd(self.informcmd) 859 | else: 860 | self.log.warning( 861 | "Fhem is not connected in EventQueue thread, retrying!" 862 | ) 863 | time.sleep(5.0) 864 | if first is True: 865 | first = False 866 | self.log.debug("FhemEventQueue worker thread active.") 867 | time.sleep(timeout) 868 | if time.time() - lastreceive > eventtimeout: 869 | self.log.debug("Event-timeout, refreshing INFORM TIMER") 870 | self.fhem.send_cmd(self.informcmd) 871 | if self.fhem.connected() is True: 872 | lastreceive = time.time() 873 | 874 | if self.fhem.connected() is True: 875 | data = self.fhem._recv_nonblocking(timeout) 876 | lines = data.decode("utf-8").split("\n") 877 | for l in lines: 878 | if len(l) > 0: 879 | lastreceive = time.time() 880 | li = l.split(" ") 881 | if len(li) > 4: 882 | dd = li[0].split("-") 883 | tt = li[1].split(":") 884 | try: 885 | if "." in tt[2]: 886 | secs = float(tt[2]) 887 | tt[2] = str(int(secs)) 888 | tt.append(str(int((secs - int(secs)) * 1000000))) 889 | except Exception as e: 890 | self.log.warning( 891 | "EventQueue: us-Bugfix failed with {}".format(e) 892 | ) 893 | try: 894 | if len(tt) == 3: 895 | dt = datetime.datetime( 896 | int(dd[0]), 897 | int(dd[1]), 898 | int(dd[2]), 899 | int(tt[0]), 900 | int(tt[1]), 901 | int(tt[2]), 902 | ) 903 | else: 904 | dt = datetime.datetime( 905 | int(dd[0]), 906 | int(dd[1]), 907 | int(dd[2]), 908 | int(tt[0]), 909 | int(tt[1]), 910 | int(tt[2]), 911 | int(tt[3]), 912 | ) 913 | except Exception as e: 914 | self.log.debug( 915 | "EventQueue: invalid date format in date={} time={}, event {} ignored: {}".format( 916 | li[0], li[1], l, e 917 | ) 918 | ) 919 | continue 920 | devtype = li[2] 921 | dev = li[3] 922 | val = "" 923 | for i in range(4, len(li)): 924 | val += li[i] 925 | if i < len(li) - 1: 926 | val += " " 927 | full_val = val 928 | vl = val.split(" ") 929 | val = "" 930 | unit = "" 931 | if len(vl) > 0: 932 | if len(vl[0]) > 0 and vl[0][-1] == ":": 933 | read = vl[0][:-1] 934 | if len(vl) > 1: 935 | val = vl[1] 936 | if len(vl) > 2: 937 | unit = vl[2] 938 | else: 939 | read = "STATE" 940 | if len(vl) > 0: 941 | val = vl[0] 942 | if len(vl) > 1: 943 | unit = vl[1] 944 | 945 | adQ = True 946 | if filterlist is not None: 947 | adQ = False 948 | for f in filterlist: 949 | adQt = True 950 | for c in f: 951 | if c == "devtype": 952 | if devtype != f[c]: 953 | adQt = False 954 | if c == "device": 955 | if dev != f[c]: 956 | adQt = False 957 | if c == "reading": 958 | if read != f[c]: 959 | adQt = False 960 | if adQt: 961 | adQ = True 962 | if adQ: 963 | if raw_value is False: 964 | ev = { 965 | "timestamp": dt, 966 | "devicetype": devtype, 967 | "device": dev, 968 | "reading": read, 969 | "value": val, 970 | "unit": unit, 971 | } 972 | else: 973 | ev = { 974 | "timestamp": dt, 975 | "devicetype": devtype, 976 | "device": dev, 977 | "reading": read, 978 | "value": full_val, 979 | "unit": None, 980 | } 981 | que.put(ev) 982 | # self.log.debug("Event queued for {}".format(ev['device'])) 983 | time.sleep(timeout) 984 | self.fhem.close() 985 | self.log.debug("FhemEventQueue worker thread terminated.") 986 | return 987 | 988 | def close(self): 989 | """Stop event thread and close socket.""" 990 | self.eventThreadActive = False 991 | time.sleep(0.5 + self.timeout) 992 | -------------------------------------------------------------------------------- /fhem/setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="fhem", 8 | version="0.7.0", 9 | author="Dominik Schloesser", 10 | author_email="dsc@dosc.net", 11 | description="Python API for FHEM home automation server", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="http://github.com/domschl/python-fhem", 15 | project_urls={"Bug Tracker": "https://github.com/domschl/python-fhem/issues"}, 16 | classifiers=[ 17 | "Programming Language :: Python :: 3", 18 | "Development Status :: 5 - Production/Stable", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | ], 22 | package_dir={"": "."}, 23 | packages=setuptools.find_packages(where="."), 24 | python_requires=">=3.6", 25 | ) 26 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ ! -f ~/.pypirc ]; then 4 | echo "Please configure .pypirc for pypi access first" 5 | exit -2 6 | fi 7 | if [[ ! -d fhem/dist ]]; then 8 | mkdir fhem/dist 9 | fi 10 | cd fhem 11 | cp ../README.md . 12 | rm dist/* 13 | export PIP_USER= 14 | python -m build 15 | if [[ $1 == "upload" ]]; then 16 | twine upload dist/* 17 | fi 18 | -------------------------------------------------------------------------------- /selftest/README.md: -------------------------------------------------------------------------------- 1 | ## Automatic FHEM installation and python-fhem API self-test for CI. 2 | 3 | The selftest can be used manually, but are targeted for use with github action CI. 4 | 5 | The scripts automatically download the latest FHEM release, install, configure and run it and then use the Python API to 6 | perform self-tests. 7 | 8 | Tests performed: 9 | * FHEM connections via sockets, secure sockets, HTTP and HTTPS with password. 10 | * Automatic creation of devices on Fhem (using all connection variants) 11 | * Aquiring readings from Fhem using all different connection types and python versions 12 | * Automatic testing of the FhemEventQueue 13 | 14 | **WARNING**: Be careful when using this script, e.g. the install-class ***completely erases*** the existing FHEM installation 15 | within the selftest tree (and all configuration files) to allow clean tests. 16 | 17 | ### Environment notes 18 | 19 | Fhem requires the perl module IO::Socket::SSL for secure socket and HTTPS protocotls. 20 | 21 | It needs to be installed with either: 22 | 23 | * `cpan -i IO::Socket::SSL` 24 | * or `apt-get install libio-socket-ssl-perl` 25 | * or `pacman -S perl-io-socket-ssl` 26 | 27 | If selftests fails on the first SSL connection, it is usually a sign that the fhem-perl requirements for SSL are not installed. 28 | 29 | ## Manual test run 30 | 31 | - Make sure `python-fhem` is installed (e.g. `pip install fhem`) 32 | - Make sure that Perl's `socket::ssl` is installed (s.a.) 33 | - Run `python selftest.py` 34 | 35 | You can run the selftest with option `--reuse` to reuse an existing and running FHEM installation. The selftest requires a number of 36 | ports and passwords to be configured. Check out `fhem-config-addon.cfg` for details. 37 | 38 | ## CI notes 39 | 40 | The selftest can be used for CI testing. It is currently used with github actions. Be aware that port `8084` is not available on github actions. 41 | See `.github/workflows/python-fhem-test.yaml` for details. 42 | 43 | ## History 44 | 45 | - 2023-08-17: Updated for FHEM 6.0, python 2.x support removed. Prepared move from Travis CI to github actions. 46 | -------------------------------------------------------------------------------- /selftest/fhem-config-addon.cfg: -------------------------------------------------------------------------------- 1 | 2 | ################################################################################## 3 | ### additional configuration for self-test fhem ### 4 | ### telnet: 7072, 7073 (secured) 5 | ### https: 8084, 8085 (with password. user: test, pwd: secretsauce) 6 | 7 | attr global modpath . 8 | 9 | define telnetPort telnet 7072 global 10 | 11 | define telnetPort2 telnet 7073 global 12 | attr telnetPort2 SSL 1 13 | attr telnetPort2 sslVersion TLSv12:!SSLv3 14 | define allowTelPort2 allowed 15 | attr allowTelPort2 password secretsauce 16 | attr allowTelPort2 validFor telnetPort2 17 | 18 | # HTTPS requires IO::Socket::SSL, to be installed with cpan -i IO::Socket::SSL 19 | # or apt-get install libio-socket-ssl-perl 20 | # or pacman -S perl-io-socket-ssl 21 | define WEBS FHEMWEB 8086 global 22 | attr WEBS HTTPS 1 23 | attr WEBS sslVersion TLSv12:!SSLv3 24 | attr WEBS longpoll 1 25 | 26 | define WebPwd FHEMWEB 8085 global 27 | attr WebPwd HTTPS 1 28 | attr WebPwd sslVersion TLSv12:!SSLv3 29 | attr WebPwd longpoll 1 30 | define allowWebPwd allowed 31 | # test:secretsauce NOTE: do not reuse those values for actual installations! 32 | attr allowWebPwd basicAuth dGVzdDpzZWNyZXRzYXVjZQ== 33 | attr allowWebPwd validFor WebPwd 34 | 35 | define MultiWebPwd FHEMWEB 8087 global 36 | attr MultiWebPwd HTTPS 1 37 | attr MultiWebPwd sslVersion TLSv12:!SSLv3 38 | attr MultiWebPwd longpoll 1 39 | define allowMultiWebPwd allowed 40 | # echo -n "toast:salad" | base64 41 | # dG9hc3Q6c2FsYWQ= 42 | attr allowMultiWebPwd basicAuth dG9hc3Q6c2FsYWQ= 43 | attr allowMultiWebPwd validFor WebPwd 44 | 45 | ################################################################################# 46 | -------------------------------------------------------------------------------- /selftest/selftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import shutil 4 | import logging 5 | import time 6 | import queue 7 | import subprocess 8 | import argparse 9 | 10 | from urllib.parse import quote 11 | from urllib.parse import urlencode 12 | from urllib.request import urlopen 13 | from urllib.error import URLError 14 | from urllib.request import HTTPSHandler 15 | from urllib.request import HTTPPasswordMgrWithDefaultRealm 16 | from urllib.request import HTTPBasicAuthHandler 17 | from urllib.request import build_opener 18 | from urllib.request import install_opener 19 | import tarfile 20 | 21 | import fhem 22 | 23 | """ 24 | FhemSelfTester implements necessary functionality for automatic testing of FHEM 25 | with the Python API. 26 | This module can automatically download, install and run a clean FHEM server. 27 | """ 28 | 29 | 30 | class FhemSelfTester: 31 | def __init__(self): 32 | self.log = logging.getLogger("SelfTester") 33 | 34 | def download(self, filename, urlpath): 35 | """ 36 | Download an FHEM tar.gz file, if not yet available locally. 37 | """ 38 | if os.path.exists(filename): 39 | return True 40 | try: 41 | dat = urlopen(urlpath).read() 42 | except Exception as e: 43 | self.log.error("Failed to download {}, {}".format(urlpath, e)) 44 | return False 45 | try: 46 | with open(filename, "wb") as f: 47 | f.write(dat) 48 | except Exception as e: 49 | self.log.error("Failed to write {}, {}".format(filename, e)) 50 | return False 51 | self.log.debug("Downloaded {} to {}".format(urlpath, filename)) 52 | return True 53 | 54 | def install(self, archivename, destination, sanity_check_file): 55 | """ 56 | Install a NEW, DEFAULT FHEM server. 57 | WARNING: the directory tree in destination is ERASED! In order to prevent 58 | accidental erasures, the destination direction must contain 'fhem' and the fhem.pl 59 | file at sanity_check_file must exist. 60 | OLD INSTALLATIONS ARE DELETE! 61 | """ 62 | if not archivename.endswith("tar.gz"): 63 | self.log.error( 64 | "Archive needs to be of type *.tar.gz: {}".format(archivename) 65 | ) 66 | return False 67 | if not os.path.exists(archivename): 68 | self.log.error("Archive {} not found.".format(archivename)) 69 | return False 70 | if "fhem" not in destination or ( 71 | os.path.exists(destination) and not os.path.exists(sanity_check_file) 72 | ): 73 | self.log.error( 74 | "Dangerous or inconsistent fhem install-path: {}, need destination with 'fhem' in name.".format( 75 | destination 76 | ) 77 | ) 78 | self.log.error( 79 | "Or {} exists and sanity-check-file {} doesn't exist.".format( 80 | destination, sanity_check_file 81 | ) 82 | ) 83 | return False 84 | if os.path.exists(destination): 85 | try: 86 | shutil.rmtree(destination) 87 | except Exception as e: 88 | self.log.error( 89 | "Failed to remove existing installation at {}".format(destination) 90 | ) 91 | return False 92 | try: 93 | tar = tarfile.open(archivename, "r:gz") 94 | tar.extractall(destination) 95 | tar.close() 96 | except Exception as e: 97 | self.log.error("Failed to extract {}, {}".format(archivename, e)) 98 | return False 99 | self.log.debug("Extracted {} to {}".format(archivename, destination)) 100 | return True 101 | 102 | def is_running(self, fhem_url="localhost", protocol="http", port=8083): 103 | """ 104 | Check if an fhem server is already running. 105 | """ 106 | try: 107 | fh = fhem.Fhem(fhem_url, protocol=protocol, port=port) 108 | ver = fh.send_cmd("version") 109 | except Exception as e: 110 | ver = None 111 | if ver is not None: 112 | fh.close() 113 | self.log.warning("Fhem already running at {}".format(fhem_url)) 114 | return ver 115 | fh.close() 116 | self.log.debug("Fhem not running at {}".format(fhem_url)) 117 | return None 118 | 119 | def shutdown(self, fhem_url="localhost", protocol="http", port=8083): 120 | """ 121 | Shutdown a running FHEM server 122 | """ 123 | fh = fhem.Fhem(fhem_url, protocol=protocol, port=port) 124 | fh.log.level = logging.CRITICAL 125 | try: 126 | self.log.warning("Shutting down fhem at {}".format(fhem_url)) 127 | fh.send_cmd("shutdown") 128 | except: 129 | pass 130 | self.log.warning("Fhem shutdown complete.") 131 | 132 | 133 | def set_reading(fhi, name, reading, value): 134 | fhi.send_cmd("setreading {} {} {}".format(name, reading, value)) 135 | 136 | 137 | def create_device(fhi, name, readings): 138 | fhi.send_cmd("define {} dummy".format(name)) 139 | fhi.send_cmd("attr {} setList state:on,off".format(name)) 140 | fhi.send_cmd("set {} on".format(name)) 141 | readingList = "" 142 | for rd in readings: 143 | if readingList != "": 144 | readingList += " " 145 | readingList += rd 146 | fhi.send_cmd("attr {} readingList {}".format(name, readingList)) 147 | for rd in readings: 148 | set_reading(fhi, name, rd, readings[rd]) 149 | 150 | 151 | if __name__ == "__main__": 152 | # check args for reuse (-r) 153 | parser = argparse.ArgumentParser(description="Fhem self-tester") 154 | parser.add_argument( 155 | "-r", 156 | "--reuse", 157 | action="store_true", 158 | help="Reuse existing FHEM installation", 159 | ) 160 | args = parser.parse_args() 161 | reuse = args.reuse 162 | 163 | logging.basicConfig( 164 | level=logging.DEBUG, 165 | format="%(asctime)s.%(msecs)03d %(name)s %(levelname)s %(message)s", 166 | datefmt="%Y-%m-%d %H:%M:%S", 167 | ) 168 | log = logging.getLogger("SelfTesterMainApp") 169 | log.info("Start FhemSelfTest") 170 | st = FhemSelfTester() 171 | log.info("State 1: Object created.") 172 | config = { 173 | "archivename": "./fhem-6.0.tar.gz", 174 | "urlpath": "https://fhem.de/fhem-6.0.tar.gz", 175 | "destination": "./fhem", 176 | "fhem_file": "./fhem/fhem-6.0/fhem.pl", 177 | "config_file": "./fhem/fhem-6.0/fhem.cfg", 178 | "fhem_dir": "./fhem/fhem-6.0/", 179 | "exec": "cd fhem/fhem-6.0/ && perl fhem.pl fhem.cfg", 180 | "cmds": ["perl", "fhem.pl", "fhem.cfg"], 181 | "testhost": "localhost", 182 | } 183 | 184 | installed = False 185 | if ( 186 | st.is_running(fhem_url=config["testhost"], protocol="http", port=8083) 187 | is not None 188 | ): 189 | log.info("Fhem is already running!") 190 | if reuse is True: 191 | installed = True 192 | else: 193 | st.shutdown(fhem_url=config["testhost"], protocol="http", port=8083) 194 | time.sleep(1) 195 | if ( 196 | st.is_running(fhem_url=config["testhost"], protocol="http", port=8083) 197 | is not None 198 | ): 199 | log.error("Shutdown failed!") 200 | sys.exit(-3) 201 | log.info("--------------------") 202 | log.info("Reinstalling FHEM...") 203 | 204 | if installed is False: 205 | if not st.download(config["archivename"], config["urlpath"]): 206 | log.error("Download failed.") 207 | sys.exit(-1) 208 | 209 | log.info("Starting fhem installation") 210 | 211 | # WARNING! THIS DELETES ANY EXISTING FHEM SERVER at 'destination'! 212 | # All configuration files, databases, logs etc. are DELETED to allow a fresh test install! 213 | if not st.install( 214 | config["archivename"], config["destination"], config["fhem_file"] 215 | ): 216 | log.info("Install failed") 217 | sys.exit(-2) 218 | 219 | os.system("cat fhem-config-addon.cfg >> {}".format(config["config_file"])) 220 | 221 | if not os.path.exists(config["config_file"]): 222 | log.error("Failed to create config file!") 223 | sys.exit(-2) 224 | 225 | certs_dir = os.path.join(config["fhem_dir"], "certs") 226 | os.system("mkdir {}".format(certs_dir)) 227 | os.system( 228 | 'cd {} && openssl req -newkey rsa:2048 -nodes -keyout server-key.pem -x509 -days 36500 -out server-cert.pem -subj "/C=DE/ST=NRW/L=Earth/O=CompanyName/OU=IT/CN=www.example.com/emailAddress=email@example.com"'.format( 229 | certs_dir 230 | ) 231 | ) 232 | 233 | cert_file = os.path.join(certs_dir, "server-cert.pem") 234 | key_file = os.path.join(certs_dir, "server-key.pem") 235 | if not os.path.exists(cert_file) or not os.path.exists(key_file): 236 | log.error("Failed to create certificate files!") 237 | sys.exit(-2) 238 | 239 | # os.system(config["exec"]) 240 | process = subprocess.Popen(config["cmds"], cwd=config['fhem_dir'],stdout=subprocess.PIPE, 241 | stderr=subprocess.PIPE, start_new_session=True) 242 | output, error = process.communicate() 243 | if process.returncode != 0: 244 | raise Exception("Process fhem failed %d %s %s" % (process.returncode, output, error)) 245 | log.info("Fhem startup at {}: {}".format(config['cmds'], output.decode('utf-8'))) 246 | 247 | retry_cnt = 2 248 | for i in range(retry_cnt): 249 | time.sleep(1) 250 | 251 | if st.is_running(fhem_url=config["testhost"], protocol="http", port=8083) is None: 252 | log.warning("Fhem is NOT (yet) running after install and start!") 253 | if i == retry_cnt - 1: 254 | log.error("Giving up.") 255 | sys.exit(-4) 256 | else: 257 | break 258 | 259 | log.info("Install should be ok, Fhem running.") 260 | 261 | connections = [ 262 | {"protocol": "http", "port": 8083}, 263 | { 264 | "protocol": "telnet", 265 | "port": 7073, 266 | "use_ssl": True, 267 | "password": "secretsauce", 268 | }, 269 | {"protocol": "telnet", "port": 7072}, 270 | {"protocol": "https", "port": 8086}, 271 | { 272 | "protocol": "https", 273 | "port": 8085, 274 | "username": "test", 275 | "password": "secretsauce", 276 | }, 277 | { 278 | "protocol": "https", 279 | "port": 8087, 280 | "username": "toast", 281 | "password": "salad", 282 | }, 283 | ] 284 | 285 | first = True 286 | devs = [ 287 | { 288 | "name": "clima_sensor1", 289 | "readings": {"temperature": 18.2, "humidity": 88.2}, 290 | }, 291 | { 292 | "name": "clima_sensor2", 293 | "readings": {"temperature": 19.1, "humidity": 85.7}, 294 | }, 295 | ] 296 | log.info("") 297 | log.info("----------------- Fhem ------------") 298 | log.info("Testing python-fhem Fhem():") 299 | for connection in connections: 300 | log.info("Testing connection to {} via {}".format(config["testhost"], connection)) 301 | fh = fhem.Fhem(config["testhost"], **connection) 302 | 303 | 304 | if first is True: 305 | for dev in devs: 306 | create_device(fh, dev["name"], dev["readings"]) 307 | first = False 308 | 309 | for dev in devs: 310 | for i in range(10): 311 | log.debug("Repetion: {}, connection: {}".format(i + 1, fh.connection)) 312 | if fh.connected() is False: 313 | log.info("Connecting...") 314 | fh.connect() 315 | for rd in dev["readings"]: 316 | dict_value = fh.get_device_reading(dev["name"], rd, blocking=False) 317 | try: 318 | value = dict_value["Value"] 319 | except: 320 | log.error( 321 | "Bad reply reading {} {} -> {}".format( 322 | dev["name"], rd, dict_value 323 | ) 324 | ) 325 | sys.exit(-7) 326 | 327 | if value == dev["readings"][rd]: 328 | log.debug( 329 | "Reading-test {},{}={} ok.".format( 330 | dev["name"], rd, dev["readings"][rd] 331 | ) 332 | ) 333 | else: 334 | log.error( 335 | "Failed to set and read reading! {},{} {} != {}".format( 336 | dev["name"], rd, value, dev["readings"][rd] 337 | ) 338 | ) 339 | sys.exit(-5) 340 | 341 | num_temps = 0 342 | for dev in devs: 343 | if "temperature" in dev["readings"]: 344 | num_temps += 1 345 | temps = fh.get_readings("temperature", timeout=0.1, blocking=False) 346 | if len(temps) != num_temps: 347 | log.error( 348 | "There should have been {} devices with temperature reading, but we got {}. Ans: {}".format( 349 | num_temps, len(temps), temps 350 | ) 351 | ) 352 | sys.exit(-6) 353 | else: 354 | log.info("Multiread of all devices with 'temperature' reading: ok.") 355 | 356 | states = fh.get_states() 357 | if len(states) < 5: 358 | log.error("Iconsistent number of states: {}".format(len(states))) 359 | sys.exit(-7) 360 | else: 361 | log.info("states received: {}, ok.".format(len(states))) 362 | fh.close() 363 | 364 | log.info("---------------MultiConnect--------------------") 365 | fhm = [] 366 | for connection in connections[-2:]: 367 | log.info("Testing multi-connection to {} via {}".format(config["testhost"], connection)) 368 | fhm.append(fhem.Fhem(config["testhost"], **connection)) 369 | 370 | for dev in devs: 371 | for i in range(10): 372 | for fh in fhm: 373 | log.debug("Repetion: {}, connection: {}".format(i + 1, fh.connection)) 374 | if fh.connected() is False: 375 | log.info("Connecting...") 376 | fh.connect() 377 | for rd in dev["readings"]: 378 | dict_value = fh.get_device_reading(dev["name"], rd, blocking=False) 379 | try: 380 | value = dict_value["Value"] 381 | except: 382 | log.error( 383 | "Bad reply reading {} {} -> {}".format( 384 | dev["name"], rd, dict_value 385 | ) 386 | ) 387 | sys.exit(-7) 388 | 389 | if value == dev["readings"][rd]: 390 | log.debug( 391 | "Reading-test {},{}={} ok.".format( 392 | dev["name"], rd, dev["readings"][rd] 393 | ) 394 | ) 395 | else: 396 | log.error( 397 | "Failed to set and read reading! {},{} {} != {}".format( 398 | dev["name"], rd, value, dev["readings"][rd] 399 | ) 400 | ) 401 | sys.exit(-5) 402 | 403 | num_temps = 0 404 | for dev in devs: 405 | if "temperature" in dev["readings"]: 406 | num_temps += 1 407 | for fh in fhm: 408 | temps = fh.get_readings("temperature", timeout=0.1, blocking=False) 409 | if len(temps) != num_temps: 410 | log.error( 411 | "There should have been {} devices with temperature reading, but we got {}. Ans: {}".format( 412 | num_temps, len(temps), temps 413 | ) 414 | ) 415 | sys.exit(-6) 416 | else: 417 | log.info("Multiread of all devices with 'temperature' reading: ok.") 418 | 419 | for fh in fhm: 420 | states = fh.get_states() 421 | if len(states) < 5: 422 | log.error("Iconsistent number of states: {}".format(len(states))) 423 | sys.exit(-7) 424 | else: 425 | log.info("states received: {}, ok.".format(len(states))) 426 | fh.close() 427 | 428 | log.info("---------------Queues--------------------------") 429 | log.info("Testing python-fhem telnet FhemEventQueues():") 430 | for connection in connections: 431 | if connection["protocol"] != "telnet": 432 | continue 433 | log.info("Testing connection to {} via {}".format(config["testhost"], connection)) 434 | fh = fhem.Fhem(config["testhost"], **connections[0]) 435 | 436 | que = queue.Queue() 437 | que_events = 0 438 | fq = fhem.FhemEventQueue(config["testhost"], que, **connection) 439 | 440 | devs = [ 441 | { 442 | "name": "clima_sensor1", 443 | "readings": {"temperature": 18.2, "humidity": 88.2}, 444 | }, 445 | { 446 | "name": "clima_sensor2", 447 | "readings": {"temperature": 19.1, "humidity": 85.7}, 448 | }, 449 | ] 450 | time.sleep(1.0) 451 | for dev in devs: 452 | for i in range(10): 453 | log.debug("Repetion: {}".format(i + 1)) 454 | for rd in dev["readings"]: 455 | set_reading(fh, dev["name"], rd, 18.0 + i / 0.2) 456 | que_events += 1 457 | time.sleep(0.05) 458 | 459 | time.sleep(3) # This is crucial due to python's "thread"-handling. 460 | ql = 0 461 | has_data = True 462 | while has_data: 463 | try: 464 | que.get(False) 465 | except: 466 | has_data = False 467 | break 468 | que.task_done() 469 | ql += 1 470 | 471 | log.debug("Queue length: {}".format(ql)) 472 | if ql != que_events: 473 | log.error( 474 | "FhemEventQueue contains {} entries, expected {} entries, failure.".format( 475 | ql, que_events 476 | ) 477 | ) 478 | sys.exit(-8) 479 | else: 480 | log.info("Queue test success, Ok.") 481 | fh.close() 482 | fq.close() 483 | time.sleep(0.5) 484 | 485 | log.info("All tests successfull.") 486 | sys.exit(0) 487 | -------------------------------------------------------------------------------- /test_mod.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ -f /etc/os-release ]; then 3 | # freedesktop.org and systemd 4 | . /etc/os-release 2>/dev/null 5 | OS=$NAME 6 | VER=$VERSION_ID 7 | if uname -a | grep -Fq "WSL" 2> /dev/null; then 8 | SUB_SYSTEM="WSL" 9 | fi 10 | elif type lsb_release >/dev/null 2>&1; then 11 | # linuxbase.org 12 | OS=$(lsb_release -si) 13 | VER=$(lsb_release -sr) 14 | elif [ -f /etc/lsb-release ]; then 15 | # For some versions of Debian/Ubuntu without lsb_release command 16 | . /etc/lsb-release 17 | OS=$DISTRIB_ID 18 | VER=$DISTRIB_RELEASE 19 | fi 20 | 21 | if [[ "$OS" == "Arch Linux" ]]; then 22 | echo "Arch Linux" 23 | pip uninstall fhem --break-system-packages 24 | else 25 | echo "OS: $OS" 26 | pip uninstall fhem 27 | fi 28 | ./publish.sh 29 | if [[ "$OS" == "Arch Linux" ]]; then 30 | pip install fhem/dist/fhem-0.7.0.tar.gz --break-system-packages 31 | else 32 | pip install fhem/dist/fhem-0.7.0.tar.gz 33 | fi 34 | cd selftest 35 | python selftest.py 36 | 37 | --------------------------------------------------------------------------------