├── dist ├── ownd-0.7.49.tar.gz └── OWNd-0.7.49-py3-none-any.whl ├── OWNd ├── __init__.py ├── __main__.py ├── discovery.py ├── connection.py └── message.py ├── setup.py ├── README.md ├── .gitignore └── LICENSE /dist/ownd-0.7.49.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anotherjulien/OWNd/HEAD/dist/ownd-0.7.49.tar.gz -------------------------------------------------------------------------------- /OWNd/__init__.py: -------------------------------------------------------------------------------- 1 | """ OWNd - an OpenWebNet daemon """ # pylint: disable=invalid-name 2 | __version__ = "0.7.49" 3 | -------------------------------------------------------------------------------- /dist/OWNd-0.7.49-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anotherjulien/OWNd/HEAD/dist/OWNd-0.7.49-py3-none-any.whl -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ PyPi setup file for OWNd. """ 4 | 5 | import setuptools 6 | 7 | with open("README.md", encoding="utf-8", mode="r") as fh: 8 | long_description = fh.read() 9 | 10 | setuptools.setup( 11 | name="OWNd", 12 | version="0.7.49", 13 | author="anotherjulien", 14 | url="https://github.com/anotherjulien/OWNd", 15 | author_email="yetanotherjulien@gmail.com", 16 | description="Python interface for the OpenWebNet protocol", 17 | long_description=long_description, 18 | long_description_content_type="text/markdown", 19 | packages=setuptools.find_packages(), 20 | classifiers=[ 21 | "Programming Language :: Python :: 3.8", 22 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 23 | "Operating System :: OS Independent", 24 | ], 25 | install_requires=["aiohttp", "pytz", "python-dateutil"], 26 | python_requires=">=3.8", 27 | ) 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OWNd 2 | 3 | This package is an event listener and command forwarder for the OpenWebNet protocol. 4 | 5 | It is mainly intended to be used in an Home-Assistant integration. 6 | 7 | At this point most events are understood. 8 | WHO = 5 (Burglar Alarm) event support is limited and needs further development. 9 | Many commands are implemented, mostly within the requirements of Home-Assistant. 10 | 11 | ## Testing OWNd 12 | 13 | Testing OWNd is pretty simple. 14 | Clone this repository and then: 15 | 16 | ``` 17 | cd 18 | pip3 install . 19 | python3 -m OWNd --help # to visualize possible options 20 | ``` 21 | 22 | To attempt connection to the first available OpenWebNet gateway in the local area network you 23 | can run: 24 | 25 | ``` 26 | python3 -m OWNd 27 | ``` 28 | 29 | This will use [SSDP](https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol) to discover 30 | all supported gateways and pick the first one. 31 | 32 | Alternatively, if you want to skip the SSDP discovery step, you can provide the IP address, port 33 | and MAC address of the gateway from command-line: 34 | 35 | ``` 36 | python3 -m OWNd --address --port --password --mac 37 | ``` 38 | 39 | Note that all these details can be retrieved using bTICINO Home+Project Android application. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Visual studio code settings 2 | .vscode 3 | .VSCodeCounter/ 4 | 5 | # macOS 6 | .DS_Store 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | #Pylint 138 | .pylintrc -------------------------------------------------------------------------------- /OWNd/__main__.py: -------------------------------------------------------------------------------- 1 | """ OWNd entry point when running it directly from CLI 2 | (as opposed to imported into another project) 3 | """ 4 | import argparse 5 | import asyncio 6 | import logging 7 | 8 | from .message import OWNMessage 9 | 10 | from .connection import OWNEventSession, OWNGateway 11 | 12 | 13 | async def main(arguments: dict, connection: OWNEventSession) -> None: 14 | """Package entry point!""" 15 | 16 | address = ( 17 | arguments["address"] 18 | if "address" in arguments and isinstance(arguments["address"], str) 19 | else None 20 | ) 21 | port = ( 22 | arguments["port"] 23 | if "port" in arguments and isinstance(arguments["port"], int) 24 | else None 25 | ) 26 | password = ( 27 | arguments["password"] 28 | if "password" in arguments and isinstance(arguments["password"], str) 29 | else None 30 | ) 31 | serial_number = ( 32 | arguments["serialNumber"] 33 | if "serialNumber" in arguments and isinstance(arguments["serialNumber"], str) 34 | else None 35 | ) 36 | logger = ( 37 | arguments["logger"] 38 | if "logger" in arguments and isinstance(arguments["logger"], logging.Logger) 39 | else None 40 | ) 41 | 42 | logger.info("Starting discovery of a supported gateway via SSDP") 43 | gateway = await OWNGateway.build_from_discovery_info( 44 | { 45 | "address": address, 46 | "port": port, 47 | "password": password, 48 | "serialNumber": serial_number, 49 | } 50 | ) 51 | connection.gateway = gateway 52 | 53 | if logger is not None: 54 | connection.logger = logger 55 | 56 | logger.info("Starting connection to the discovered gateway") 57 | await connection.connect() 58 | 59 | logger.info("Now waiting for events from the gateway (e.g. a cover opening/closing)") 60 | while True: 61 | message = await connection.get_next() 62 | if message: 63 | logger.debug("Received: %s", message) 64 | if isinstance(message, OWNMessage) and message.is_event: 65 | logger.info(message.human_readable_log) 66 | 67 | 68 | if __name__ == "__main__": 69 | 70 | parser = argparse.ArgumentParser() 71 | parser.add_argument( 72 | "-a", "--address", type=str, help="IP address of the OpenWebNet gateway" 73 | ) 74 | parser.add_argument( 75 | "-p", 76 | "--port", 77 | type=int, 78 | help="TCP port to connectect the gateway, default is 20000", 79 | ) 80 | parser.add_argument( 81 | "-P", 82 | "--password", 83 | type=str, 84 | help="Numeric password for the OpenWebNet connection, default is 12345", 85 | ) 86 | parser.add_argument( 87 | "-m", 88 | "--mac", 89 | type=str, 90 | help="MAC address of the gateway (to be used as ID, if not found via SSDP)", 91 | ) 92 | parser.add_argument( 93 | "-v", 94 | "--verbose", 95 | type=int, 96 | help="Change output verbosity [0 = WARNING; 1 = INFO (default); 2 = DEBUG]", 97 | ) 98 | args = parser.parse_args() 99 | 100 | # create logger with 'OWNd' 101 | _logger = logging.getLogger("OWNd") 102 | _logger.setLevel(logging.DEBUG) 103 | 104 | # create console handler which logs even debug messages 105 | log_stream_handler = logging.StreamHandler() 106 | 107 | if args.verbose == 2: 108 | log_stream_handler.setLevel(logging.DEBUG) 109 | elif args.verbose == 0: 110 | log_stream_handler.setLevel(logging.WARNING) 111 | else: 112 | log_stream_handler.setLevel(logging.INFO) 113 | 114 | # create formatter and add it to the handlers 115 | formatter = logging.Formatter( 116 | "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 117 | ) 118 | log_stream_handler.setFormatter(formatter) 119 | # add the handlers to the logger 120 | _logger.addHandler(log_stream_handler) 121 | 122 | event_session = OWNEventSession(gateway=None, logger=_logger) 123 | _arguments = { 124 | "address": args.address, 125 | "port": args.port, 126 | "password": args.password, 127 | "serialNumber": args.mac, 128 | "logger": _logger, 129 | } 130 | 131 | loop = asyncio.get_event_loop() 132 | main_task = asyncio.ensure_future(main(_arguments, event_session)) 133 | # loop.set_debug(True) 134 | 135 | try: 136 | _logger.info("Starting OWNd.") 137 | loop.run_forever() 138 | # asyncio.run(main(arguments)) 139 | except KeyboardInterrupt: 140 | _logger.info("Stoping OWNd.") 141 | main_task.cancel() 142 | loop.run_until_complete(event_session.close()) 143 | loop.stop() 144 | loop.close() 145 | finally: 146 | _logger.info("OWNd stopped.") 147 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /OWNd/discovery.py: -------------------------------------------------------------------------------- 1 | """ OWNd mechanism for discovering gateways on local network """ 2 | 3 | import asyncio 4 | import email.parser 5 | import socket 6 | import xml.dom.minidom 7 | from urllib.parse import urlparse 8 | 9 | import aiohttp 10 | 11 | 12 | class SSDPMessage: 13 | """Simplified HTTP message to serve as a SSDP message.""" 14 | 15 | def __init__(self, version="HTTP/1.1", headers=None): 16 | if headers is None: 17 | headers = [] 18 | elif isinstance(headers, dict): 19 | headers = headers.items() 20 | 21 | self.version = version 22 | self.headers = list(headers) 23 | self.headers_dictionary = {} 24 | for header in self.headers: 25 | self.headers_dictionary.setdefault(header[0], header[1]) 26 | 27 | @classmethod 28 | def parse(cls, msg): 29 | """ 30 | Parse message a string into a :class:`SSDPMessage` instance. 31 | Args: 32 | msg (str): Message string. 33 | Returns: 34 | SSDPMessage: Message parsed from string. 35 | """ 36 | raise NotImplementedError() 37 | 38 | @classmethod 39 | def parse_headers(cls, msg): 40 | """ 41 | Parse HTTP headers. 42 | Args: 43 | msg (str): HTTP message. 44 | Returns: 45 | (List[Tuple[str, str]]): List of header tuples. 46 | """ 47 | return list(email.parser.Parser().parsestr(msg).items()) 48 | 49 | def __str__(self): 50 | """Return full HTTP message.""" 51 | raise NotImplementedError() 52 | 53 | def __bytes__(self): 54 | """Return full HTTP message as bytes.""" 55 | _bytes = self.__str__().encode().replace(b"\n", b"\r\n") 56 | _bytes = _bytes + b"\r\n\r\n" 57 | return _bytes 58 | 59 | 60 | class SSDPResponse(SSDPMessage): 61 | """Simple Service Discovery Protocol (SSDP) response.""" 62 | 63 | def __init__(self, status_code, reason, **kwargs): 64 | self.status_code = int(status_code) 65 | self.reason = reason 66 | super().__init__(**kwargs) 67 | 68 | @classmethod 69 | def parse(cls, msg): 70 | """Parse message string to response object.""" 71 | lines = msg.splitlines() 72 | version, status_code, reason = lines[0].split() 73 | headers = cls.parse_headers("\r\n".join(lines[1:])) 74 | return cls( 75 | version=version, status_code=status_code, reason=reason, headers=headers 76 | ) 77 | 78 | def __str__(self): 79 | """Return complete SSDP response.""" 80 | lines = list() 81 | lines.append(" ".join([self.version, str(self.status_code), self.reason])) 82 | for header in self.headers: 83 | lines.append("%s: %s" % header) 84 | return "\n".join(lines) 85 | 86 | 87 | class SSDPRequest(SSDPMessage): 88 | """Simple Service Discovery Protocol (SSDP) request.""" 89 | 90 | def __init__(self, method, uri="*", version="HTTP/1.1", headers=None): 91 | self.method = method 92 | self.uri = uri 93 | super().__init__(version=version, headers=headers) 94 | 95 | @classmethod 96 | def parse(cls, msg): 97 | """Parse message string to request object.""" 98 | lines = msg.splitlines() 99 | method, uri, version = lines[0].split() 100 | headers = cls.parse_headers("\r\n".join(lines[1:])) 101 | return cls(version=version, uri=uri, method=method, headers=headers) 102 | 103 | def __str__(self): 104 | """Return complete SSDP request.""" 105 | lines = list() 106 | lines.append(" ".join([self.method, self.uri, self.version])) 107 | for header in self.headers: 108 | lines.append("%s: %s" % header) 109 | return "\n".join(lines) 110 | 111 | 112 | class SimpleServiceDiscoveryProtocol(asyncio.DatagramProtocol): 113 | """ 114 | Simple Service Discovery Protocol (SSDP). 115 | SSDP is part of UPnP protocol stack. For more information see: 116 | https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol 117 | """ 118 | 119 | def __init__(self, recvq, excq): 120 | """ 121 | @param recvq - asyncio.Queue for new datagrams 122 | @param excq - asyncio.Queue for exceptions 123 | """ 124 | self._recvq = recvq 125 | self._excq = excq 126 | 127 | # Transports are connected at the time a connection is made. 128 | self._transport = None 129 | 130 | def connection_made(self, transport): 131 | self._transport = transport 132 | 133 | def datagram_received(self, data, addr): 134 | data = data.decode() 135 | 136 | if data.startswith("HTTP/"): 137 | response = SSDPResponse.parse(data) 138 | if ( 139 | response.headers_dictionary["USN"].startswith("uuid:pnp-webserver-") 140 | or response.headers_dictionary["USN"].startswith("uuid:pnp-scheduler-") 141 | or response.headers_dictionary["USN"].startswith( 142 | "uuid:pnp-scheduler201-" 143 | ) 144 | or response.headers_dictionary["USN"].startswith( 145 | "uuid:pnp-touchscreen-" 146 | ) 147 | or response.headers_dictionary["USN"].startswith( 148 | "uuid:pnp-myhomeserver1-" 149 | ) 150 | or response.headers_dictionary["USN"].startswith( 151 | "uuid:upnp-Basic gateway-" 152 | ) 153 | or response.headers_dictionary["USN"].startswith( 154 | "uuid:upnp-IPscenariomodule-" 155 | ) 156 | or response.headers_dictionary["USN"].startswith( 157 | "uuid:upnp-IPscenarioModule-" 158 | ) 159 | ): 160 | 161 | self._recvq.put_nowait( 162 | { 163 | "address": addr[0], 164 | "ssdp_location": response.headers_dictionary["LOCATION"], 165 | "ssdp_st": response.headers_dictionary["ST"], 166 | } 167 | ) 168 | 169 | def error_received(self, exc): 170 | self._excq.put_nowait(exc) 171 | 172 | def connection_lost(self, exc): 173 | if exc is not None: 174 | self._excq.put_nowait(exc) 175 | 176 | if self._transport is not None: 177 | self._transport.close() 178 | self._transport = None 179 | 180 | 181 | def _get_soap_body(namespace: str, action: str) -> str: 182 | soap_body = f""" 183 | 184 | 185 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | """ 196 | return soap_body 197 | 198 | 199 | async def get_port(scpd_location: str) -> int: 200 | 201 | host = urlparse(scpd_location).netloc 202 | scheme = urlparse(scpd_location).scheme 203 | try: 204 | async with aiohttp.ClientSession() as session: 205 | 206 | service_ns = "urn:schemas-bticino-it:service:openserver:1" 207 | service_action = "getopenserverPort" 208 | service_control = "upnp/pwdControl" 209 | soap_body = _get_soap_body(service_ns, service_action) 210 | soap_action = f"{service_ns}#{service_action}" 211 | headers = { 212 | "SOAPAction": f'"{soap_action}"', 213 | "Host": f"{host}", 214 | "Content-Type": "text/xml", 215 | "Content-Length": str(len(soap_body)), 216 | } 217 | 218 | ctrl_url = f"{scheme}://{host}/{service_control}" 219 | resp = await session.post(ctrl_url, data=soap_body, headers=headers) 220 | soap_response = xml.dom.minidom.parseString( 221 | await resp.text() 222 | ).documentElement 223 | await session.close() 224 | 225 | return int(soap_response.getElementsByTagName("Port")[0].childNodes[0].data) 226 | except aiohttp.client_exceptions.ServerDisconnectedError: 227 | return 20000 228 | except aiohttp.client_exceptions.ClientOSError: 229 | return 20000 230 | 231 | 232 | async def _get_scpd_details(scpd_location: str) -> dict: 233 | 234 | discovery_info = dict() 235 | 236 | async with aiohttp.ClientSession() as session: 237 | scpd_response = await session.get(scpd_location) 238 | scpd_xml = xml.dom.minidom.parseString( 239 | await scpd_response.text() 240 | ).documentElement 241 | 242 | discovery_info["deviceType"] = ( 243 | scpd_xml.getElementsByTagName("deviceType")[0].childNodes[0].data 244 | ) 245 | discovery_info["friendlyName"] = ( 246 | scpd_xml.getElementsByTagName("friendlyName")[0].childNodes[0].data 247 | ) 248 | discovery_info["manufacturer"] = ( 249 | scpd_xml.getElementsByTagName("manufacturer")[0].childNodes[0].data 250 | ) 251 | discovery_info["manufacturerURL"] = ( 252 | scpd_xml.getElementsByTagName("manufacturerURL")[0].childNodes[0].data 253 | ) 254 | discovery_info["modelName"] = ( 255 | scpd_xml.getElementsByTagName("modelName")[0].childNodes[0].data 256 | ) 257 | discovery_info["modelNumber"] = ( 258 | scpd_xml.getElementsByTagName("modelNumber")[0].childNodes[0].data 259 | ) 260 | # discovery_info["presentationURL"] = ( 261 | # scpd_xml.getElementsByTagName("presentationURL")[0].childNodes[0].data 262 | # ) ## bticino did not populate this field 263 | discovery_info["serialNumber"] = ( 264 | scpd_xml.getElementsByTagName("serialNumber")[0].childNodes[0].data 265 | ) 266 | discovery_info["UDN"] = ( 267 | scpd_xml.getElementsByTagName("UDN")[0].childNodes[0].data 268 | ) 269 | 270 | discovery_info["port"] = await get_port(scpd_location) 271 | 272 | await session.close() 273 | 274 | return discovery_info 275 | 276 | 277 | async def find_gateways() -> list: 278 | 279 | return_list = list() 280 | 281 | # Start the asyncio loop. 282 | loop = asyncio.get_running_loop() 283 | recvq = asyncio.Queue() 284 | excq = asyncio.Queue() 285 | 286 | search_request = bytes( 287 | SSDPRequest( 288 | "M-SEARCH", 289 | headers={ 290 | "MX": "2", 291 | "ST": "upnp:rootdevice", 292 | "MAN": '"ssdp:discover"', 293 | "HOST": "239.255.255.250:1900", 294 | "Content-Length": "0", 295 | }, 296 | ) 297 | ) 298 | 299 | ( 300 | transport, 301 | protocol, # pylint: disable=unused-variable 302 | ) = await loop.create_datagram_endpoint( 303 | lambda: SimpleServiceDiscoveryProtocol(recvq, excq), family=socket.AF_INET 304 | ) 305 | transport.sendto(search_request, ("239.255.255.250", 1900)) 306 | try: 307 | await asyncio.sleep(2) 308 | finally: 309 | transport.close() 310 | 311 | while not recvq.empty(): 312 | discovery_info = await recvq.get() 313 | discovery_info.update(await _get_scpd_details(discovery_info["ssdp_location"])) 314 | 315 | return_list.append(discovery_info) 316 | 317 | return return_list 318 | 319 | 320 | async def get_gateway(address: str) -> dict: 321 | _local_gateways = await find_gateways() 322 | for _gateway in _local_gateways: 323 | if _gateway["address"] == address: 324 | return _gateway 325 | 326 | 327 | if __name__ == "__main__": 328 | local_gateways = asyncio.run(find_gateways()) 329 | 330 | for gateway in local_gateways: 331 | print(f"Address: {gateway['address']}") 332 | print(f"Port: {gateway['port']}") 333 | print(f"Manufacturer: {gateway['manufacturer']}") 334 | print(f"Model: {gateway['modelName']}") 335 | print(f"Firmware: {gateway['modelNumber']}") 336 | print(f"Serial: {gateway['serialNumber']}") 337 | print() 338 | -------------------------------------------------------------------------------- /OWNd/connection.py: -------------------------------------------------------------------------------- 1 | """ This module handles TCP connections to the OpenWebNet gateway """ 2 | 3 | import asyncio 4 | import hmac 5 | import hashlib 6 | import string 7 | import random 8 | import logging 9 | from typing import Union 10 | from urllib.parse import urlparse 11 | 12 | from .discovery import find_gateways, get_gateway, get_port 13 | from .message import OWNMessage, OWNSignaling 14 | 15 | 16 | class OWNGateway: 17 | def __init__(self, discovery_info: dict): 18 | # Attributes potentially provided by user 19 | self.address = ( 20 | discovery_info["address"] if "address" in discovery_info else None 21 | ) 22 | self._password = ( 23 | discovery_info["password"] if "password" in discovery_info else None 24 | ) 25 | # Attributes retrieved from SSDP discovery 26 | self.ssdp_location = ( 27 | discovery_info["ssdp_location"] 28 | if "ssdp_location" in discovery_info 29 | else None 30 | ) 31 | self.ssdp_st = ( 32 | discovery_info["ssdp_st"] if "ssdp_st" in discovery_info else None 33 | ) 34 | # Attributes retrieved from UPnP device description 35 | self.device_type = ( 36 | discovery_info["deviceType"] if "deviceType" in discovery_info else None 37 | ) 38 | self.friendly_name = ( 39 | discovery_info["friendlyName"] if "friendlyName" in discovery_info else None 40 | ) 41 | self.manufacturer = ( 42 | discovery_info["manufacturer"] 43 | if "manufacturer" in discovery_info 44 | else "BTicino S.p.A." 45 | ) 46 | self.manufacturer_url = ( 47 | discovery_info["manufacturerURL"] 48 | if "manufacturerURL" in discovery_info 49 | else None 50 | ) 51 | self.model_name = ( 52 | discovery_info["modelName"] 53 | if "modelName" in discovery_info 54 | else "Unknown model" 55 | ) 56 | self.model_number = ( 57 | discovery_info["modelNumber"] if "modelNumber" in discovery_info else None 58 | ) 59 | # self.presentationURL = ( 60 | # discovery_info["presentationURL"] 61 | # if "presentationURL" in discovery_info 62 | # else None 63 | # ) 64 | self.serial_number = ( 65 | discovery_info["serialNumber"] if "serialNumber" in discovery_info else None 66 | ) 67 | self.udn = discovery_info["UDN"] if "UDN" in discovery_info else None 68 | # Attributes retrieved from SOAP service control 69 | self.port = discovery_info["port"] if "port" in discovery_info else None 70 | 71 | self._log_id = f"[{self.model_name} gateway - {self.host}]" 72 | 73 | @property 74 | def unique_id(self) -> str: 75 | return self.serial_number 76 | 77 | @unique_id.setter 78 | def unique_id(self, unique_id: str) -> None: 79 | self.serial_number = unique_id 80 | 81 | @property 82 | def host(self) -> str: 83 | return self.address 84 | 85 | @host.setter 86 | def host(self, host: str) -> None: 87 | self.address = host 88 | 89 | @property 90 | def firmware(self) -> str: 91 | return self.model_number 92 | 93 | @firmware.setter 94 | def firmware(self, firmware: str) -> None: 95 | self.model_number = firmware 96 | 97 | @property 98 | def serial(self) -> str: 99 | return self.serial_number 100 | 101 | @serial.setter 102 | def serial(self, serial: str) -> None: 103 | self.serial_number = serial 104 | 105 | @property 106 | def password(self) -> str: 107 | return self._password 108 | 109 | @password.setter 110 | def password(self, password: str) -> None: 111 | self._password = password 112 | 113 | @property 114 | def log_id(self) -> str: 115 | return self._log_id 116 | 117 | @log_id.setter 118 | def log_id(self, id: str) -> None: 119 | self._log_id = id 120 | 121 | @classmethod 122 | async def get_first_available_gateway(cls, password: str = None): 123 | local_gateways = await find_gateways() 124 | local_gateways[0]["password"] = password 125 | return cls(local_gateways[0]) 126 | 127 | @classmethod 128 | async def find_from_address(cls, address: str): 129 | if address is not None: 130 | return cls(await get_gateway(address)) 131 | else: 132 | return await cls.get_first_available_gateway() 133 | 134 | @classmethod 135 | async def build_from_discovery_info(cls, discovery_info: dict): 136 | if ( 137 | ("address" not in discovery_info or discovery_info["address"] is None) 138 | and "ssdp_location" in discovery_info 139 | and discovery_info["ssdp_location"] is not None 140 | ): 141 | discovery_info["address"] = urlparse( 142 | discovery_info["ssdp_location"] 143 | ).hostname 144 | 145 | if "port" in discovery_info and discovery_info["port"] is None: 146 | if ( 147 | "ssdp_location" in discovery_info 148 | and discovery_info["ssdp_location"] is not None 149 | ): 150 | discovery_info["port"] = await get_port(discovery_info["ssdp_location"]) 151 | elif "address" in discovery_info and discovery_info["address"] is not None: 152 | return await cls.find_from_address(discovery_info["address"]) 153 | else: 154 | return await cls.get_first_available_gateway( 155 | password=discovery_info["password"] 156 | if "password" in discovery_info 157 | else None 158 | ) 159 | 160 | return cls(discovery_info) 161 | 162 | 163 | class OWNSession: 164 | """Connection to OpenWebNet gateway""" 165 | 166 | SEPARATOR = "##".encode() 167 | 168 | def __init__( 169 | self, 170 | gateway: OWNGateway = None, 171 | connection_type: str = "test", 172 | logger: logging.Logger = None, 173 | ): 174 | """Initialize the class 175 | Arguments: 176 | gateway: OpenWebNet gateway instance 177 | connection_type: used when logging to identify this session 178 | logger: instance of logging 179 | """ 180 | 181 | self._gateway = gateway 182 | self._type = connection_type.lower() 183 | self._logger = logger 184 | 185 | # annotations for stream reader/writer: 186 | self._stream_reader: asyncio.StreamReader 187 | self._stream_writer: asyncio.StreamWriter 188 | # init them to None: 189 | self._stream_reader = None 190 | self._stream_writer = None 191 | 192 | @property 193 | def gateway(self) -> OWNGateway: 194 | return self._gateway 195 | 196 | @gateway.setter 197 | def gateway(self, gateway: OWNGateway) -> None: 198 | self._gateway = gateway 199 | 200 | # password is a property inside OWNGateway... right? 201 | #@property 202 | #def password(self) -> str: 203 | # return str(self._password) 204 | #@password.setter 205 | #def password(self, password: str) -> None: 206 | # self._password = password 207 | 208 | @property 209 | def logger(self) -> logging.Logger: 210 | return self._logger 211 | 212 | @logger.setter 213 | def logger(self, logger: logging.Logger) -> None: 214 | self._logger = logger 215 | 216 | @property 217 | def connection_type(self) -> str: 218 | return self._type 219 | 220 | @connection_type.setter 221 | def connection_type(self, connection_type: str) -> None: 222 | self._type = connection_type.lower() 223 | 224 | @classmethod 225 | async def test_gateway(cls, gateway: OWNGateway) -> dict: 226 | connection = cls(gateway) 227 | return await connection.test_connection() 228 | 229 | async def test_connection(self) -> dict: 230 | retry_count = 0 231 | retry_timer = 1 232 | 233 | while True: 234 | try: 235 | if retry_count > 2: 236 | self._logger.error( 237 | "%s Test session connection still refused after 3 attempts.", 238 | self._gateway.log_id, 239 | ) 240 | return None 241 | ( 242 | self._stream_reader, 243 | self._stream_writer, 244 | ) = await asyncio.open_connection( 245 | self._gateway.address, self._gateway.port 246 | ) 247 | break 248 | except ConnectionRefusedError: 249 | self._logger.warning( 250 | "%s Test session connection refused, retrying in %ss.", 251 | self._gateway.log_id, 252 | retry_timer, 253 | ) 254 | await asyncio.sleep(retry_timer) 255 | retry_count += 1 256 | retry_timer *= 2 257 | 258 | try: 259 | result = await self._negotiate() 260 | await self.close() 261 | except ConnectionResetError: 262 | error = True 263 | error_message = "password_retry" 264 | self._logger.error( 265 | "%s Negotiation reset while opening %s session. Wait 60 seconds before retrying.", 266 | self._gateway.log_id, 267 | self._type, 268 | ) 269 | 270 | return {"Success": not error, "Message": error_message} 271 | 272 | return result 273 | 274 | async def connect(self): 275 | self._logger.debug("%s Opening %s session.", self._gateway.log_id, self._type) 276 | 277 | retry_count = 0 278 | retry_timer = 1 279 | 280 | while True: 281 | try: 282 | if retry_count > 4: 283 | self._logger.error( 284 | "%s %s session connection still refused after 5 attempts.", 285 | self._gateway.log_id, 286 | self._type.capitalize(), 287 | ) 288 | return None 289 | ( 290 | self._stream_reader, 291 | self._stream_writer, 292 | ) = await asyncio.open_connection( 293 | self._gateway.address, self._gateway.port 294 | ) 295 | return await self._negotiate() 296 | except (ConnectionRefusedError, asyncio.IncompleteReadError): 297 | self._logger.warning( 298 | "%s %s session connection refused, retrying in %ss.", 299 | self._gateway.log_id, 300 | self._type.capitalize(), 301 | retry_timer, 302 | ) 303 | await asyncio.sleep(retry_timer) 304 | retry_count += 1 305 | retry_timer = retry_count * 2 306 | except ConnectionResetError: 307 | self._logger.warning( 308 | "%s %s session connection reset, retrying in 60s.", 309 | self._gateway.log_id, 310 | self._type.capitalize(), 311 | ) 312 | await asyncio.sleep(60) 313 | retry_count += 1 314 | 315 | async def close(self) -> None: 316 | """Closes the connection to the OpenWebNet gateway""" 317 | 318 | # this method may be invoked on an empty instance of OWNSession, so be robust against Nones: 319 | if self._stream_writer is not None: 320 | self._stream_writer.close() 321 | await self._stream_writer.wait_closed() 322 | if self._gateway is not None: 323 | self._logger.debug( 324 | "%s %s session closed.", self._gateway.log_id, self._type.capitalize() 325 | ) 326 | 327 | async def _negotiate(self) -> dict: 328 | type_id = 0 if self._type == "command" else 1 329 | error = False 330 | error_message = None 331 | 332 | self._logger.debug( 333 | "%s Negotiating %s session.", self._gateway.log_id, self._type 334 | ) 335 | 336 | self._stream_writer.write(f"*99*{type_id}##".encode()) 337 | await self._stream_writer.drain() 338 | 339 | raw_response = await self._stream_reader.readuntil(OWNSession.SEPARATOR) 340 | resulting_message = OWNSignaling(raw_response.decode()) 341 | # self._logger.debug("%s Reply: `%s`", self._gateway.log_id, resulting_message) 342 | 343 | if resulting_message.is_nack(): 344 | self._logger.error( 345 | "%s Error while opening %s session.", self._gateway.log_id, self._type 346 | ) 347 | error = True 348 | error_message = "connection_refused" 349 | 350 | raw_response = await self._stream_reader.readuntil(OWNSession.SEPARATOR) 351 | resulting_message = OWNSignaling(raw_response.decode()) 352 | if resulting_message.is_nack(): 353 | error = True 354 | error_message = "negotiation_refused" 355 | self._logger.debug( 356 | "%s Reply: `%s`", self._gateway.log_id, resulting_message 357 | ) 358 | self._logger.error( 359 | "%s Error while opening %s session.", self._gateway.log_id, self._type 360 | ) 361 | elif resulting_message.is_sha(): 362 | self._logger.debug( 363 | "%s Received SHA challenge: `%s`", 364 | self._gateway.log_id, 365 | resulting_message, 366 | ) 367 | if self._gateway.password is None: 368 | error = True 369 | error_message = "password_required" 370 | self._logger.warning( 371 | "%s Connection requires a password but none was provided.", 372 | self._gateway.log_id, 373 | ) 374 | self._stream_writer.write("*#*0##".encode()) 375 | await self._stream_writer.drain() 376 | else: 377 | method = "sha" 378 | if resulting_message.is_sha_1(): 379 | # self._logger.debug("%s Detected SHA-1 method.", self._gateway.log_id) 380 | method = "sha1" 381 | elif resulting_message.is_sha_256(): 382 | # self._logger.debug("%s Detected SHA-256 method.", self._gateway.log_id) 383 | method = "sha256" 384 | self._logger.debug( 385 | "%s Accepting %s challenge, initiating handshake.", 386 | self._gateway.log_id, 387 | method, 388 | ) 389 | self._stream_writer.write("*#*1##".encode()) 390 | await self._stream_writer.drain() 391 | raw_response = await self._stream_reader.readuntil(OWNSession.SEPARATOR) 392 | resulting_message = OWNSignaling(raw_response.decode()) 393 | if resulting_message.is_nonce(): 394 | server_random_string_ra = resulting_message.nonce 395 | # self._logger.debug("%s Received Ra.", self._gateway.log_id) 396 | key = "".join(random.choices(string.digits, k=56)) 397 | client_random_string_rb = self._hex_string_to_int_string( 398 | hmac.new(key=key.encode(), digestmod=method).hexdigest() 399 | ) 400 | # self._logger.debug("%s Generated Rb.", self._gateway.log_id) 401 | hashed_password = f"*#{client_random_string_rb}*{self._encode_hmac_password(method=method, password=self._gateway.password, nonce_a=server_random_string_ra, nonce_b=client_random_string_rb)}##" # pylint: disable=line-too-long 402 | self._logger.debug( 403 | "%s Sending %s session password.", 404 | self._gateway.log_id, 405 | self._type, 406 | ) 407 | self._stream_writer.write(hashed_password.encode()) 408 | await self._stream_writer.drain() 409 | try: 410 | raw_response = await asyncio.wait_for( 411 | self._stream_reader.readuntil(OWNSession.SEPARATOR), 412 | timeout=5, 413 | ) 414 | resulting_message = OWNSignaling(raw_response.decode()) 415 | if resulting_message.is_nack(): 416 | error = True 417 | error_message = "password_error" 418 | self._logger.error( 419 | "%s Password error while opening %s session.", 420 | self._gateway.log_id, 421 | self._type, 422 | ) 423 | elif resulting_message.is_nonce(): 424 | # self._logger.debug( 425 | # "%s Received HMAC response.", self._gateway.log_id 426 | # ) 427 | hmac_response = resulting_message.nonce 428 | if hmac_response == self._decode_hmac_response( 429 | method=method, 430 | password=self._gateway.password, 431 | nonce_a=server_random_string_ra, 432 | nonce_b=client_random_string_rb, 433 | ): 434 | # self._logger.debug( 435 | # "%s Server identity confirmed.", self._gateway.log_id 436 | # ) 437 | self._stream_writer.write("*#*1##".encode()) 438 | await self._stream_writer.drain() 439 | self._logger.debug( 440 | "%s Session established successfully.", self._gateway.log_id 441 | ) 442 | else: 443 | self._logger.error( 444 | "%s Server identity could not be confirmed.", 445 | self._gateway.log_id, 446 | ) 447 | self._stream_writer.write("*#*0##".encode()) 448 | await self._stream_writer.drain() 449 | error = True 450 | error_message = "negociation_error" 451 | self._logger.error( 452 | "%s Error while opening %s session: HMAC authentication failed.", 453 | self._gateway.log_id, 454 | self._type, 455 | ) 456 | except asyncio.IncompleteReadError: 457 | error = True 458 | error_message = "password_error" 459 | self._logger.error( 460 | "%s Password error while opening %s session.", 461 | self._gateway.log_id, 462 | self._type, 463 | ) 464 | except asyncio.TimeoutError: 465 | error = True 466 | error_message = "password_error" 467 | self._logger.error( 468 | "%s Password timeout error while opening %s session.", 469 | self._gateway.log_id, 470 | self._type, 471 | ) 472 | elif resulting_message.is_nonce(): 473 | self._logger.debug( 474 | "%s Received nonce: `%s`", self._gateway.log_id, resulting_message 475 | ) 476 | if self._gateway.password is not None: 477 | hashed_password = f"*#{self._get_own_password(self._gateway.password, resulting_message.nonce)}##" # pylint: disable=line-too-long 478 | self._logger.debug( 479 | "%s Sending %s session password.", self._gateway.log_id, self._type 480 | ) 481 | self._stream_writer.write(hashed_password.encode()) 482 | await self._stream_writer.drain() 483 | raw_response = await self._stream_reader.readuntil(OWNSession.SEPARATOR) 484 | resulting_message = OWNSignaling(raw_response.decode()) 485 | # self._logger.debug("%s Reply: `%s`", self._gateway.log_id, resulting_message) 486 | if resulting_message.is_nack(): 487 | error = True 488 | error_message = "password_error" 489 | self._logger.error( 490 | "%s Password error while opening %s session.", 491 | self._gateway.log_id, 492 | self._type, 493 | ) 494 | elif resulting_message.is_ack(): 495 | self._logger.debug( 496 | "%s %s session established successfully.", 497 | self._gateway.log_id, 498 | self._type.capitalize(), 499 | ) 500 | else: 501 | error = True 502 | error_message = "password_error" 503 | self._logger.error( 504 | "%s Connection requires a password but none was provided for %s session.", 505 | self._gateway.log_id, 506 | self._type, 507 | ) 508 | elif resulting_message.is_ack(): 509 | # self._logger.debug("%s Reply: `%s`", self._gateway.log_id, resulting_message) 510 | self._logger.debug( 511 | "%s %s session established successfully.", 512 | self._gateway.log_id, 513 | self._type.capitalize(), 514 | ) 515 | else: 516 | error = True 517 | error_message = "negotiation_failed" 518 | self._logger.debug( 519 | "%s Unexpected message during negotiation: %s", 520 | self._gateway.log_id, 521 | resulting_message, 522 | ) 523 | 524 | return {"Success": not error, "Message": error_message} 525 | 526 | def _get_own_password(self, password, nonce, test=False): 527 | start = True 528 | num1 = 0 529 | num2 = 0 530 | password = int(password) 531 | if test: 532 | print("password: %08x" % (password)) 533 | for character in nonce: 534 | if character != "0": 535 | if start: 536 | num2 = password 537 | start = False 538 | if test: 539 | print("c: %s num1: %08x num2: %08x" % (character, num1, num2)) 540 | if character == "1": 541 | num1 = (num2 & 0xFFFFFF80) >> 7 542 | num2 = num2 << 25 543 | elif character == "2": 544 | num1 = (num2 & 0xFFFFFFF0) >> 4 545 | num2 = num2 << 28 546 | elif character == "3": 547 | num1 = (num2 & 0xFFFFFFF8) >> 3 548 | num2 = num2 << 29 549 | elif character == "4": 550 | num1 = num2 << 1 551 | num2 = num2 >> 31 552 | elif character == "5": 553 | num1 = num2 << 5 554 | num2 = num2 >> 27 555 | elif character == "6": 556 | num1 = num2 << 12 557 | num2 = num2 >> 20 558 | elif character == "7": 559 | num1 = ( 560 | num2 & 0x0000FF00 561 | | ((num2 & 0x000000FF) << 24) 562 | | ((num2 & 0x00FF0000) >> 16) 563 | ) 564 | num2 = (num2 & 0xFF000000) >> 8 565 | elif character == "8": 566 | num1 = (num2 & 0x0000FFFF) << 16 | (num2 >> 24) 567 | num2 = (num2 & 0x00FF0000) >> 8 568 | elif character == "9": 569 | num1 = ~num2 570 | else: 571 | num1 = num2 572 | 573 | num1 &= 0xFFFFFFFF 574 | num2 &= 0xFFFFFFFF 575 | if character not in "09": 576 | num1 |= num2 577 | if test: 578 | print(" num1: %08x num2: %08x" % (num1, num2)) 579 | num2 = num1 580 | return num1 581 | 582 | def _encode_hmac_password( 583 | self, method: str, password: str, nonce_a: str, nonce_b: str 584 | ): 585 | if method == "sha1": 586 | message = ( 587 | self._int_string_to_hex_string(nonce_a) 588 | + self._int_string_to_hex_string(nonce_b) 589 | + "736F70653E" 590 | + "636F70653E" 591 | + hashlib.sha1(password.encode()).hexdigest() 592 | ) 593 | return self._hex_string_to_int_string( 594 | hashlib.sha1(message.encode()).hexdigest() 595 | ) 596 | elif method == "sha256": 597 | message = ( 598 | self._int_string_to_hex_string(nonce_a) 599 | + self._int_string_to_hex_string(nonce_b) 600 | + "736F70653E" 601 | + "636F70653E" 602 | + hashlib.sha256(password.encode()).hexdigest() 603 | ) 604 | return self._hex_string_to_int_string( 605 | hashlib.sha256(message.encode()).hexdigest() 606 | ) 607 | else: 608 | return None 609 | 610 | def _decode_hmac_response( 611 | self, method: str, password: str, nonce_a: str, nonce_b: str 612 | ): 613 | if method == "sha1": 614 | message = ( 615 | self._int_string_to_hex_string(nonce_a) 616 | + self._int_string_to_hex_string(nonce_b) 617 | + hashlib.sha1(password.encode()).hexdigest() 618 | ) 619 | return self._hex_string_to_int_string( 620 | hashlib.sha1(message.encode()).hexdigest() 621 | ) 622 | elif method == "sha256": 623 | message = ( 624 | self._int_string_to_hex_string(nonce_a) 625 | + self._int_string_to_hex_string(nonce_b) 626 | + hashlib.sha256(password.encode()).hexdigest() 627 | ) 628 | return self._hex_string_to_int_string( 629 | hashlib.sha256(message.encode()).hexdigest() 630 | ) 631 | else: 632 | return None 633 | 634 | def _int_string_to_hex_string(self, int_string: str) -> str: 635 | hex_string = "" 636 | for i in range(0, len(int_string), 2): 637 | hex_string += f"{int(int_string[i:i+2]):x}" 638 | return hex_string 639 | 640 | def _hex_string_to_int_string(self, hex_string: str) -> str: 641 | int_string = "" 642 | for i in range(0, len(hex_string), 1): 643 | int_string += f"{int(hex_string[i:i+1], 16):0>2d}" 644 | return int_string 645 | 646 | 647 | class OWNEventSession(OWNSession): 648 | def __init__(self, gateway: OWNGateway = None, logger: logging.Logger = None): 649 | super().__init__(gateway=gateway, connection_type="event", logger=logger) 650 | 651 | @classmethod 652 | async def connect_to_gateway(cls, gateway: OWNGateway): 653 | connection = cls(gateway) 654 | await connection.connect() 655 | 656 | async def get_next(self) -> Union[OWNMessage, str, None]: 657 | """Acts as an entry point to read messages on the event bus. 658 | It will read one frame and return it as an OWNMessage object""" 659 | try: 660 | data = await self._stream_reader.readuntil(OWNSession.SEPARATOR) 661 | _decoded_data = data.decode() 662 | _message = OWNMessage.parse(_decoded_data) 663 | return _message if _message else _decoded_data 664 | except asyncio.IncompleteReadError: 665 | self._logger.warning( 666 | "%s Connection interrupted, reconnecting...", self._gateway.log_id 667 | ) 668 | await self.connect() 669 | return None 670 | except AttributeError: 671 | self._logger.exception( 672 | "%s Received data could not be parsed into a message:", 673 | self._gateway.log_id, 674 | ) 675 | return None 676 | except ConnectionError: 677 | self._logger.exception("%s Connection error:", self._gateway.log_id) 678 | return None 679 | except Exception: # pylint: disable=broad-except 680 | self._logger.exception("%s Event session crashed.", self._gateway.log_id) 681 | return None 682 | 683 | 684 | class OWNCommandSession(OWNSession): 685 | def __init__(self, gateway: OWNGateway = None, logger: logging.Logger = None): 686 | super().__init__(gateway=gateway, connection_type="command", logger=logger) 687 | 688 | @classmethod 689 | async def send_to_gateway(cls, message: str, gateway: OWNGateway): 690 | connection = cls(gateway) 691 | await connection.connect() 692 | await connection.send(message) 693 | 694 | @classmethod 695 | async def connect_to_gateway(cls, gateway: OWNGateway): 696 | connection = cls(gateway) 697 | await connection.connect() 698 | 699 | async def send(self, message, is_status_request: bool = False, attempt: int = 1): 700 | """Send the attached message on an existing 'command' connection, 701 | actively reconnecting it if it had been reset.""" 702 | 703 | try: 704 | 705 | self._stream_writer.write(str(message).encode()) 706 | await self._stream_writer.drain() 707 | raw_response = await self._stream_reader.readuntil(OWNSession.SEPARATOR) 708 | resulting_message = OWNMessage.parse(raw_response.decode()) 709 | 710 | while not isinstance(resulting_message, OWNSignaling): 711 | self._logger.debug( 712 | "%s Message `%s` received response `%s`.", 713 | self._gateway.log_id, 714 | message, 715 | resulting_message, 716 | ) 717 | raw_response = await self._stream_reader.readuntil(OWNSession.SEPARATOR) 718 | resulting_message = OWNMessage.parse(raw_response.decode()) 719 | 720 | if resulting_message.is_nack(): 721 | if attempt <= 2: 722 | self._logger.error( 723 | "%s Could not send message `%s`. Retrying (%d)...", self._gateway.log_id, message, 724 | attempt 725 | ) 726 | return await self.send(message, is_status_request, attempt + 1) 727 | else: 728 | self._logger.error( 729 | "%s Could not send message `%s`. No more retries.", self._gateway.log_id, message 730 | ) 731 | elif resulting_message.is_ack(): 732 | log_message = "%s Message `%s` was successfully sent." 733 | if not is_status_request: 734 | self._logger.info(log_message, self._gateway.log_id, message) 735 | else: 736 | self._logger.debug(log_message, self._gateway.log_id, message) 737 | 738 | except (ConnectionResetError, asyncio.IncompleteReadError): 739 | self._logger.debug( 740 | "%s Command session connection reset, retrying...", self._gateway.log_id 741 | ) 742 | await self.connect() 743 | await self.send(message=message, is_status_request=is_status_request) 744 | except Exception: # pylint: disable=broad-except 745 | self._logger.exception("%s Command session crashed.", self._gateway.log_id) 746 | return None 747 | -------------------------------------------------------------------------------- /OWNd/message.py: -------------------------------------------------------------------------------- 1 | """ This module contains OpenWebNet messages definition """ # pylint: disable=too-many-lines 2 | 3 | from __future__ import annotations 4 | 5 | import datetime 6 | import re 7 | from typing import Optional 8 | 9 | from dateutil.relativedelta import relativedelta 10 | import pytz 11 | 12 | MESSAGE_TYPE_ACTIVE_POWER = "active_power" 13 | MESSAGE_TYPE_ENERGY_TOTALIZER = "energy_totalizer" 14 | MESSAGE_TYPE_HOURLY_CONSUMPTION = "hourly_consumption" 15 | MESSAGE_TYPE_DAILY_CONSUMPTION = "daily_consumption" 16 | MESSAGE_TYPE_MONTHLY_CONSUMPTION = "monthly_consumption" 17 | MESSAGE_TYPE_CURRENT_DAY_CONSUMPTION = "current_day_partial_consumption" 18 | MESSAGE_TYPE_CURRENT_MONTH_CONSUMPTION = "current_month_partial_consumption" 19 | MESSAGE_TYPE_MAIN_TEMPERATURE = "main_temperature" 20 | MESSAGE_TYPE_MAIN_HUMIDITY = "main_humidity" 21 | MESSAGE_TYPE_SECONDARY_TEMPERATURE = "secondary_temperature" 22 | MESSAGE_TYPE_TARGET_TEMPERATURE = "target_temperature" 23 | MESSAGE_TYPE_LOCAL_OFFSET = "local_offset" 24 | MESSAGE_TYPE_LOCAL_TARGET_TEMPERATURE = "local_targer_temperature" 25 | MESSAGE_TYPE_MODE = "hvac_mode" 26 | MESSAGE_TYPE_MODE_TARGET = "hvac_mode_target" 27 | MESSAGE_TYPE_ACTION = "hvac_action" 28 | MESSAGE_TYPE_MOTION = "motion_detected" 29 | MESSAGE_TYPE_PIR_SENSITIVITY = "pir_sensitivity" 30 | MESSAGE_TYPE_ILLUMINANCE = "illuminance_value" 31 | MESSAGE_TYPE_MOTION_TIMEOUT = "motion_timeout" 32 | 33 | CLIMATE_MODE_OFF = "off" 34 | CLIMATE_MODE_HEAT = "heat" 35 | CLIMATE_MODE_COOL = "cool" 36 | CLIMATE_MODE_AUTO = "auto" 37 | 38 | PIR_SENSITIVITY_MAPPING = ["low", "medium", "high", "very high"] 39 | 40 | 41 | class OWNMessage: 42 | _ACK = re.compile(r"^\*#\*1##$") # *#*1## 43 | _NACK = re.compile(r"^\*#\*0##$") # *#*0## 44 | _COMMAND_SESSION = re.compile(r"^\*99\*0##$") # *99*0## 45 | _EVENT_SESSION = re.compile(r"^\*99\*1##$") # *99*1## 46 | _NONCE = re.compile(r"^\*#(\d+)##$") # *#123456789## 47 | _SHA = re.compile(r"^\*98\*(\d)##$") # *98*SHA## 48 | 49 | _STATUS = re.compile( 50 | r"^\*(?P\d+)\*(?P\d+)(?P(?:#\d+)*)\*(?P\*|#?\d+)(?P(?:#\d+)*)##$" # pylint: disable=line-too-long 51 | ) # *WHO*WHAT*WHERE## 52 | _STATUS_REQUEST = re.compile( 53 | r"^\*#(?P\d+)\*(?P#?\d+)(?P(?:#\d+)*)##$" 54 | ) # *#WHO*WHERE 55 | _DIMENSION_WRITING = re.compile( 56 | r"^\*#(?P\d+)\*(?P#?\d+)?(?P(?:#\d+)*)?\*#(?P\d+)(?P(?:#\d+)*)?(?P(?:\*\d*)+)##$" # pylint: disable=line-too-long 57 | ) # *#WHO*WHERE*#DIMENSION*VAL1*VALn## 58 | _DIMENSION_REQUEST = re.compile( 59 | r"^\*#(?P\d+)\*(?P#?\d+)?(?P(?:#\d+)*)?\*(?P\d+)##$" 60 | ) # *#WHO*WHERE*DIMENSION## 61 | _DIMENSION_REQUEST_REPLY = re.compile( 62 | r"^\*#(?P\d+)\*(?P#?\d+)?(?P(?:#\d+)*)?\*(?P\d+)(?P(?:#\d+)*)?(?P(?:\*\d*)+)##$" # pylint: disable=line-too-long 63 | ) # *#WHO*WHERE*DIMENSION*VAL1*VALn## 64 | 65 | """ Base class for all OWN messages """ 66 | 67 | def __init__(self, data): 68 | self._raw = data 69 | self._human_readable_log = self._raw 70 | self._family = "" 71 | self._who = "" 72 | self._where = "" 73 | self._is_valid_message = False 74 | 75 | if self._STATUS.match(self._raw): 76 | self._is_valid_message = True 77 | self._match = self._STATUS.match(self._raw) 78 | self._family = "EVENT" 79 | self._message_type = "STATUS" 80 | self._who = int(self._match.group("who")) 81 | self._what = int(self._match.group("what")) 82 | if self._what == 1000: 83 | self._family = "COMMAND_TRANSLATION" 84 | self._what_param = self._match.group("what_param").split("#") 85 | del self._what_param[0] 86 | self._where = self._match.group("where") 87 | self._where_param = self._match.group("where_param").split("#") 88 | del self._where_param[0] 89 | self._dimension = None 90 | self._dimension_param = None 91 | self._dimension_value = None 92 | 93 | elif self._STATUS_REQUEST.match(self._raw): 94 | self._is_valid_message = True 95 | self._match = self._STATUS_REQUEST.match(self._raw) 96 | self._family = "REQUEST" 97 | self._message_type = "STATUS_REQUEST" 98 | self._who = int(self._match.group("who")) 99 | self._what = None 100 | self._what_param = None 101 | self._where = self._match.group("where") 102 | self._where_param = self._match.group("where_param").split("#") 103 | del self._where_param[0] 104 | self._dimension = None 105 | self._dimension_param = None 106 | self._dimension_value = None 107 | 108 | elif self._DIMENSION_REQUEST.match(self._raw): 109 | self._is_valid_message = True 110 | self._match = self._DIMENSION_REQUEST.match(self._raw) 111 | self._family = "REQUEST" 112 | self._message_type = "DIMENSION_REQUEST" 113 | self._who = int(self._match.group("who")) 114 | self._what = None 115 | self._what_param = None 116 | self._where = self._match.group("where") 117 | self._where_param = self._match.group("where_param").split("#") 118 | del self._where_param[0] 119 | self._dimension = int(self._match.group("dimension")) 120 | self._dimension_param = None 121 | self._dimension_value = None 122 | 123 | elif self._DIMENSION_REQUEST_REPLY.match(self._raw): 124 | self._is_valid_message = True 125 | self._match = self._DIMENSION_REQUEST_REPLY.match(self._raw) 126 | self._family = "EVENT" 127 | self._message_type = "DIMENSION_REQUEST_REPLY" 128 | self._who = int(self._match.group("who")) 129 | self._what = None 130 | self._what_param = None 131 | self._where = self._match.group("where") 132 | self._where_param = self._match.group("where_param").split("#") 133 | del self._where_param[0] 134 | self._dimension = int(self._match.group("dimension")) 135 | self._dimension_param = self._match.group("dimension_param").split("#") 136 | del self._dimension_param[0] 137 | self._dimension_value = self._match.group("dimension_value").split("*") 138 | del self._dimension_value[0] 139 | 140 | elif self._DIMENSION_WRITING.match(self._raw): 141 | self._is_valid_message = True 142 | self._match = self._DIMENSION_WRITING.match(self._raw) 143 | self._family = "COMMAND" 144 | self._message_type = "DIMENSION_WRITING" 145 | self._who = int(self._match.group("who")) 146 | self._what = None 147 | self._what_param = None 148 | self._where = self._match.group("where") 149 | self._where_param = self._match.group("where_param").split("#") 150 | del self._where_param[0] 151 | self._dimension = int(self._match.group("dimension")) 152 | self._dimension_param = self._match.group("dimension_param").split("#") 153 | del self._dimension_param[0] 154 | self._dimension_value = self._match.group("dimension_value").split("*") 155 | del self._dimension_value[0] 156 | 157 | @classmethod 158 | def parse(cls, data) -> Optional[OWNMessage]: 159 | if ( 160 | cls._ACK.match(data) 161 | or cls._NACK.match(data) 162 | or cls._COMMAND_SESSION.match(data) 163 | or cls._EVENT_SESSION.match(data) 164 | or cls._NONCE.match(data) 165 | or cls._SHA.match(data) 166 | ): 167 | return OWNSignaling(data) 168 | elif cls._STATUS.match(data) or cls._DIMENSION_REQUEST_REPLY.match(data): 169 | return OWNEvent.parse(data) 170 | elif ( 171 | cls._STATUS_REQUEST.match(data) 172 | or cls._DIMENSION_REQUEST.match(data) 173 | or cls._DIMENSION_WRITING.match(data) 174 | ): 175 | return OWNCommand.parse(data) 176 | else: 177 | return None 178 | 179 | @property 180 | def is_event(self) -> bool: 181 | return self._family == "EVENT" 182 | 183 | @property 184 | def is_command(self) -> bool: 185 | return self._family == "COMMAND" 186 | 187 | @property 188 | def is_request(self) -> bool: 189 | return self._family == "REQUEST" 190 | 191 | @property 192 | def is_translation(self) -> bool: 193 | return self._family == "COMMAND_TRANSLATION" 194 | 195 | @property 196 | def is_valid(self) -> bool: 197 | return self._is_valid_message 198 | 199 | @property 200 | def who(self) -> int: 201 | """The 'who' ID of the subject of this message""" 202 | return self._who 203 | 204 | @property 205 | def where(self) -> str: 206 | """The 'where' ID of the subject of this message""" 207 | return self._where # [1:] if self._where.startswith('#') else self._where 208 | 209 | @property 210 | def interface(self) -> str: 211 | """The 'where' parameter corresponding to the bus interface of the subject of this message""" 212 | return ( 213 | self._where_param[1] 214 | if self._who in [1, 2, 15] 215 | and len(self._where_param) > 0 216 | and self._where_param[0] == "4" 217 | else None 218 | ) 219 | 220 | @property 221 | def dimension(self) -> str: 222 | """The 'where' ID of the subject of this message""" 223 | return self._dimension 224 | 225 | @property 226 | def entity(self) -> str: 227 | """The ID of the subject of this message""" 228 | return self.unique_id 229 | 230 | @property 231 | def unique_id(self) -> str: 232 | """The ID of the subject of this message""" 233 | return ( 234 | f"{self.who}-{self.where}#4#{self.interface}" 235 | if self.interface is not None 236 | else f"{self.who}-{self.where}" 237 | ) 238 | 239 | @property 240 | def event_content(self) -> dict: 241 | _event = { 242 | "message": self._raw, 243 | "family": self._family.replace("_", " ").capitalize(), 244 | "type": self._message_type.replace("_", " ").capitalize(), 245 | "who": self._who, 246 | } 247 | if self._where: 248 | _event.update({"where": self._where}) 249 | if self.interface: 250 | _event.update({"interface": self.interface}) 251 | if self._where_param and len(self._where_param) > 2: 252 | _event.update({"where parameters": self._where_param[2:]}) 253 | elif self._where_param: 254 | _event.update({"where parameters": self._where_param}) 255 | if self._what: 256 | _event.update({"what": self._what}) 257 | if self._what_param: 258 | _event.update({"what parameters": self._what_param}) 259 | if self._dimension: 260 | _event.update({"dimension": self._dimension}) 261 | if self._dimension_param: 262 | _event.update({"dimension parameters": self._dimension_param}) 263 | if self._dimension_value: 264 | _event.update({"dimension values": self._dimension_value}) 265 | 266 | return _event 267 | 268 | @property 269 | def human_readable_log(self) -> str: 270 | """A human readable log of the event""" 271 | return self._human_readable_log 272 | 273 | @property 274 | def _interface_log_text(self) -> str: 275 | return f" on interface {self.interface}" if self.interface is not None else "" 276 | 277 | @property 278 | def is_general(self) -> bool: 279 | if self.who == 1 or self.who == 2: 280 | if self._where == "0": 281 | return True 282 | else: 283 | return False 284 | 285 | @property 286 | def is_group(self) -> bool: 287 | if self.who == 1 or self.who == 2: 288 | if self._where.startswith("#"): 289 | return True 290 | else: 291 | return False 292 | else: 293 | return False 294 | 295 | @property 296 | def is_area(self) -> bool: 297 | if self.who == 1 or self.who == 2: 298 | try: 299 | if ( 300 | self._where == "00" 301 | or self._where == "100" 302 | or ( 303 | len(self._where) == 1 304 | and int(self._where) > 0 305 | and int(self._where) < 10 306 | ) 307 | ): 308 | return True 309 | else: 310 | return False 311 | except ValueError: 312 | return False 313 | else: 314 | return False 315 | 316 | @property 317 | def group(self) -> int: 318 | if self.is_group: 319 | return int(self._where[1:]) 320 | else: 321 | return None 322 | 323 | @property 324 | def area(self) -> int: 325 | if self.is_area: 326 | return 10 if self._where == "100" else int(self._where) 327 | else: 328 | return None 329 | 330 | def __repr__(self) -> str: 331 | return self._raw 332 | 333 | def __str__(self) -> str: 334 | return self._raw 335 | 336 | 337 | class OWNEvent(OWNMessage): 338 | """ 339 | This class is a subclass of messages. 340 | All messages received during an event session are events. 341 | Dividing this in a subclass provides better clarity 342 | """ 343 | 344 | @classmethod 345 | def parse(cls, data) -> Optional[OWNEvent]: 346 | _match = re.match(r"^\*#?(?P\d+)\*.+##$", data) 347 | 348 | if _match: 349 | _who = int(_match.group("who")) 350 | 351 | if _who == 0: 352 | return OWNScenarioEvent(data) 353 | elif _who == 1: 354 | return OWNLightingEvent(data) 355 | elif _who == 2: 356 | return OWNAutomationEvent(data) 357 | elif _who == 4: 358 | return OWNHeatingEvent(data) 359 | elif _who == 5: 360 | return OWNAlarmEvent(data) 361 | elif _who == 9: 362 | return OWNAuxEvent(data) 363 | elif _who == 13: 364 | return OWNGatewayEvent(data) 365 | elif _who == 15: 366 | return OWNCENEvent(data) 367 | elif _who == 17: 368 | return OWNSceneEvent(data) 369 | elif _who == 18: 370 | return OWNEnergyEvent(data) 371 | elif _who == 25: 372 | _where = re.match(r"^\*.+\*(?P\d+)##$", data).group("where") 373 | if _where.startswith("2"): 374 | return OWNCENPlusEvent(data) 375 | elif _where.startswith("3"): 376 | return OWNDryContactEvent(data) 377 | elif _who > 1000: 378 | return cls(data) 379 | 380 | return None 381 | 382 | 383 | class OWNScenarioEvent(OWNEvent): 384 | def __init__(self, data): 385 | super().__init__(data) 386 | 387 | self._scenario = self._what 388 | self._control_panel = self._where 389 | self._human_readable_log = f"Scenario {self._scenario} from control panel {self._control_panel} has been launched." # pylint: disable=line-too-long 390 | 391 | @property 392 | def scenario(self): 393 | return self._scenario 394 | 395 | @property 396 | def control_panel(self): 397 | return self._control_panel 398 | 399 | 400 | class OWNLightingEvent(OWNEvent): 401 | def __init__(self, data): 402 | super().__init__(data) 403 | 404 | self._type = None 405 | self._state = None 406 | self._brightness = None 407 | self._brightness_preset = None 408 | self._transition = None 409 | self._timer = None 410 | self._blinker = None 411 | self._illuminance = None 412 | self._motion = False 413 | self._pir_sensitivity = None 414 | self._motion_timeout = None 415 | 416 | if self._what is not None and self._what != 1000: 417 | self._state = self._what 418 | 419 | if self._state == 0: # Light off 420 | self._human_readable_log = ( 421 | f"Light {self._where}{self._interface_log_text} is switched off." 422 | ) 423 | elif self._state == 1: # Light on 424 | self._human_readable_log = ( 425 | f"Light {self._where}{self._interface_log_text} is switched on." 426 | ) 427 | elif self._state > 1 and self._state < 11: # Light dimmed to preset value 428 | self._brightness_preset = self._state 429 | # self._brightness = self._state * 10 430 | self._human_readable_log = f"Light {self._where}{self._interface_log_text} is switched on at brightness level {self._state}." # pylint: disable=line-too-long 431 | elif self._state == 11: # Timer at 1m 432 | self._timer = 60 433 | self._human_readable_log = f"Light {self._where}{self._interface_log_text} is switched on for {self._timer}s." 434 | elif self._state == 12: # Timer at 2m 435 | self._timer = 120 436 | self._human_readable_log = f"Light {self._where}{self._interface_log_text} is switched on for {self._timer}s." 437 | elif self._state == 13: # Timer at 3m 438 | self._timer = 180 439 | self._human_readable_log = f"Light {self._where}{self._interface_log_text} is switched on for {self._timer}s." 440 | elif self._state == 14: # Timer at 4m 441 | self._timer = 240 442 | self._human_readable_log = f"Light {self._where}{self._interface_log_text} is switched on for {self._timer}s." 443 | elif self._state == 15: # Timer at 5m 444 | self._timer = 300 445 | self._human_readable_log = f"Light {self._where}{self._interface_log_text} is switched on for {self._timer}s." 446 | elif self._state == 16: # Timer at 15m 447 | self._timer = 900 448 | self._human_readable_log = f"Light {self._where}{self._interface_log_text} is switched on for {self._timer}s." 449 | elif self._state == 17: # Timer at 30s 450 | self._timer = 30 451 | self._human_readable_log = f"Light {self._where}{self._interface_log_text} is switched on for {self._timer}s." 452 | elif self._state == 18: # Timer at 0.5s 453 | self._timer = 0.5 454 | self._human_readable_log = f"Light {self._where}{self._interface_log_text} is switched on for {self._timer}s." 455 | elif self._state >= 20 and self._state <= 29: # Light blinking 456 | self._blinker = 0.5 * (self._state - 19) 457 | self._human_readable_log = f"Light {self._where}{self._interface_log_text} is blinking every {self._blinker}s." 458 | elif self._state == 34: # Motion detected 459 | self._type = MESSAGE_TYPE_MOTION 460 | self._motion = True 461 | self._human_readable_log = f"Light/motion sensor {self._where}{self._interface_log_text} detected motion" 462 | 463 | if self._dimension is not None: 464 | if self._dimension == 1 or self._dimension == 4: # Brightness value 465 | self._brightness = int(self._dimension_value[0]) - 100 466 | self._transition = int(self._dimension_value[1]) 467 | if self._brightness == 0: 468 | self._state = 0 469 | self._human_readable_log = f"Light {self._where}{self._interface_log_text} is switched off." 470 | else: 471 | self._state = 1 472 | self._human_readable_log = f"Light {self._where}{self._interface_log_text} is switched on at {self._brightness}%." 473 | elif self._dimension == 2: # Time value 474 | self._timer = ( 475 | int(self._dimension_value[0]) * 3600 476 | + int(self._dimension_value[1]) * 60 477 | + int(self._dimension_value[2]) 478 | ) 479 | self._human_readable_log = f"Light {self._where}{self._interface_log_text} is switched on for {self._timer}s." 480 | elif self._dimension == 5: # PIR sensitivity 481 | self._type = MESSAGE_TYPE_PIR_SENSITIVITY 482 | self._pir_sensitivity = int(self._dimension_value[0]) 483 | self._human_readable_log = f"Light/motion sensor {self._where}{self._interface_log_text} PIR sesitivity is {PIR_SENSITIVITY_MAPPING[self._pir_sensitivity]}." # pylint: disable=line-too-long 484 | elif self._dimension == 6: # Illuminance value 485 | self._type = MESSAGE_TYPE_ILLUMINANCE 486 | self._illuminance = int(self._dimension_value[0]) 487 | self._human_readable_log = f"Light/motion sensor {self._where}{self._interface_log_text} detected an illuminance value of {self._illuminance} lx." # pylint: disable=line-too-long 488 | elif self._dimension == 7: # Motion timeout value 489 | self._type = MESSAGE_TYPE_MOTION_TIMEOUT 490 | self._motion_timeout = datetime.timedelta( 491 | hours=int(self._dimension_value[0]), 492 | minutes=int(self._dimension_value[1]), 493 | seconds=int(self._dimension_value[2]), 494 | ) 495 | self._human_readable_log = f"Light/motion sensor {self._where}{self._interface_log_text} has timeout set to {self._motion_timeout}." # pylint: disable=line-too-long 496 | elif self._dimension_value is not None: 497 | self._human_readable_log = f"Light/motion sensor {self._where}{self._interface_log_text} has sent an unknown dimension {self._dimension}." 498 | else: 499 | pass 500 | 501 | @property 502 | def message_type(self): 503 | return self._type 504 | 505 | @property 506 | def brightness_preset(self): 507 | return self._brightness_preset 508 | 509 | @property 510 | def brightness(self): 511 | return self._brightness 512 | 513 | @property 514 | def transition(self): 515 | return self._transition 516 | 517 | @property 518 | def is_on(self) -> bool: 519 | return 0 < self._state < 32 520 | 521 | @property 522 | def timer(self): 523 | return self._timer 524 | 525 | @property 526 | def blinker(self): 527 | return self._blinker 528 | 529 | @property 530 | def illuminance(self): 531 | return self._illuminance 532 | 533 | @property 534 | def motion(self) -> bool: 535 | return self._motion 536 | 537 | @property 538 | def pir_sensitivity(self): 539 | return self._pir_sensitivity 540 | 541 | @property 542 | def motion_timeout(self) -> datetime.timedelta: 543 | return self._motion_timeout 544 | 545 | 546 | class OWNAutomationEvent(OWNEvent): 547 | def __init__(self, data): 548 | super().__init__(data) 549 | 550 | self._state = None 551 | self._position = None 552 | self._priority = None 553 | self._info = None 554 | self._is_opening = None 555 | self._is_closing = None 556 | self._is_closed = None 557 | 558 | if self._what is not None and self._what != 1000: 559 | self._state = self._what 560 | 561 | if self._dimension is not None: 562 | if self._dimension == 10: 563 | self._state = int(self._dimension_value[0]) 564 | self._position = int(self._dimension_value[1]) 565 | self._priority = int(self._dimension_value[2]) 566 | self._info = int(self._dimension_value[3]) 567 | 568 | if self._state == 0: 569 | self._human_readable_log = ( 570 | f"Cover {self._where}{self._interface_log_text} stopped." 571 | ) 572 | self._is_opening = False 573 | self._is_closing = False 574 | elif self._state == 10: 575 | self._is_opening = False 576 | self._is_closing = False 577 | if self._position == 0: 578 | self._human_readable_log = ( 579 | f"Cover {self._where}{self._interface_log_text} is closed." 580 | ) 581 | self._is_closed = True 582 | else: 583 | self._human_readable_log = f"Cover {self._where}{self._interface_log_text} is opened at {self._position}%." 584 | self._is_closed = False 585 | else: 586 | if self._state == 1: 587 | self._human_readable_log = ( 588 | f"Cover {self._where}{self._interface_log_text} is opening." 589 | ) 590 | self._is_opening = True 591 | self._is_closing = False 592 | elif self._state == 11 or self._state == 13: 593 | self._human_readable_log = f"Cover {self._where}{self._interface_log_text} is opening from initial position {self._position}." # pylint: disable=line-too-long 594 | self._is_opening = True 595 | self._is_closing = False 596 | self._is_closed = False 597 | elif self._state == 2: 598 | self._human_readable_log = ( 599 | f"Cover {self._where}{self._interface_log_text} is closing." 600 | ) 601 | self._is_closing = True 602 | self._is_opening = False 603 | elif self._state == 12 or self._state == 14: 604 | self._human_readable_log = f"Cover {self._where}{self._interface_log_text} is closing from initial position {self._position}." # pylint: disable=line-too-long 605 | self._is_closing = True 606 | self._is_opening = False 607 | self._is_closed = False 608 | 609 | @property 610 | def state(self): 611 | return self._state 612 | 613 | @property 614 | def is_opening(self): 615 | return self._is_opening 616 | 617 | @property 618 | def is_closing(self): 619 | return self._is_closing 620 | 621 | @property 622 | def is_closed(self): 623 | return self._is_closed 624 | 625 | @property 626 | def current_position(self): 627 | return self._position 628 | 629 | 630 | class OWNHeatingEvent(OWNEvent): 631 | def __init__(self, data): 632 | super().__init__(data) 633 | 634 | self._type = None 635 | 636 | self._zone = ( 637 | int(self._where[1:]) if self._where.startswith("#") else int(self._where) 638 | ) 639 | if self._zone == 0 and self._where_param: 640 | self._zone = int(self._where_param[0]) 641 | self._sensor = None 642 | if self._zone > 99: 643 | self._sensor = int(str(self._zone)[:1]) 644 | self._zone = int(str(self._zone)[1:]) 645 | self._actuator = None 646 | 647 | self._mode = None 648 | self._mode_name = None 649 | self._set_temperature = None 650 | self._local_offset = None 651 | self._local_set_temperature = None 652 | self._measured_temperature = None 653 | self._secondary_temperature = None 654 | self._measured_humidity = None 655 | 656 | self._is_active = None 657 | self._is_heating = None 658 | self._is_cooling = None 659 | 660 | self._fan_on = None 661 | self._fan_speed = None 662 | self._cooling_fan_on = None 663 | self._cooling_fan_speed = None 664 | 665 | _valve_active_states = ["1", "2", "6", "7", "8"] 666 | _actuator_active_states = ["1", "2", "6", "7", "8", "9"] 667 | 668 | if self._what is not None: 669 | self._mode = int(self._what) 670 | if self._mode in [103, 203, 303, 102, 202, 302]: 671 | self._type = MESSAGE_TYPE_MODE 672 | self._mode_name = CLIMATE_MODE_OFF 673 | self._human_readable_log = ( 674 | f"Zone {self._zone}'s mode is set to '{self._mode_name}'" 675 | ) 676 | elif ( 677 | self._mode in [0, 210, 211, 215] 678 | or (self._mode >= 2101 and self._mode <= 2103) 679 | or (self._mode >= 2201 and self._mode <= 2216) 680 | ): 681 | self._type = MESSAGE_TYPE_MODE 682 | self._mode_name = CLIMATE_MODE_COOL 683 | self._human_readable_log = ( 684 | f"Zone {self._zone}'s mode is set to '{self._mode_name}'" 685 | ) 686 | elif ( 687 | self._mode in [1, 110, 111, 115] 688 | or (self._mode >= 1101 and self._mode <= 1103) 689 | or (self._mode >= 1201 and self._mode <= 1216) 690 | ): 691 | self._type = MESSAGE_TYPE_MODE 692 | self._mode_name = CLIMATE_MODE_HEAT 693 | self._human_readable_log = ( 694 | f"Zone {self._zone}'s mode is set to '{self._mode_name}'" 695 | ) 696 | elif ( 697 | self._mode in [310, 311, 315] 698 | or (self._mode >= 23001 and self._mode <= 23255) 699 | or (self._mode >= 13001 and self._mode <= 13255) 700 | ): 701 | self._type = MESSAGE_TYPE_MODE 702 | self._mode_name = CLIMATE_MODE_AUTO 703 | self._human_readable_log = ( 704 | f"Zone {self._zone}'s mode is set to '{self._mode_name}'" 705 | ) 706 | elif self._mode == 20: 707 | self._mode_name = None 708 | self._human_readable_log = ( 709 | f"Zone {self._zone}'s remote control is disabled" 710 | ) 711 | elif self._mode == 21: 712 | self._mode_name = None 713 | self._human_readable_log = ( 714 | f"Zone {self._zone}'s remote control is enabled" 715 | ) 716 | else: 717 | self._mode_name = None 718 | self._human_readable_log = f"Zone {self._zone}'s mode is unknown" 719 | 720 | if ( 721 | self._type == MESSAGE_TYPE_MODE 722 | and self._what_param 723 | and self._what_param[0] is not None 724 | ): 725 | self._type = MESSAGE_TYPE_MODE_TARGET 726 | self._set_temperature = float( 727 | f"{self._what_param[0][1:3]}.{self._what_param[0][-1]}" 728 | ) 729 | self._human_readable_log += f" at {self._set_temperature}°C." 730 | else: 731 | self._human_readable_log += "." 732 | 733 | if self._dimension == 0: # Temperature 734 | if self._sensor is None: 735 | self._type = MESSAGE_TYPE_MAIN_TEMPERATURE 736 | self._measured_temperature = float( 737 | f"{self._dimension_value[0][1:3]}.{self._dimension_value[0][-1]}" 738 | ) 739 | self._human_readable_log = f"Zone {self._zone}'s main sensor is reporting a temperature of {self._measured_temperature}°C." # pylint: disable=line-too-long 740 | else: 741 | self._type = MESSAGE_TYPE_SECONDARY_TEMPERATURE 742 | self._secondary_temperature = float( 743 | f"{self._dimension_value[0][1:3]}.{self._dimension_value[0][-1]}" 744 | ) 745 | self._human_readable_log = f"Zone {self._zone}'s secondary sensor {self._sensor} is reporting a temperature of {self._secondary_temperature}°C." # pylint: disable=line-too-long 746 | 747 | elif self._dimension == 11: # Fan speed 748 | _fan_mode = int(self._dimension_value[0]) 749 | if _fan_mode < 4: 750 | self._fan_on = True 751 | self._is_active = True 752 | if _fan_mode > 0: 753 | self._fan_speed = _fan_mode 754 | self._human_readable_log = ( 755 | f"Zone {self._zone}'s fan is on at speed {self._fan_speed}." 756 | ) 757 | else: 758 | self._human_readable_log = ( 759 | f"Zone {self._zone}'s fan is on at 'Auto' speed." 760 | ) 761 | else: 762 | self._fan_on = False 763 | self._is_active = False 764 | self._human_readable_log = f"Zone {self._zone}'s fan is off." 765 | 766 | elif self._dimension == 12: # Local set temperature (set+offset) 767 | self._type = MESSAGE_TYPE_LOCAL_TARGET_TEMPERATURE 768 | self._local_set_temperature = float( 769 | f"{self._dimension_value[0][1:3]}.{self._dimension_value[0][-1]}" 770 | ) 771 | self._human_readable_log = f"Zone {self._zone}'s local target temperature is set to {self._local_set_temperature}°C." # pylint: disable=line-too-long 772 | 773 | elif self._dimension == 13: # Local offset 774 | self._type = MESSAGE_TYPE_LOCAL_OFFSET 775 | if ( 776 | self._dimension_value[0] == "0" 777 | or self._dimension_value[0] == "00" 778 | or self._dimension_value[0] == "4" 779 | or self._dimension_value[0] == "5" 780 | or self._dimension_value[0] == "6" 781 | or self._dimension_value[0] == "7" 782 | or self._dimension_value[0] == "8" 783 | ): 784 | self._local_offset = 0 785 | elif self._dimension_value[0].startswith("0"): 786 | self._local_offset = int(f"{self._dimension_value[0][1:]}") 787 | else: 788 | self._local_offset = -int(f"{self._dimension_value[0][1:]}") 789 | self._human_readable_log = ( 790 | f"Zone {self._zone}'s local offset is set to {self._local_offset}°C." 791 | ) 792 | 793 | elif self._dimension == 14: # Set temperature 794 | self._type = MESSAGE_TYPE_TARGET_TEMPERATURE 795 | self._set_temperature = float( 796 | f"{self._dimension_value[0][1:3]}.{self._dimension_value[0][-1]}" 797 | ) 798 | self._human_readable_log = f"Zone {self._zone}'s target temperature is set to {self._set_temperature}°C." # pylint: disable=line-too-long 799 | 800 | elif self._dimension == 19: # Valves status 801 | self._type = MESSAGE_TYPE_ACTION 802 | self._is_cooling = self._dimension_value[0] in _valve_active_states 803 | self._is_heating = self._dimension_value[1] in _valve_active_states 804 | self._is_active = self._is_cooling | self._is_heating 805 | # Handle cooling valve status relative to fan speed/status 806 | _cooling_value = int(self._dimension_value[0]) 807 | if _cooling_value == 0: 808 | self._human_readable_log = f"Zone {self._zone}'s cooling valve is off" 809 | elif _cooling_value == 1: 810 | self._human_readable_log = f"Zone {self._zone}'s cooling valve is on" 811 | elif _cooling_value == 2: 812 | self._human_readable_log = ( 813 | f"Zone {self._zone}'s cooling valve is opened" 814 | ) 815 | elif _cooling_value == 3: 816 | self._human_readable_log = ( 817 | f"Zone {self._zone}'s cooling valve is closed" 818 | ) 819 | elif _cooling_value == 4: 820 | self._human_readable_log = ( 821 | f"Zone {self._zone}'s cooling valve is stopped" 822 | ) 823 | elif _cooling_value > 4: 824 | _fan_mode = _cooling_value - 5 825 | if _fan_mode > 0: 826 | self._cooling_fan_on = True 827 | self._is_active = True 828 | self._cooling_fan_speed = _fan_mode 829 | self._human_readable_log = f"Zone {self._zone}'s cooling fan is on at speed {self._fan_speed}" # pylint: disable=line-too-long 830 | else: 831 | self._cooling_fan_on = False 832 | self._is_active = False 833 | self._human_readable_log = f"Zone {self._zone}'s cooling fan is off" 834 | # Handle heating valve status relative to fan speed/status 835 | _heating_value = int(self._dimension_value[1]) 836 | if _heating_value == 0: 837 | self._human_readable_log += "; heating valve is off." 838 | elif _heating_value == 1: 839 | self._human_readable_log += "; heating valve is on." 840 | elif _heating_value == 2: 841 | self._human_readable_log += "; heating valve is opened." 842 | elif _heating_value == 3: 843 | self._human_readable_log += "; heating valve is closed." 844 | elif _heating_value == 4: 845 | self._human_readable_log += "; heating valve is stopped." 846 | elif _heating_value > 4: 847 | _fan_mode = _heating_value - 5 848 | if _fan_mode > 0: 849 | self._fan_on = True 850 | self._is_active = True 851 | self._fan_speed = _fan_mode 852 | self._human_readable_log += ( 853 | f"; heating fan is on at speed {self._fan_speed}." 854 | ) 855 | else: 856 | self._fan_on = False 857 | self._is_active = False 858 | self._human_readable_log += "; heating fan is off." 859 | 860 | elif self._dimension == 20: # Actuator status 861 | self._type = MESSAGE_TYPE_ACTION 862 | self._is_active = self._dimension_value[0] in _actuator_active_states 863 | self._actuator = ( 864 | self._where_param[0] if self._where_param[0] is not None else 1 865 | ) 866 | _value = int(self._dimension_value[0]) 867 | if _value == 0: 868 | self._human_readable_log = ( 869 | f"Zone {self._zone}'s actuator {self._actuator} is off." 870 | ) 871 | elif _value == 1: 872 | self._human_readable_log = ( 873 | f"Zone {self._zone}'s actuator {self._actuator} is on." 874 | ) 875 | elif _value == 2: 876 | self._human_readable_log = ( 877 | f"Zone {self._zone}'s actuator {self._actuator} is opened." 878 | ) 879 | elif _value == 3: 880 | self._human_readable_log = ( 881 | f"Zone {self._zone}'s actuator {self._actuator} is closed." 882 | ) 883 | elif _value == 4: 884 | self._human_readable_log = ( 885 | f"Zone {self._zone}'s actuator {self._actuator} is stopped." 886 | ) 887 | elif _value > 4: 888 | _fan_mode = _value - 5 889 | if _fan_mode > 0: 890 | self._fan_on = True 891 | self._is_active = True 892 | if _fan_mode < 4: 893 | self._fan_speed = _fan_mode 894 | self._human_readable_log = ( 895 | f"Zone {self._zone}'s fan is on at speed {self._fan_speed}." 896 | ) 897 | else: 898 | self._human_readable_log = ( 899 | f"Zone {self._zone}'s fan is on at 'Auto' speed." 900 | ) 901 | else: 902 | self._fan_on = False 903 | self._is_active = False 904 | self._human_readable_log = f"Zone {self._zone}'s fan is off." 905 | 906 | elif self._dimension == 60: # Humidity 907 | self._type = MESSAGE_TYPE_MAIN_HUMIDITY 908 | self._measured_humidity = float(self._dimension_value[0]) 909 | self._human_readable_log = f"Zone {self._zone}'s main sensor is reporting a humidity of {self._measured_humidity}%." # pylint: disable=line-too-long 910 | 911 | @property 912 | def unique_id(self) -> str: 913 | """The ID of the subject of this message""" 914 | if self._zone == 0: 915 | return f"{self._who}-#0" 916 | elif self._sensor is not None: 917 | return f"{self._who}-{self._where}" 918 | else: 919 | return f"{self._who}-{self._zone}" 920 | 921 | @property 922 | def message_type(self): 923 | return self._type 924 | 925 | @property 926 | def zone(self) -> int: 927 | return self._zone 928 | 929 | @property 930 | def mode(self) -> str: 931 | return self._mode_name 932 | 933 | def is_active(self) -> bool: 934 | return self._is_active 935 | 936 | def is_heating(self) -> bool: 937 | return self._is_heating 938 | 939 | def is_cooling(self) -> bool: 940 | return self._is_cooling 941 | 942 | @property 943 | def main_temperature(self) -> float: 944 | return self._measured_temperature 945 | 946 | @property 947 | def main_humidity(self) -> float: 948 | return self._measured_humidity 949 | 950 | @property 951 | def secondary_temperature(self): 952 | return [self._sensor, self._secondary_temperature] 953 | 954 | @property 955 | def set_temperature(self) -> float: 956 | return self._set_temperature 957 | 958 | @property 959 | def local_offset(self) -> int: 960 | return self._local_offset 961 | 962 | @property 963 | def local_set_temperature(self) -> float: 964 | return self._local_set_temperature 965 | 966 | 967 | class OWNAlarmEvent(OWNEvent): 968 | def __init__(self, data): 969 | super().__init__(data) 970 | 971 | self._state_code = int(self._what) 972 | self._state = None 973 | self._system = False 974 | self._zone = None 975 | self._sensor = None 976 | 977 | if self._where == "*": 978 | self._system = True 979 | self._human_readable_log = "System is reporting: " 980 | elif self._where.startswith("#"): 981 | self._zone = self._where[1:] 982 | if self._zone == "12": 983 | self._zone = "c" 984 | elif self._zone == "15": 985 | self._zone = "f" 986 | self._human_readable_log = f"Zone {self._zone} is reporting: " 987 | elif len(self._where) > 1: 988 | self._zone = int(self._where[0]) 989 | self._sensor = int(self._where[1:]) 990 | if self._zone == 0: 991 | self._human_readable_log = ( 992 | f"Device {self._sensor} in input zone is reporting: " 993 | ) 994 | else: 995 | self._human_readable_log = ( 996 | f"Sensor {self._sensor} in zone {self._zone} is reporting: " 997 | ) 998 | else: 999 | self._system = True 1000 | self._human_readable_log = "Control panel is reporting: " 1001 | 1002 | if self._state_code == 0: 1003 | self._state = "maintenance" 1004 | elif self._state_code == 1: 1005 | self._state = "activation" 1006 | elif self._state_code == 2: 1007 | self._state = "deactivation" 1008 | elif self._state_code == 3: 1009 | self._state = "delay end" 1010 | elif self._state_code == 4: 1011 | self._state = "system battery fault" 1012 | elif self._state_code == 5: 1013 | self._state = "battery ok" 1014 | elif self._state_code == 6: 1015 | self._state = "no network" 1016 | elif self._state_code == 7: 1017 | self._state = "network present" 1018 | elif self._state_code == 8: 1019 | self._state = "engage" 1020 | elif self._state_code == 9: 1021 | self._state = "disengage" 1022 | elif self._state_code == 10: 1023 | self._state = "battery unloads" 1024 | elif self._state_code == 11: 1025 | self._state = "active zone" 1026 | elif self._state_code == 12: 1027 | self._state = "technical alarm" 1028 | elif self._state_code == 13: 1029 | self._state = "reset technical alarm" 1030 | elif self._state_code == 14: 1031 | self._state = "no reception" 1032 | elif self._state_code == 15: 1033 | self._state = "intrusion alarm" 1034 | elif self._state_code == 16: 1035 | self._state = "tampering" 1036 | elif self._state_code == 17: 1037 | self._state = "anti-panic alarm" 1038 | elif self._state_code == 18: 1039 | self._state = "non-active zone" 1040 | elif self._state_code == 26: 1041 | self._state = "start programming" 1042 | elif self._state_code == 27: 1043 | self._state = "stop programming" 1044 | elif self._state_code == 31: 1045 | self._state = "silent alarm" 1046 | 1047 | self._human_readable_log = f"{self._human_readable_log}'{self._state}'." 1048 | 1049 | @property 1050 | def general(self): 1051 | return self._system 1052 | 1053 | @property 1054 | def zone(self): 1055 | return self._zone 1056 | 1057 | @property 1058 | def sensor(self): 1059 | return self._sensor 1060 | 1061 | @property 1062 | def is_active(self): 1063 | return self._state_code == 1 or self._state_code == 11 1064 | 1065 | @property 1066 | def is_engaged(self): 1067 | return self._state_code == 8 1068 | 1069 | @property 1070 | def is_alarm(self): 1071 | return ( 1072 | self._state_code == 12 1073 | or self._state_code == 15 1074 | or self._state_code == 16 1075 | or self._state_code == 17 1076 | or self._state_code == 31 1077 | ) 1078 | 1079 | 1080 | class OWNAuxEvent(OWNEvent): 1081 | def __init__(self, data): 1082 | super().__init__(data) 1083 | 1084 | self._channel = self._where 1085 | 1086 | self._state = self._what 1087 | if self._state == 0: 1088 | self._human_readable_log = ( 1089 | f"Auxilliary channel {self._channel} is set to 'OFF'." 1090 | ) 1091 | elif self._state == 1: 1092 | self._human_readable_log = ( 1093 | f"Auxilliary channel {self._channel} is set to 'ON'." 1094 | ) 1095 | elif self._state == 2: 1096 | self._human_readable_log = ( 1097 | f"Auxilliary channel {self._channel} is set to 'TOGGLE'." 1098 | ) 1099 | elif self._state == 3: 1100 | self._human_readable_log = ( 1101 | f"Auxilliary channel {self._channel} is set to 'STOP'." 1102 | ) 1103 | elif self._state == 4: 1104 | self._human_readable_log = ( 1105 | f"Auxilliary channel {self._channel} is set to 'UP'." 1106 | ) 1107 | elif self._state == 5: 1108 | self._human_readable_log = ( 1109 | f"Auxilliary channel {self._channel} is set to 'DOWN'." 1110 | ) 1111 | elif self._state == 6: 1112 | self._human_readable_log = ( 1113 | f"Auxilliary channel {self._channel} is set to 'ENABLED'." 1114 | ) 1115 | elif self._state == 7: 1116 | self._human_readable_log = ( 1117 | f"Auxilliary channel {self._channel} is set to 'DISABLED'." 1118 | ) 1119 | elif self._state == 8: 1120 | self._human_readable_log = ( 1121 | f"Auxilliary channel {self._channel} is set to 'RESET_GEN'." 1122 | ) 1123 | elif self._state == 9: 1124 | self._human_readable_log = ( 1125 | f"Auxilliary channel {self._channel} is set to 'RESET_BI'." 1126 | ) 1127 | elif self._state == 10: 1128 | self._human_readable_log = ( 1129 | f"Auxilliary channel {self._channel} is set to 'RESET_TRI'." 1130 | ) 1131 | 1132 | @property 1133 | def channel(self): 1134 | return self._channel 1135 | 1136 | @property 1137 | def state_code(self): 1138 | return self._state 1139 | 1140 | @property 1141 | def is_on(self): 1142 | return self._state == 1 1143 | 1144 | 1145 | class OWNGatewayEvent(OWNEvent): 1146 | def __init__(self, data): 1147 | super().__init__(data) 1148 | 1149 | self._year = None 1150 | self._month = None 1151 | self._day = None 1152 | self._hour = None 1153 | self._minute = None 1154 | self._second = None 1155 | self._timezone = None 1156 | 1157 | self._time = None 1158 | self._date = None 1159 | self._datetime = None 1160 | 1161 | self._ip_address = None 1162 | self._netmask = None 1163 | self._mac_address = None 1164 | 1165 | self._device_type = None 1166 | self._firmware_version = None 1167 | 1168 | self._uptime = None 1169 | 1170 | self._kernel_version = None 1171 | self._distribution_version = None 1172 | 1173 | if self._dimension == 0: 1174 | self._hour = self._dimension_value[0] 1175 | self._minute = self._dimension_value[1] 1176 | self._second = self._dimension_value[2] 1177 | # Timezone is sometimes missing from messages, assuming UTC 1178 | if self._dimension_value[3] != "": 1179 | self._timezone = ( 1180 | f"+{self._dimension_value[3][1:]}:00" 1181 | if self._dimension_value[3][0] == "0" 1182 | else f"-{self._dimension_value[3][1:]}:00" 1183 | ) 1184 | else: 1185 | self._timezone = "" 1186 | self._human_readable_log = f"Gateway's internal time is: {self._hour}:{self._minute}:{self._second} UTC {self._timezone}." # pylint: disable=line-too-long 1187 | 1188 | elif self._dimension == 1: 1189 | self._year = self._dimension_value[3] 1190 | self._month = self._dimension_value[2] 1191 | self._day = self._dimension_value[1] 1192 | self._date = datetime.date( 1193 | year=int(self._year), month=int(self._month), day=int(self._day) 1194 | ) 1195 | self._human_readable_log = ( 1196 | f"Gateway's internal date is: {self._year}-{self._month}-{self._day}." 1197 | ) 1198 | 1199 | elif self._dimension == 10: 1200 | self._ip_address = f"{self._dimension_value[0]}.{self._dimension_value[1]}.{self._dimension_value[2]}.{self._dimension_value[3]}" # pylint: disable=line-too-long 1201 | self._human_readable_log = f"Gateway's IP address is: {self._ip_address}." 1202 | 1203 | elif self._dimension == 11: 1204 | self._netmask = f"{self._dimension_value[0]}.{self._dimension_value[1]}.{self._dimension_value[2]}.{self._dimension_value[3]}" # pylint: disable=line-too-long 1205 | self._human_readable_log = f"Gateway's netmask is: {self._netmask}." 1206 | 1207 | elif self._dimension == 12: 1208 | self._mac_address = f"{int(self._dimension_value[0]):02x}:{int(self._dimension_value[1]):02x}:{int(self._dimension_value[2]):02x}:{int(self._dimension_value[3]):02x}:{int(self._dimension_value[4]):02x}:{int(self._dimension_value[5]):02x}" # pylint: disable=line-too-long 1209 | self._human_readable_log = f"Gateway's MAC address is: {self._mac_address}." 1210 | 1211 | elif self._dimension == 15: 1212 | if self._dimension_value[0] == "2": 1213 | self._device_type = "MHServer" 1214 | elif self._dimension_value[0] == "4": 1215 | self._device_type = "MH200" 1216 | elif self._dimension_value[0] == "6": 1217 | self._device_type = "F452" 1218 | elif self._dimension_value[0] == "7": 1219 | self._device_type = "F452V" 1220 | elif self._dimension_value[0] == "11": 1221 | self._device_type = "MHServer2" 1222 | elif self._dimension_value[0] == "13": 1223 | self._device_type = "H4684" 1224 | elif self._dimension_value[0] == "200": 1225 | self._device_type = "F454" 1226 | else: 1227 | self._device_type = f"Unknown ({self._dimension_value[0]})" 1228 | self._human_readable_log = f"Gateway device type is: {self._device_type}." 1229 | 1230 | elif self._dimension == 16: 1231 | self._firmware_version = f"{self._dimension_value[0]}.{self._dimension_value[1]}.{self._dimension_value[2]}" # pylint: disable=line-too-long 1232 | self._human_readable_log = ( 1233 | f"Gateway's firmware version is: {self._firmware_version}." 1234 | ) 1235 | 1236 | elif self._dimension == 19: 1237 | self._uptime = datetime.timedelta( 1238 | days=int(self._dimension_value[0]), 1239 | hours=int(self._dimension_value[1]), 1240 | minutes=int(self._dimension_value[2]), 1241 | seconds=int(self._dimension_value[3]), 1242 | ) 1243 | self._human_readable_log = f"Gateway's uptime is: {self._uptime}." 1244 | 1245 | elif self._dimension == 22: 1246 | self._hour = self._dimension_value[0] 1247 | self._minute = self._dimension_value[1] 1248 | self._second = self._dimension_value[2] 1249 | # Timezone is sometimes missing from messages, assuming UTC 1250 | if self._dimension_value[3] != "": 1251 | self._timezone = ( 1252 | f"+{self._dimension_value[3][1:]}:00" 1253 | if self._dimension_value[3][0] == "0" 1254 | else f"-{self._dimension_value[3][1:]}:00" 1255 | ) 1256 | else: 1257 | self._timezone = "" 1258 | self._day = self._dimension_value[5] 1259 | self._month = self._dimension_value[6] 1260 | self._year = self._dimension_value[7] 1261 | self._datetime = datetime.datetime.fromisoformat( 1262 | f"{self._year}-{self._month}-{self._day}*{self._hour}:{self._minute}:{self._second}{self._timezone}" # pylint: disable=line-too-long 1263 | ) 1264 | self._human_readable_log = ( 1265 | f"Gateway's internal datetime is: {self._datetime}." 1266 | ) 1267 | 1268 | elif self._dimension == 23: 1269 | self._kernel_version = f"{self._dimension_value[0]}.{self._dimension_value[1]}.{self._dimension_value[2]}" # pylint: disable=line-too-long 1270 | self._human_readable_log = ( 1271 | f"Gateway's kernel version is: {self._kernel_version}." 1272 | ) 1273 | 1274 | elif self._dimension == 24: 1275 | self._distribution_version = f"{self._dimension_value[0]}.{self._dimension_value[1]}.{self._dimension_value[2]}" # pylint: disable=line-too-long 1276 | self._human_readable_log = ( 1277 | f"Gateway's distribution version is: {self._distribution_version}." 1278 | ) 1279 | 1280 | 1281 | class OWNCENEvent(OWNEvent): 1282 | def __init__(self, data): 1283 | super().__init__(data) 1284 | 1285 | try: 1286 | self._state = self._what_param[0] 1287 | except IndexError: 1288 | self._state = None 1289 | self.push_button = self._what 1290 | self.object = self._where 1291 | 1292 | if self._state is None: 1293 | self._human_readable_log = f"Button {self.push_button} of CEN object {self.object}{self._interface_log_text} has been pressed." # pylint: disable=line-too-long 1294 | elif int(self._state) == 3: 1295 | self._human_readable_log = f"Button {self.push_button} of CEN object {self.object}{self._interface_log_text} is being held pressed." # pylint: disable=line-too-long 1296 | elif int(self._state) == 1: 1297 | self._human_readable_log = f"Button {self.push_button} of CEN object {self.object}{self._interface_log_text} has been released after a short press." # pylint: disable=line-too-long 1298 | elif int(self._state) == 2: 1299 | self._human_readable_log = f"Button {self.push_button} of CEN object {self.object}{self._interface_log_text} has been released after a long press." # pylint: disable=line-too-long 1300 | 1301 | @property 1302 | def is_pressed(self): 1303 | return self._state is None 1304 | 1305 | @property 1306 | def is_held(self): 1307 | return int(self._state) == 3 1308 | 1309 | @property 1310 | def is_released_after_short_press(self): 1311 | return int(self._state) == 1 1312 | 1313 | @property 1314 | def is_released_after_long_press(self): 1315 | return int(self._state) == 2 1316 | 1317 | 1318 | class OWNSceneEvent(OWNEvent): 1319 | def __init__(self, data): 1320 | super().__init__(data) 1321 | 1322 | self._scene = self._where 1323 | self._state = self._what 1324 | 1325 | if self._state == 1: 1326 | _status = "started" 1327 | elif self._state == 2: 1328 | _status = "stoped" 1329 | elif self._state == 3: 1330 | _status = "enabled" 1331 | elif self._state == 4: 1332 | _status = "disabled" 1333 | else: 1334 | _status = f"unknonwn ({self._state})" 1335 | 1336 | self._human_readable_log = f"Scene {self._scene} is {_status}." 1337 | 1338 | @property 1339 | def scenario(self): 1340 | return self._scene 1341 | 1342 | @property 1343 | def state(self): 1344 | return self._state 1345 | 1346 | @property 1347 | def is_on(self): 1348 | if self._state == 1: 1349 | return True 1350 | elif self._state == 2: 1351 | return False 1352 | else: 1353 | return None 1354 | 1355 | @property 1356 | def is_enabled(self): 1357 | if self._state == 3: 1358 | return True 1359 | elif self._state == 4: 1360 | return False 1361 | else: 1362 | return None 1363 | 1364 | 1365 | class OWNEnergyEvent(OWNEvent): 1366 | def __init__(self, data): 1367 | super().__init__(data) 1368 | 1369 | if not self._where.startswith("5") and not self._where.startswith("7"): 1370 | return None 1371 | 1372 | self._type = None 1373 | self._sensor = self._where[1:] 1374 | self._active_power = 0 1375 | self._total_consumption = 0 1376 | self._hourly_consumption = dict() 1377 | self._daily_consumption = dict() 1378 | self._current_day_partial_consumption = 0 1379 | self._monthly_consumption = dict() 1380 | self._current_month_partial_consumption = 0 1381 | 1382 | if self._dimension is not None: 1383 | if self._dimension == 113: 1384 | self._type = MESSAGE_TYPE_ACTIVE_POWER 1385 | self._active_power = int(self._dimension_value[0]) 1386 | self._human_readable_log = f"Sensor {self._sensor} is reporting an active power draw of {self._active_power} W." # pylint: disable=line-too-long 1387 | elif self._dimension == 511: 1388 | _now = datetime.date.today() 1389 | _raw_message_date = datetime.date( 1390 | _now.year, 1391 | int(self._dimension_param[0]), 1392 | int(self._dimension_param[1]), 1393 | ) 1394 | try: 1395 | if _raw_message_date > _now: 1396 | _message_date = datetime.date( 1397 | _now.year - 1, 1398 | int(self._dimension_param[0]), 1399 | int(self._dimension_param[1]), 1400 | ) 1401 | else: 1402 | _message_date = datetime.date( 1403 | _now.year, 1404 | int(self._dimension_param[0]), 1405 | int(self._dimension_param[1]), 1406 | ) 1407 | except ValueError: 1408 | return None 1409 | 1410 | if int(self._dimension_value[0]) != 25: 1411 | self._type = MESSAGE_TYPE_HOURLY_CONSUMPTION 1412 | self._hourly_consumption["date"] = _message_date 1413 | self._hourly_consumption["hour"] = int(self._dimension_value[0]) - 1 1414 | self._hourly_consumption["value"] = int(self._dimension_value[1]) 1415 | self._human_readable_log = f"Sensor {self._sensor} is reporting a power consumption of {self._hourly_consumption['value']} Wh for {self._hourly_consumption['date']} at {self._hourly_consumption['hour']}." # pylint: disable=line-too-long 1416 | else: 1417 | self._type = MESSAGE_TYPE_DAILY_CONSUMPTION 1418 | self._daily_consumption["date"] = _message_date 1419 | self._daily_consumption["value"] = int(self._dimension_value[1]) 1420 | self._human_readable_log = f"Sensor {self._sensor} is reporting a power consumption of {self._daily_consumption['value']} Wh for {self._daily_consumption['date']}." # pylint: disable=line-too-long 1421 | elif self._dimension == 513 or self._dimension == 514: 1422 | _now = datetime.date.today() 1423 | _raw_message_date = datetime.date( 1424 | _now.year, int(self._dimension_param[0]), 1 1425 | ) 1426 | try: 1427 | if self._dimension == 513 and _raw_message_date > _now: 1428 | _message_date = datetime.date( 1429 | _now.year - 1, 1430 | int(self._dimension_param[0]), 1431 | int(self._dimension_value[0]), 1432 | ) 1433 | elif self._dimension == 514: 1434 | if _raw_message_date > _now: 1435 | _message_date = datetime.date( 1436 | _now.year - 2, 1437 | int(self._dimension_param[0]), 1438 | int(self._dimension_value[0]), 1439 | ) 1440 | else: 1441 | _message_date = datetime.date( 1442 | _now.year - 1, 1443 | int(self._dimension_param[0]), 1444 | int(self._dimension_value[0]), 1445 | ) 1446 | else: 1447 | _message_date = datetime.date( 1448 | _now.year, 1449 | int(self._dimension_param[0]), 1450 | int(self._dimension_value[0]), 1451 | ) 1452 | except ValueError: 1453 | return None 1454 | self._type = MESSAGE_TYPE_DAILY_CONSUMPTION 1455 | self._daily_consumption["date"] = _message_date 1456 | self._daily_consumption["value"] = int(self._dimension_value[1]) 1457 | self._human_readable_log = f"Sensor {self._sensor} is reporting a power consumption of {self._daily_consumption['value']} Wh for {self._daily_consumption['date']}." # pylint: disable=line-too-long 1458 | elif self._dimension == 51: 1459 | self._type = MESSAGE_TYPE_ENERGY_TOTALIZER 1460 | self._total_consumption = int(self._dimension_value[0]) 1461 | self._human_readable_log = f"Sensor {self._sensor} is reporting a total power consumption of {self._total_consumption} Wh." # pylint: disable=line-too-long 1462 | elif self._dimension == 54: 1463 | self._type = MESSAGE_TYPE_CURRENT_DAY_CONSUMPTION 1464 | self._current_day_partial_consumption = int(self._dimension_value[0]) 1465 | self._human_readable_log = f"Sensor {self._sensor} is reporting a power consumption of {self._current_day_partial_consumption} Wh up to now today." # pylint: disable=line-too-long 1466 | elif self._dimension == 52: 1467 | self._type = MESSAGE_TYPE_MONTHLY_CONSUMPTION 1468 | _message_date = datetime.date( 1469 | int(f"20{self._dimension_param[0]}"), self._dimension_param[1], 1 1470 | ) 1471 | self._monthly_consumption["date"] = _message_date 1472 | self._monthly_consumption["value"] = int(self._dimension_value[0]) 1473 | self._human_readable_log = f"Sensor {self._sensor} is reporting a power consumption of {self._monthly_consumption['value']} Wh for {self._monthly_consumption['date'].strftime('%B %Y')}." # pylint: disable=line-too-long 1474 | elif self._dimension == 53: 1475 | self._type = MESSAGE_TYPE_CURRENT_MONTH_CONSUMPTION 1476 | self._current_month_partial_consumption = int(self._dimension_value[0]) 1477 | self._human_readable_log = f"Sensor {self._sensor} is reporting a power consumption of {self._current_month_partial_consumption} Wh up to now this month." # pylint: disable=line-too-long 1478 | 1479 | @property 1480 | def message_type(self): 1481 | return self._type 1482 | 1483 | @property 1484 | def active_power(self): 1485 | return self._active_power 1486 | 1487 | @property 1488 | def total_consumption(self): 1489 | return self._total_consumption 1490 | 1491 | @property 1492 | def hourly_consumption(self): 1493 | return self._hourly_consumption 1494 | 1495 | @property 1496 | def daily_consumption(self): 1497 | return self._daily_consumption 1498 | 1499 | @property 1500 | def current_day_partial_consumption(self): 1501 | return self._current_day_partial_consumption 1502 | 1503 | @property 1504 | def monthly_consumption(self): 1505 | return self._monthly_consumption 1506 | 1507 | @property 1508 | def current_month_partial_consumption(self): 1509 | return self._current_month_partial_consumption 1510 | 1511 | @property 1512 | def human_readable_log(self): 1513 | return self._human_readable_log 1514 | 1515 | 1516 | class OWNDryContactEvent(OWNEvent): 1517 | def __init__(self, data): 1518 | super().__init__(data) 1519 | 1520 | self._state = 1 if self._what == 31 else 0 1521 | self._detection = int(self._what_param[0]) 1522 | self._sensor = self._where[1:] 1523 | 1524 | if self._detection == 1: 1525 | self._human_readable_log = ( 1526 | f"Sensor {self._sensor} detected {'ON' if self._state == 1 else 'OFF'}." 1527 | ) 1528 | else: 1529 | self._human_readable_log = ( 1530 | f"Sensor {self._sensor} reported {'ON' if self._state == 1 else 'OFF'}." 1531 | ) 1532 | 1533 | @property 1534 | def is_on(self): 1535 | return self._state == 1 1536 | 1537 | @property 1538 | def is_detection(self): 1539 | return self._detection == 1 1540 | 1541 | @property 1542 | def human_readable_log(self): 1543 | return self._human_readable_log 1544 | 1545 | 1546 | class OWNCENPlusEvent(OWNEvent): 1547 | def __init__(self, data): 1548 | super().__init__(data) 1549 | 1550 | self._state = self._what 1551 | self.push_button = int(self._what_param[0]) 1552 | self.object = self._where[1:] 1553 | 1554 | if self._state == 21: 1555 | self._human_readable_log = f"Button {self.push_button} of CEN+ object {self.object} has been pressed" # pylint: disable=line-too-long 1556 | elif self._state == 22: 1557 | self._human_readable_log = f"Button {self.push_button} of CEN+ object {self.object} is being held pressed" # pylint: disable=line-too-long 1558 | elif self._state == 23: 1559 | self._human_readable_log = f"Button {self.push_button} of CEN+ object {self.object} is still being held pressed" # pylint: disable=line-too-long 1560 | elif self._state == 24: 1561 | self._human_readable_log = f"Button {self.push_button} of CEN+ object {self.object} has been released" # pylint: disable=line-too-long 1562 | elif self._state == 25: 1563 | self._human_readable_log = f"Button {self.push_button} of CEN+ object {self.object} has been slowly rotated clockwise" # pylint: disable=line-too-long 1564 | elif self._state == 26: 1565 | self._human_readable_log = f"Button {self.push_button} of CEN+ object {self.object} has been quickly rotated clockwise" # pylint: disable=line-too-long 1566 | elif self._state == 27: 1567 | self._human_readable_log = f"Button {self.push_button} of CEN+ object {self.object} has been slowly rotated counter-clockwise" # pylint: disable=line-too-long 1568 | elif self._state == 28: 1569 | self._human_readable_log = f"Button {self.push_button} of CEN+ object {self.object} has been quickly rotated counter-clockwise" # pylint: disable=line-too-long 1570 | 1571 | @property 1572 | def is_short_pressed(self): 1573 | return self._state == 21 1574 | 1575 | @property 1576 | def is_held(self): 1577 | return self._state == 22 1578 | 1579 | @property 1580 | def is_still_held(self): 1581 | return self._state == 23 1582 | 1583 | @property 1584 | def is_released(self): 1585 | return self._state == 24 1586 | 1587 | @property 1588 | def is_slowly_turned_cw(self): 1589 | return self._state == 25 1590 | 1591 | @property 1592 | def is_quickly_turned_cw(self): 1593 | return self._state == 26 1594 | 1595 | @property 1596 | def is_slowly_turned_ccw(self): 1597 | return self._state == 27 1598 | 1599 | @property 1600 | def is_quickly_turned_ccw(self): 1601 | return self._state == 28 1602 | 1603 | @property 1604 | def human_readable_log(self): 1605 | return self._human_readable_log 1606 | 1607 | 1608 | class OWNCommand(OWNMessage): 1609 | """ 1610 | This class is a subclass of messages. 1611 | All messages sent during a command session are commands. 1612 | Dividing this in a subclass provides better clarity 1613 | """ 1614 | 1615 | @classmethod 1616 | def parse(cls, data) -> Optional[OWNCommand]: 1617 | _match = re.match(r"^\*#?(?P\d+)\*.+##$", data) 1618 | 1619 | if _match: 1620 | _who = int(_match.group("who")) 1621 | 1622 | if _who == 0: 1623 | return cls(data) 1624 | elif _who == 1: 1625 | return OWNLightingCommand(data) 1626 | elif _who == 2: 1627 | return OWNAutomationCommand(data) 1628 | elif _who == 3: # Charges / Loads ? 1629 | return cls(data) 1630 | elif _who == 4: 1631 | return OWNHeatingCommand(data) 1632 | elif _who == 5: 1633 | return cls(data) 1634 | elif _who == 6: # VDES 1635 | return cls(data) 1636 | elif _who == 7: 1637 | return cls(data) 1638 | elif _who == 9: 1639 | return cls(data) 1640 | elif _who == 13: 1641 | return OWNGatewayCommand(data) 1642 | elif _who == 14: 1643 | return cls(data) 1644 | elif _who == 15: 1645 | return cls(data) 1646 | elif _who == 16: 1647 | return cls(data) 1648 | elif _who == 17: 1649 | return cls(data) 1650 | elif _who == 18: 1651 | return OWNEnergyCommand(data) 1652 | elif _who == 22: 1653 | return cls(data) 1654 | elif _who == 24: 1655 | return cls(data) 1656 | elif _who == 25: 1657 | _where = re.match(r"^\*.+\*(?P\d+)##$", data).group("where") 1658 | if _where.startswith("2"): 1659 | return cls(data) 1660 | elif _where.startswith("3"): 1661 | return OWNDryContactCommand(data) 1662 | elif _who > 1000: 1663 | return cls(data) 1664 | 1665 | return None 1666 | 1667 | 1668 | class OWNLightingCommand(OWNCommand): 1669 | @classmethod 1670 | def status(cls, where): 1671 | message = cls(f"*#1*{where}##") 1672 | message._human_readable_log = f"Requesting light or switch {message._where}{message._interface_log_text} status." 1673 | return message 1674 | 1675 | @classmethod 1676 | def get_brightness(cls, where): 1677 | message = cls(f"*#1*{where}*1##") 1678 | message._human_readable_log = f"Requesting light {message._where}{message._interface_log_text} brightness." 1679 | return message 1680 | 1681 | @classmethod 1682 | def get_pir_sensitivity(cls, where): 1683 | message = cls(f"*#1*{where}*5##") 1684 | message._human_readable_log = f"Requesting light/motion sensor {message._where}{message._interface_log_text} PIR sensitivity." 1685 | return message 1686 | 1687 | @classmethod 1688 | def get_illuminance(cls, where): 1689 | message = cls(f"*#1*{where}*6##") 1690 | message._human_readable_log = f"Requesting light/motion sensor {message._where}{message._interface_log_text} illuminance." 1691 | return message 1692 | 1693 | @classmethod 1694 | def get_motion_timeout(cls, where): 1695 | message = cls(f"*#1*{where}*7##") 1696 | message._human_readable_log = f"Requesting light/motion sensor {message._where}{message._interface_log_text} motion timeout." 1697 | return message 1698 | 1699 | @classmethod 1700 | def flash(cls, where, _freqency=0.5): 1701 | if _freqency is not None and _freqency >= 0.5 and _freqency <= 5: 1702 | _freqency = round(_freqency * 2) / 2 1703 | else: 1704 | _freqency = 0.5 1705 | _what = int((_freqency / 0.5) + 19) 1706 | message = cls(f"*1*{_what}*{where}##") 1707 | message._human_readable_log = f"Flashing light {message._where}{message._interface_log_text} every {_freqency}s." 1708 | return message 1709 | 1710 | @classmethod 1711 | def switch_on(cls, where, _transition=None): 1712 | if _transition is not None and _transition >= 0 and _transition <= 255: 1713 | message = cls(f"*1*1#{_transition}*{where}##") 1714 | message._human_readable_log = f"Switching ON light {message._where}{message._interface_log_text} with transition speed {_transition}." 1715 | else: 1716 | message = cls(f"*1*1*{where}##") 1717 | message._human_readable_log = f"Switching ON light or switch {message._where}{message._interface_log_text}." 1718 | return message 1719 | 1720 | @classmethod 1721 | def switch_off(cls, where, _transition=None): 1722 | if _transition is not None and _transition >= 0 and _transition <= 255: 1723 | message = cls(f"*1*0#{_transition}*{where}##") 1724 | message._human_readable_log = f"Switching OFF light {message._where}{message._interface_log_text} with transition speed {_transition}." 1725 | else: 1726 | message = cls(f"*1*0*{where}##") 1727 | message._human_readable_log = f"Switching OFF light or switch {message._where}{message._interface_log_text}." 1728 | return message 1729 | 1730 | @classmethod 1731 | def set_brightness(cls, where, _level=30, _transition=0): 1732 | command_level = int(_level) + 100 1733 | transition_speed = _transition if _transition >= 0 and _transition <= 255 else 0 1734 | message = cls(f"*#1*{where}*#1*{command_level}*{transition_speed}##") 1735 | message._human_readable_log = ( 1736 | f"Setting light {message._where}{message._interface_log_text} brightness to {_level}% with transition speed {transition_speed}." # pylint: disable=line-too-long 1737 | if transition_speed > 0 1738 | else f"Setting light {message._where}{message._interface_log_text} brightness to {_level}%." 1739 | ) 1740 | return message 1741 | 1742 | 1743 | class OWNAutomationCommand(OWNCommand): 1744 | @classmethod 1745 | def status(cls, where): 1746 | message = cls(f"*#2*{where}##") 1747 | message._human_readable_log = ( 1748 | f"Requesting shutter {message._where}{message._interface_log_text} status." 1749 | ) 1750 | return message 1751 | 1752 | @classmethod 1753 | def raise_shutter(cls, where): 1754 | message = cls(f"*2*1*{where}##") 1755 | message._human_readable_log = ( 1756 | f"Raising shutter {message._where}{message._interface_log_text}." 1757 | ) 1758 | return message 1759 | 1760 | @classmethod 1761 | def lower_shutter(cls, where): 1762 | message = cls(f"*2*2*{where}##") 1763 | message._human_readable_log = ( 1764 | f"Lowering shutter {message._where}{message._interface_log_text}." 1765 | ) 1766 | return message 1767 | 1768 | @classmethod 1769 | def stop_shutter(cls, where): 1770 | message = cls(f"*2*0*{where}##") 1771 | message._human_readable_log = ( 1772 | f"Stoping shutter {message._where}{message._interface_log_text}." 1773 | ) 1774 | return message 1775 | 1776 | @classmethod 1777 | def set_shutter_level(cls, where, level=30): 1778 | message = cls(f"*#2*{where}*#11#001*{level}##") 1779 | message._human_readable_log = f"Setting shutter {message._where}{message._interface_log_text} position to {level}%." 1780 | return message 1781 | 1782 | 1783 | class OWNHeatingCommand(OWNCommand): 1784 | @classmethod 1785 | def status(cls, where): 1786 | message = cls(f"*#4*{where}##") 1787 | message._human_readable_log = f"Requesting climate status update for {message._where}{message._interface_log_text}." 1788 | return message 1789 | 1790 | @classmethod 1791 | def get_temperature(cls, where): 1792 | message = cls(f"*#4*{where}*0##") 1793 | message._human_readable_log = f"Requesting climate status update for {message._where}{message._interface_log_text}." 1794 | return message 1795 | 1796 | @classmethod 1797 | def set_mode(cls, where, mode: str, standalone=False): 1798 | central_local = re.compile(r"^#0#\d+$") 1799 | if central_local.match(str(where)): 1800 | zone = where 1801 | zone_name = f"zone {int(where.split('#')[-1])}" 1802 | else: 1803 | zone = int(where.split("#")[-1]) if where.startswith("#") else int(where) 1804 | zone_name = f"zone {zone}" if zone > 0 else "general" 1805 | 1806 | if standalone: 1807 | zone = f"#{zone}" if zone == 0 else str(zone) 1808 | else: 1809 | zone = f"#{zone}" 1810 | 1811 | mode_name = mode 1812 | if mode == CLIMATE_MODE_OFF: 1813 | mode = 303 1814 | elif mode == CLIMATE_MODE_AUTO: 1815 | mode = 311 1816 | else: 1817 | return None 1818 | 1819 | message = cls(f"*4*{mode}*{zone}##") 1820 | message._human_readable_log = f"Setting {zone_name} mode to '{mode_name}'." 1821 | return message 1822 | 1823 | @classmethod 1824 | def turn_off(cls, where, standalone=False): 1825 | return cls.set_mode(where=where, mode=CLIMATE_MODE_OFF, standalone=standalone) 1826 | 1827 | @classmethod 1828 | def set_temperature(cls, where, temperature: float, mode: str, standalone=False): 1829 | central_local = re.compile(r"^#0#\d+$") 1830 | if central_local.match(str(where)): 1831 | zone = where 1832 | zone_name = f"zone {int(where.split('#')[-1])}" 1833 | else: 1834 | zone = int(where.split("#")[-1]) if where.startswith("#") else int(where) 1835 | zone_name = f"zone {zone}" if zone > 0 else "general" 1836 | 1837 | if standalone: 1838 | zone = f"#{zone}" if zone == 0 else str(zone) 1839 | else: 1840 | zone = f"#{zone}" 1841 | 1842 | temperature = round(temperature * 2) / 2 1843 | if temperature < 5.0: 1844 | temperature = 5.0 1845 | elif temperature > 40.0: 1846 | temperature = 40.0 1847 | temperature_print = f"{temperature}" 1848 | temperature = int(temperature * 10) 1849 | 1850 | mode_name = mode 1851 | if mode == CLIMATE_MODE_HEAT: 1852 | mode = 1 1853 | elif mode == CLIMATE_MODE_COOL: 1854 | mode = 2 1855 | elif mode == CLIMATE_MODE_AUTO: 1856 | mode = 3 1857 | 1858 | message = cls(f"*#4*{zone}*#14*{temperature:04d}*{mode}##") 1859 | message._human_readable_log = ( 1860 | f"Setting {zone_name} to {temperature_print}°C in mode '{mode_name}'." 1861 | ) 1862 | return message 1863 | 1864 | 1865 | class OWNAVCommand(OWNCommand): 1866 | @classmethod 1867 | def receive_video(cls, where): 1868 | camera_id = where 1869 | if int(where) < 100: 1870 | where = f"40{camera_id}" 1871 | elif int(where) >= 4000 and int(where) < 5000: 1872 | camera_id = where[2:] 1873 | else: 1874 | return None 1875 | 1876 | message = cls(f"*7*0*{where}##") 1877 | message._human_readable_log = f"Opening video stream for camera {camera_id}." 1878 | return message 1879 | 1880 | @classmethod 1881 | def close_video(cls): 1882 | message = cls("*7*9**##") 1883 | message._human_readable_log = "Closing video stream." 1884 | return message 1885 | 1886 | 1887 | class OWNGatewayCommand(OWNCommand): 1888 | def __init__(self, data): 1889 | super().__init__(data) 1890 | 1891 | self._year = None 1892 | self._month = None 1893 | self._day = None 1894 | self._hour = None 1895 | self._minute = None 1896 | self._second = None 1897 | self._timezone = None 1898 | 1899 | self._time = None 1900 | self._date = None 1901 | self._datetime = None 1902 | 1903 | if self._dimension == 0: 1904 | self._hour = self._dimension_value[0] 1905 | self._minute = self._dimension_value[1] 1906 | self._second = self._dimension_value[2] 1907 | # Timezone is sometimes missing from messages, assuming UTC 1908 | if self._dimension_value[3] != "": 1909 | self._timezone = ( 1910 | f"+{self._dimension_value[3][1:]}:00" 1911 | if self._dimension_value[3][0] == "0" 1912 | else f"-{self._dimension_value[3][1:]}:00" 1913 | ) 1914 | else: 1915 | self._timezone = "" 1916 | self._time = datetime.time.fromisoformat( 1917 | f"{self._hour}:{self._minute}:{self._second}{self._timezone}" 1918 | ) 1919 | self._human_readable_log = ( 1920 | f"Gateway broadcasting internal time: {self._time}." 1921 | ) 1922 | 1923 | elif self._dimension == 1: 1924 | self._year = self._dimension_value[3] 1925 | self._month = self._dimension_value[2] 1926 | self._day = self._dimension_value[1] 1927 | self._date = datetime.date( 1928 | year=int(self._year), month=int(self._month), day=int(self._day) 1929 | ) 1930 | self._human_readable_log = ( 1931 | f"Gateway broadcasting internal date: {self._date}." 1932 | ) 1933 | 1934 | elif self._dimension == 22: 1935 | self._hour = self._dimension_value[0] 1936 | self._minute = self._dimension_value[1] 1937 | self._second = self._dimension_value[2] 1938 | # Timezone is sometimes missing from messages, assuming UTC 1939 | if self._dimension_value[3] != "": 1940 | self._timezone = ( 1941 | f"+{self._dimension_value[3][1:]}:00" 1942 | if self._dimension_value[3][0] == "0" 1943 | else f"-{self._dimension_value[3][1:]}:00" 1944 | ) 1945 | else: 1946 | self._timezone = "" 1947 | self._day = self._dimension_value[5] 1948 | self._month = self._dimension_value[6] 1949 | self._year = self._dimension_value[7] 1950 | self._datetime = datetime.datetime.fromisoformat( 1951 | f"{self._year}-{self._month}-{self._day}*{self._hour}:{self._minute}:{self._second}{self._timezone}" # pylint: disable=line-too-long 1952 | ) 1953 | self._human_readable_log = ( 1954 | f"Gateway broadcasting internal datetime: {self._datetime}." 1955 | ) 1956 | 1957 | @classmethod 1958 | def set_datetime_to_now(cls, time_zone: str): 1959 | timezone = pytz.timezone(time_zone) 1960 | now = timezone.localize(datetime.datetime.now()) 1961 | timezone_offset = ( 1962 | f"0{now.strftime('%z')[1:3]}" 1963 | if now.strftime("%z")[0] == "+" 1964 | else f"1{now.strftime('%z')[1:3]}" 1965 | ) 1966 | message = cls( 1967 | f"*#13**#22*{now.strftime('%H*%M*%S')}*{timezone_offset}*0{now.strftime('%w*%d*%m*%Y##')}" # pylint: disable=line-too-long 1968 | ) 1969 | message._human_readable_log = f"Setting gateway time to: {message._datetime}." 1970 | return message 1971 | 1972 | @classmethod 1973 | def set_date_to_today(cls, time_zone: str): 1974 | timezone = pytz.timezone(time_zone) 1975 | now = timezone.localize(datetime.datetime.now()) 1976 | message = cls(f"*#13**#1*0{now.strftime('%w*%d*%m*%Y##')}") 1977 | message._human_readable_log = f"Setting gateway date to: {message._date}." 1978 | return message 1979 | 1980 | @classmethod 1981 | def set_time_to_now(cls, time_zone: str): 1982 | timezone = pytz.timezone(time_zone) 1983 | now = timezone.localize(datetime.datetime.now()) 1984 | timezone_offset = ( 1985 | f"0{now.strftime('%z')[1:3]}" 1986 | if now.strftime("%z")[0] == "+" 1987 | else f"1{now.strftime('%z')[1:3]}" 1988 | ) 1989 | message = cls(f"*#13**#0*{now.strftime('%H*%M*%S')}*{timezone_offset}*##") 1990 | message._human_readable_log = f"Setting gateway time to: {message._time}." 1991 | return message 1992 | 1993 | 1994 | class OWNEnergyCommand(OWNCommand): 1995 | @classmethod 1996 | def start_sending_instant_power(cls, where, duration: int = 65): 1997 | where = f"{where}#0" if str(where).startswith("7") else str(where) 1998 | duration = 255 if duration > 255 else duration 1999 | message = cls(f"*#18*{where}*#1200#1*{duration}##") 2000 | message._human_readable_log = f"Requesting instant power draw update from sensor {where} for {duration} minutes." # pylint: disable=line-too-long 2001 | return message 2002 | 2003 | @classmethod 2004 | def get_hourly_consumption(cls, where, date: datetime.date): 2005 | where = f"{where}#0" if str(where).startswith("7") else str(where) 2006 | today = datetime.date.today() 2007 | one_year_ago = datetime.date( 2008 | year=today.year - 1, month=today.month, day=today.day 2009 | ) 2010 | if date < one_year_ago: 2011 | return None 2012 | message = cls(f"*#18*{where}*511#{date.month}#{date.day}##") 2013 | message._human_readable_log = ( 2014 | f"Requesting hourly power consumption from sensor {where} for {date}." 2015 | ) 2016 | return message 2017 | 2018 | @classmethod 2019 | def get_partial_daily_consumption(cls, where): 2020 | where = f"{where}#0" if str(where).startswith("7") else str(where) 2021 | message = cls(f"*#18*{where}*54##") 2022 | message._human_readable_log = ( 2023 | f"Requesting today's partial power consumption from sensor {where}." 2024 | ) 2025 | return message 2026 | 2027 | @classmethod 2028 | def get_daily_consumption(cls, where, year, month): 2029 | where = f"{where}#0" if str(where).startswith("7") else str(where) 2030 | today = datetime.date.today() 2031 | one_year_ago = today - relativedelta(years=1) 2032 | two_year_ago = today - relativedelta(years=2) 2033 | target = datetime.date(year=year, month=month, day=1) 2034 | if target > today: 2035 | return None 2036 | elif target > one_year_ago: 2037 | message = cls(f"*18*59#{month}*{where}##") 2038 | elif target > two_year_ago: 2039 | message = cls(f"*18*510#{month}*{where}##") 2040 | else: 2041 | return None 2042 | message._human_readable_log = f"Requesting daily power consumption for {year}-{month} from sensor {where}." # pylint: disable=line-too-long 2043 | return message 2044 | 2045 | @classmethod 2046 | def get_partial_monthly_consumption(cls, where): 2047 | where = f"{where}#0" if str(where).startswith("7") else str(where) 2048 | message = cls(f"*#18*{where}*53##") 2049 | message._human_readable_log = ( 2050 | f"Requesting this month's partial power consumption from sensor {where}." 2051 | ) 2052 | return message 2053 | 2054 | @classmethod 2055 | def get_monthly_consumption(cls, where, year, month): 2056 | where = f"{where}#0" if str(where).startswith("7") else str(where) 2057 | message = cls(f"*#18*{where}*52#{str(year)[2:]}#{month}##") 2058 | message._human_readable_log = f"Requesting monthly power consumption for {year}-{month} from sensor {where}." # pylint: disable=line-too-long 2059 | return message 2060 | 2061 | @classmethod 2062 | def get_total_consumption(cls, where): 2063 | where = f"{where}#0" if str(where).startswith("7") else str(where) 2064 | message = cls(f"*#18*{where}*51##") 2065 | message._human_readable_log = ( 2066 | f"Requesting total power consumption from sensor {where}." 2067 | ) 2068 | return message 2069 | 2070 | 2071 | class OWNDryContactCommand(OWNCommand): 2072 | @classmethod 2073 | def status(cls, where): 2074 | message = cls(f"*#25*{where}##") 2075 | message._human_readable_log = f"Requesting dry contact {where} status." 2076 | return message 2077 | 2078 | 2079 | class OWNSignaling(OWNMessage): 2080 | """ 2081 | This class is a subclass of messages. 2082 | It is dedicated to signaling messages such as ACK or Authentication negotiation 2083 | """ 2084 | 2085 | def __init__(self, data): # pylint: disable=super-init-not-called 2086 | self._raw = data 2087 | self._family = None 2088 | self._type = "UNKNOWN" 2089 | self._human_readable_log = data 2090 | 2091 | if self._ACK.match(self._raw): 2092 | self._match = self._ACK.match(self._raw) 2093 | self._family = "SIGNALING" 2094 | self._type = "ACK" 2095 | self._human_readable_log = "ACK." 2096 | elif self._NACK.match(self._raw): 2097 | self._match = self._NACK.match(self._raw) 2098 | self._family = "SIGNALING" 2099 | self._type = "NACK" 2100 | self._human_readable_log = "NACK." 2101 | elif self._NONCE.match(self._raw): 2102 | self._match = self._NONCE.match(self._raw) 2103 | self._family = "SIGNALING" 2104 | self._type = "NONCE" 2105 | self._human_readable_log = ( 2106 | f"Nonce challenge received: {self._match.group(1)}." 2107 | ) 2108 | elif self._SHA.match(self._raw): 2109 | self._match = self._SHA.match(self._raw) 2110 | self._family = "SIGNALING" 2111 | self._type = f"SHA{'-1' if self._match.group(1) == '1' else '-256'}" 2112 | self._human_readable_log = f"SHA{'-1' if self._match.group(1) == '1' else '-256'} challenge received." # pylint: disable=line-too-long 2113 | elif self._COMMAND_SESSION.match(self._raw): 2114 | self._match = self._COMMAND_SESSION.match(self._raw) 2115 | self._family = "SIGNALING" 2116 | self._type = "COMMAND_SESSION" 2117 | self._human_readable_log = "Command session requested." 2118 | elif self._EVENT_SESSION.match(self._raw): 2119 | self._match = self._EVENT_SESSION.match(self._raw) 2120 | self._family = "SIGNALING" 2121 | self._type = "EVENT_SESSION" 2122 | self._human_readable_log = "Event session requested." 2123 | 2124 | @property 2125 | def nonce(self): 2126 | """Return the authentication nonce IF the message is a nonce message""" 2127 | if self.is_nonce: # pylint: disable=using-constant-test 2128 | return self._match.group(1) 2129 | else: 2130 | return None 2131 | 2132 | @property 2133 | def sha_version(self): 2134 | """Return the authentication SHA version IF the message is a SHA challenge message""" 2135 | if self.is_sha: # pylint: disable=using-constant-test 2136 | return self._match.group(1) 2137 | else: 2138 | return None 2139 | 2140 | def is_ack(self) -> bool: 2141 | return self._type == "ACK" 2142 | 2143 | def is_nack(self) -> bool: 2144 | return self._type == "NACK" 2145 | 2146 | def is_nonce(self) -> bool: 2147 | return self._type == "NONCE" 2148 | 2149 | def is_sha(self) -> bool: 2150 | return self._type == "SHA-1" or self._type == "SHA-256" 2151 | 2152 | def is_sha_1(self) -> bool: 2153 | return self._type == "SHA-1" 2154 | 2155 | def is_sha_256(self) -> bool: 2156 | return self._type == "SHA-256" 2157 | --------------------------------------------------------------------------------