pax_global_header00006660000000000000000000000064147056666630014535gustar00rootroot0000000000000052 comment=70149af426b0f3d114a83e1f5e777e36609f0c70 pyDuotecno-2024.10.1/000077500000000000000000000000001470566666300141755ustar00rootroot00000000000000pyDuotecno-2024.10.1/.flake8000066400000000000000000000001751470566666300153530ustar00rootroot00000000000000[flake8] ignore = E203, E266, E501, W503, F403, F401, C901 max-line-length = 79 max-complexity = 18 select = B,C,E,F,W,T4,B9 pyDuotecno-2024.10.1/.github/000077500000000000000000000000001470566666300155355ustar00rootroot00000000000000pyDuotecno-2024.10.1/.github/FUNDING.yml000066400000000000000000000001031470566666300173440ustar00rootroot00000000000000# These are supported funding model platforms github: [Cereal2nd] pyDuotecno-2024.10.1/.github/workflows/000077500000000000000000000000001470566666300175725ustar00rootroot00000000000000pyDuotecno-2024.10.1/.github/workflows/main.yml000066400000000000000000000021061470566666300212400ustar00rootroot00000000000000name: Python checks on: [push, pull_request] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12"] steps: - name: Checkout reposistory uses: actions/checkout@master with: submodules: recursive - uses: chartboost/ruff-action@v1 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - uses: pre-commit/action@v3.0.0 - name: Install requirements run: pip install -r requirements.txt - name: Install package run: pip install . - name: Build binary wheel and a source tarball run: python setup.py sdist - name: Install pypa/build run: >- python -m pip install build --user - name: Build a binary wheel and a source tarballd run: >- python -m build --sdist --wheel --outdir dist/ . pyDuotecno-2024.10.1/.github/workflows/python-publish.yml000066400000000000000000000014741470566666300233100ustar00rootroot00000000000000name: Upload to PyPi on release on: workflow_dispatch: release: types: [created] jobs: deploy: runs-on: ubuntu-latest steps: - name: checkout uses: actions/checkout@v3 with: submodules: recursive - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.x" - name: Install pypa/build run: >- python -m pip install build --user - name: Build a binary wheel and a source tarball run: >- python -m build --sdist --wheel --outdir dist/ . - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} pyDuotecno-2024.10.1/.gitignore000066400000000000000000000034201470566666300161640ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ duovenv pyDuotecno-2024.10.1/.pre-commit-config.yaml000066400000000000000000000034011470566666300204540ustar00rootroot00000000000000repos: - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.1.12 hooks: - id: ruff args: [--fix] - id: ruff-format - repo: https://github.com/asottile/pyupgrade rev: v3.3.2 hooks: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/codespell-project/codespell rev: v2.2.4 hooks: - id: codespell args: - --quiet-level=2 - --ignore-words-list=hass - repo: https://github.com/PyCQA/bandit rev: 1.7.5 hooks: - id: bandit args: - --quiet files: ^.*/.+\.py$ - repo: https://github.com/PyCQA/isort rev: 5.12.0 hooks: - id: isort stages: [manual] args: ["--profile", "black"] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 # Use the ref you want to point at hooks: - id: check-ast - id: check-json - id: check-builtin-literals - id: check-case-conflict - id: end-of-file-fixer - id: requirements-txt-fixer - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 # Use the ref you want to point at hooks: - id: python-use-type-annotations - id: python-no-eval - id: python-no-log-warn - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.3.2 hooks: - id: prettier - repo: https://github.com/cdce8p/python-typing-update rev: v0.5.1 hooks: # Run `python-typing-update` hook manually from time to time # to update python typing syntax. # Will require manual work, before submitting changes! - id: python-typing-update stages: [manual] args: - --py38-plus - --force - --black - --keep-updates pyDuotecno-2024.10.1/CONTRIBUTING.md000066400000000000000000000017601470566666300164320ustar00rootroot00000000000000# Contributing You are considering to contribute. Thank you! This document should get you up and running with your development environment. ## Development environment 1. Clone the repo: `git clone --recurse-submodules https://github.com/Cereal2nd/pyDuotecno` 2. (optional) To keep dependencies from different projects from conflicting, it's usually better to install every project in its own Virtual Environment. Start by creating a new virtualenv: `python3 -m venv duovenv` This will create a new directory called `duovenv`. You need to activate the virtual environment every time you open a new shell by running `source duovenv/bin/activate`. Your prompt will be prefixed with `(duovenv)` to indicate the virtual environment is active. 3. Install the development dependencies: `pip install -r requirements-dev.txt` 4. Prepare your changes 5. Run the tests to check if everything still works as expected: `pytest` 6. Run `pre-commit run --all-files` to check and correct formatting pyDuotecno-2024.10.1/LICENSE000066400000000000000000000261351470566666300152110ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. pyDuotecno-2024.10.1/MANIFEST.in000066400000000000000000000001111470566666300157240ustar00rootroot00000000000000include LICENSE include README.md include requirements.txt exclude venv/ pyDuotecno-2024.10.1/README.md000066400000000000000000000011021470566666300154460ustar00rootroot00000000000000This is a package to talk to duotecno ip interfaces. > This project requires financial support, but it is free for you to use. You can join those helping to keep the lights on at: > > [](https://buymeacoffee.com/cereal2nd) >[](https://github.com/sponsors/Cereal2nd/) pyDuotecno-2024.10.1/azure-pipelines.yml000066400000000000000000000014221470566666300200330ustar00rootroot00000000000000trigger: - main pool: vmImage: ubuntu-latest strategy: matrix: Python39: python.version: "3.9" Python310: python.version: "3.10" Python311: python.version: "3.11" steps: - task: UsePythonVersion@0 inputs: versionSpec: "$(python.version)" displayName: "Use Python $(python.version)" - script: | python -m pip install --upgrade pip pip install -r requirements.txt displayName: "Install dependencies" - script: | python setup.py sdist displayName: "Build binary wheel and a source tarball" - script: | pip install build --user displayName: "Install pypa/build" - script: | python -m build --sdist --wheel --outdir dist/ . displayName: Build a binary wheel and a source tarball pyDuotecno-2024.10.1/duotecno/000077500000000000000000000000001470566666300160155ustar00rootroot00000000000000pyDuotecno-2024.10.1/duotecno/__init__.py000066400000000000000000000000001470566666300201140ustar00rootroot00000000000000pyDuotecno-2024.10.1/duotecno/controller.py000066400000000000000000000323311470566666300205540ustar00rootroot00000000000000"""Main interface to the duotecno bus.""" from __future__ import annotations import asyncio import logging import time from typing import Final from collections import deque from duotecno.exceptions import LoadFailure, InvalidPassword from duotecno.protocol import ( Packet, EV_CLIENTCONNECTSET_3, EV_NODEDATABASEINFO_0, EV_NODEDATABASEINFO_1, EV_HEARTBEATSTATUS_1, ) from duotecno.node import Node from duotecno.unit import BaseUnit PW_TIMEOUT: Final = 5 LOAD_NODE_TIMEOUT: Final = 60 LOAD_UNIT_TIMEOUT: Final = 120 HB_TIMEOUT: Final = 20 HB_BUSEMPTY: Final = 60 MAX_INFLIGHT: Final = 5 STATUS_RETRANSMIT: Final = 2 RECONNECT_TIMEOUT: Final = 180 class PyDuotecno: """Class that will will do the bus management. - send packets - receive packets - open and close the connection """ writer: asyncio.StreamWriter | None = None reader: asyncio.StreamReader | None = None readerTask: asyncio.Task[None] hbTask: asyncio.Task[None] workTask: asyncio.Task[None] writerTask: asyncio.Task[None] receiveQueue: asyncio.PriorityQueue sendSema: asyncio.Semaphore sendQueue: asyncio.PriorityQueue connectionOK: asyncio.Event heartbeatReceived: asyncio.Event nextHeartbeat: int packetToWaitFor: str | None = None packetWaiter: asyncio.Event nodes: dict[int, Node] = {} host: str port: int password: str numNodes: int = 0 def get_units(self, unit_type: list[str] | str) -> list[BaseUnit]: res = [] for node in self.nodes.values(): for unit in node.get_unit_by_type(unit_type): res.append(unit) return res async def enableAllUnits(self) -> None: self._log.debug("Enable all Units on all nodes") for node in self.nodes.values(): await node.enable() async def disableAllUnits(self) -> None: self._log.debug("Disable all Units on all nodes") for node in self.nodes.values(): await node.disable() async def disconnect(self) -> None: self._log.debug("Disconnecting") self.connectionOK.clear() if self.readerTask.cancel(): try: await self.readerTask except asyncio.CancelledError: pass if self.hbTask.cancel(): try: await self.hbTask except asyncio.CancelledError: pass if self.workTask.cancel(): try: await self.workTask except asyncio.CancelledError: pass if self.writerTask.cancel(): try: await self.writerTask except asyncio.CancelledError: pass if self.writer: self.writer.close() self._log.debug("Disconnecting Finished") async def connect( self, host: str, port: int, password: str, testOnly: bool = False ) -> None: """Initialize the connection.""" self.host = host self.port = port self.password = password await self._do_connect(testOnly) async def _reconnect(self): await self.disconnect() await self.disableAllUnits() await asyncio.sleep(RECONNECT_TIMEOUT) await self.continuously_check_connection() async def _do_connect(self, testOnly: bool = False, skipLoad: bool = False) -> None: if not skipLoad: self.nodes = {} self._log = logging.getLogger("pyduotecno") # Try to connect self._log.debug("Try to connect") try: self.reader, self.writer = await asyncio.open_connection( self.host, self.port ) except (ConnectionError, TimeoutError): raise # events self.connectionOK = asyncio.Event() self.heartbeatReceived = asyncio.Event() self.packetWaiter = asyncio.Event() # at this point the connection should be ok self._log.debug("Connection established") self.connectionOK.set() self.heartbeatReceived.clear() # start the bus reading task self.receiveQueue = asyncio.PriorityQueue() self.sendQueue = asyncio.PriorityQueue() self.sendSema = asyncio.Semaphore(MAX_INFLIGHT) self.readerTask = asyncio.Task(self._readTask()) self.writerTask = asyncio.Task(self._writeTask()) self.workTask = asyncio.Task(self._handleTask()) # send login info passw = [str(ord(i)) for i in self.password] await self.write(f"[214,3,{len(passw)},{','.join(passw)}]") # wait for the login to be ok try: await asyncio.wait_for(self.waitForPacket("67,3,1"), timeout=PW_TIMEOUT) except TimeoutError: await self.disconnect() raise InvalidPassword() # if we are not testing the connection, start scanning if testOnly: return # do we need to reload the modules? if not skipLoad: await self.write("[209,5]") await self.write("[209,0]") try: await asyncio.wait_for(self._loadTaskNodes(), timeout=LOAD_NODE_TIMEOUT) self._log.info("Nodes discoverd") for n in self.nodes.values(): await n.load() await asyncio.sleep(0.1) await asyncio.wait_for(self._loadTaskUnits(), timeout=LOAD_UNIT_TIMEOUT) self._log.info("Units discoverd") except TimeoutError: await self.disconnect() raise LoadFailure() # in case of skipload we do want to request the status again self._log.info("Requesting unit status") for node in self.nodes.values(): for unit in node.get_units(): self._log.debug(f"Unit: {unit}") await unit.requestStatus() await asyncio.sleep(0.1) self.hbTask = asyncio.Task(self.heartbeatTask()) await self.enableAllUnits() async def write(self, msg: str) -> None: """Send a message.""" if not self.writer: return if self.writer.transport.is_closing(): await self._reconnect() return self._log.debug(f"TX: {msg}") await self.sendQueue.put(msg) return async def _writeTask(self) -> None: while True: try: msg = await self.sendQueue.get() await self.sendSema.acquire() msg = f"{msg}{chr(10)}" self.writer.write(msg.encode()) await self.writer.drain() await asyncio.sleep(0.1) except ConnectionError: await self.reconnect() return async def _loadTaskNodes(self) -> None: while len(self.nodes) < 1: await asyncio.sleep(3) while True: if len(self.nodes) == self.numNodes: return await asyncio.sleep(1) async def _loadTaskUnits(self) -> None: while True: c = 0 for n in self.nodes.values(): if n.isLoaded.is_set(): c += 1 if c == len(self.nodes): return await asyncio.sleep(1) async def check_tcp_connection(self, timeout=3) -> bool: """Check if a TCP connection can be established to the given host and port.""" self._log.debug("Checking connection...") conn = asyncio.open_connection(self.host, self.port) try: reader, writer = await asyncio.wait_for(conn, timeout=timeout) writer.close() await writer.wait_closed() return True except (asyncio.TimeoutError, OSError): # Could not connect within the timeout period or there was a network error return False async def continuously_check_connection(self) -> None: """Continuously check for connection restoration and reconnect.""" self._log.info("Waiting for connection...") while True: connection_restored = await self.check_tcp_connection() if connection_restored: self._log.info("Connection to host restored, reconnecting.") await self._do_connect(skipLoad=True) break else: self._log.debug("Connection to host not yet restored, retrying...") await asyncio.sleep(5) async def heartbeatTask(self) -> None: await asyncio.sleep(30) self._log.info("Starting HB task") while True: # wait until the timer expire of 5 seconds while self.nextHeartbeat > int(time.time()): self._log.debug( f"Waiting until {self.nextHeartbeat} to send a HB ({time.time()})" ) await asyncio.sleep(5) # send the heartbeat self.heartbeatReceived.clear() try: self._log.debug("Sending heartbeat message") await self.write("[215,1]") await asyncio.wait_for( self.heartbeatReceived.wait(), timeout=HB_TIMEOUT ) self._log.debug("Received heartbeat message") except TimeoutError: self._log.warning("Timeout on heartbeat, reconnecting") await self._reconnect() break except asyncio.exceptions.CancelledError: break self._log.info("Stopping HB task") async def _readTask(self) -> None: """Reader task.""" while self.connectionOK.is_set() and self.reader: try: tmp2 = await self.reader.readline() except ConnectionError: await self._reconnect() return if tmp2 == "": return tmp3 = tmp2.decode() tmp = tmp3.rstrip() # self._log.debug(f'Raw Receive: "{tmp}"') if not tmp.startswith("["): tmp = tmp.lstrip("[") tmp = tmp.replace("\x00", "") # self._log.debug(f'Receive: "{tmp}"') tmp = tmp[1:-1] if tmp == "": return self._log.debug(f'RX: "{tmp}"') self.nextHeartbeat = int(time.time()) + HB_BUSEMPTY self.sendSema.release() if await self._comparePacket(tmp): p = tmp.split(",") try: pc = Packet(int(p[0]), int(p[1]), deque([int(_i) for _i in p[2:]])) await self.receiveQueue.put(pc) # self._log.debug(f'QUEUE: "{pc}" {self.receiveQueue.qsize()}') except Exception as e: self._log.error(e) self._log.error(tmp) await asyncio.sleep(0.1) async def _comparePacket(self, rpck: str) -> bool: if not self.packetToWaitFor: return True # self._log.debug(f"COMPARE received packet {rpck} with {self.packetToWaitFor}") if rpck.startswith(self.packetToWaitFor): self.packetWaiter.set() return True return False async def waitForPacket(self, pstr: str) -> None: """Wait for a certain packet. will clear an event and then wait for the event to be set again """ self.packetWaiter.clear() self.packetToWaitFor = pstr await self.packetWaiter.wait() self.packetToWaitFor = None async def _handleTask(self) -> None: """handler task.""" while self.connectionOK.is_set() and self.receiveQueue: try: pc = await self.receiveQueue.get() self._log.debug(f"WX: {pc}") await self._handlePacket(pc) except Exception as e: self._log.error(e) await asyncio.sleep(0.1) async def _handlePacket(self, packet: Packet) -> None: if packet.cls is None: self._log.debug(f"Ignoring packet: {packet}") return if isinstance(packet.cls, EV_CLIENTCONNECTSET_3): return if isinstance(packet.cls, EV_HEARTBEATSTATUS_1): self.heartbeatReceived.set() return if isinstance(packet.cls, EV_NODEDATABASEINFO_0): self.numNodes = packet.cls.numNode for i in range(packet.cls.numNode): await self.write(f"[209,1,{i}]") await self.waitForPacket(f"64,1,{i}") return if isinstance(packet.cls, EV_NODEDATABASEINFO_1): if packet.cls.address not in self.nodes: self.nodes[packet.cls.address] = Node( name=packet.cls.nodeName, address=packet.cls.address, index=packet.cls.index, nodeType=packet.cls.nodeType, numUnits=packet.cls.numUnits, writer=self.write, pwaiter=self.waitForPacket, ) # await self.nodes[packet.cls.address].load() return if hasattr(packet.cls, "address") and packet.cls.address in self.nodes: await self.nodes[packet.cls.address].handlePacket(packet.cls) return self._log.debug(f"Ignoring packet: {packet}") pyDuotecno-2024.10.1/duotecno/exceptions.py000066400000000000000000000001751470566666300205530ustar00rootroot00000000000000class InvalidPassword(Exception): pass class FailedLogin(Exception): pass class LoadFailure(Exception): pass pyDuotecno-2024.10.1/duotecno/node.py000066400000000000000000000072561470566666300173260ustar00rootroot00000000000000from __future__ import annotations from typing import Callable, Awaitable import asyncio import logging from duotecno.protocol import NodeType, EV_NODEDATABASEINFO_2, BaseMessage from duotecno.unit import ( BaseUnit, SwitchUnit, SensUnit, DimUnit, DuoswitchUnit, VirtualUnit, ControlUnit, ) class Node: name: str index: int nodeType: NodeType address: int numUnits: int units: dict[int, BaseUnit] isloaded: asyncio.Event def __init__( self, name: str, address: int, index: int, nodeType: NodeType, numUnits: int, writer: Callable[[str], Awaitable[None]], pwaiter: Callable[[str], Awaitable[None]], ) -> None: self._log = logging.getLogger("pyduotecno-node") self.name = name self.address = address self.index = index self.numUnits = numUnits self.nodeType = nodeType self.writer = writer self.pwaiter = pwaiter self.isLoaded = asyncio.Event() self.isLoaded.clear() self.units = {} self._log.info(f"New node found: {self.name}") async def enable(self) -> None: for unit in self.units.values(): await unit.enable() async def disable(self) -> None: for unit in self.units.values(): await unit.disable() def get_name(self) -> str: return self.name def get_address(self) -> int: return self.address def __repr__(self) -> str: items = [] for k, v in self.__dict__.items(): if k not in ["_log", "writer"]: items.append(f"{k} = {v!r}") return "{}[{}]".format(type(self), ", ".join(items)) def get_units(self) -> list[BaseUnit]: res = [] for unit in self.units.values(): res.append(unit) return res def get_unit_by_type(self, unit_type: list[str] | str) -> list[BaseUnit]: if isinstance(unit_type, str): unit_type = [unit_type] res = [] for unit in self.units.values(): for unitT in unit_type: if str(type(unit)) == f"": res.append(unit) return res async def load(self) -> None: self._log.debug(f"Node {self.name}: Requesting units") for i in range(self.numUnits): await self.writer(f"[209,2,{self.address},{i}]") await self.pwaiter(f"64,2,{self.address},{i}") async def handlePacket(self, packet: BaseMessage) -> None: if isinstance(packet, EV_NODEDATABASEINFO_2): if packet.unit not in self.units: u = BaseUnit if packet.unitTypeName == "SWITCH": u = SwitchUnit elif packet.unitTypeName == "SENS": u = SensUnit elif packet.unitTypeName == "DIM": u = DimUnit elif packet.unitTypeName == "DUOSWITCH": u = DuoswitchUnit elif packet.unitTypeName == "VIRTUAL": u = VirtualUnit elif packet.unitTypeName == "CONTROL": u = ControlUnit else: self._log.warning(f"Unhandled unitType: {packet.unitTypeName}") self.units[packet.unit] = u( self, name=packet.unitName, unit=packet.unit, writer=self.writer ) if len(self.units) == self.numUnits: self.isLoaded.set() return if hasattr(packet, "unit") and packet.unit in self.units: await self.units[packet.unit].handlePacket(packet) return pyDuotecno-2024.10.1/duotecno/protocol.py000066400000000000000000000256151470566666300202410ustar00rootroot00000000000000from __future__ import annotations from typing import final, Deque from enum import Enum, unique from dataclasses import dataclass, field import collections import sys import json @unique class MsgType(Enum): EV_UNITCONTROLSTATUS = 4 EV_UNITDIMSTATUS = 5 EV_UNITSWITCHSTATUS = 6 EV_UNITSENSSTATUS = 7 EV_MESSAGEERROR = 17 EV_NODERESET = 18 EV_UNITAUDIOSTATUS = 23 EV_UNITDUOSWITCHSTATUS = 38 EV_UNITAVMATRIXSTATUS = 54 EV_UNITDEFAULTSTATUS = 48 EV_NODEDATABASEINFO = 64 EV_APPLICATIONTASKSTATUS = 66 EV_CLIENTCONNECTSET = 67 EV_UNITMACROCOMMAND = 69 EV_UNITAUDIOEXTSTATUS = 70 EV_TIMEDATESTATUS = 71 EV_HEARTBEATSTATUS = 72 EV_SCHEDULESTATUS = 73 EV_NODEMANAGEMENTINFO = 74 EV_ACCESSLEVELSET = 75 EV_VIDEOPHONESTATUS = 76 EV_REGISTERMAP = 77 FC_UNITDIMREQUESTSTATUS = 131 FC_UNITSENSSET = 136 FC_UNITREQUESTSENSSTATUS = 137 FC_NODERESETSET = 155 FC_UNITAUDIOBASICSET = 159 FC_UNITDIMSET = 162 FC_UNITSWITCHSET = 163 FC_UNITCONTROLSET = 168 FC_TIMEDATE = 170 FC_UNITIRTXSET = 173 FC_UNITDUOSWITCHSET = 182 FC_CHECKIRRXCODE = 192 FC_UNITVIDEOMUXSET = 193 FC_UNITAVMATRIXSET = 202 FC_UNITALARMSET = 204 FC_UNITAUDIOEXTSET = 208 FC_NODEDATABASEREQUESTSTATUS = 209 FC_APPLICATIONTASKSET = 212 FC_REQUESTAPPLICATIONTASKSTATUS = 213 FC_CLIENTCONNECTSET = 214 FC_HEARTBEATREQUESTSTATUS = 215 FC_REQUESTTIMEDATE = 216 FC_SCHEDULESET = 217 FC_REQUESTSCHEDULE = 218 FC_NODEMANAGEMENTSET = 219 FC_REQUESTNODEMANAGEMENT = 220 FC_NODEDATABASESET = 221 FC_ACCESSLEVELSET = 222 FC_VIDEOPHONESET = 223 FC_REGISTERMAP = 224 UNKNOWN = 255 @classmethod def _missing_(cls, value: object) -> MsgType: return cls.UNKNOWN @dataclass class Packet: """Basic structure for a packet.""" cmdName: str = field(init=False) cmdCode: int = field(repr=False) method: int data: Deque[int] cls: BaseMessage | None = field(init=False) def __lt__(self, other: Packet) -> bool: if isinstance(other.cls, EV_HEARTBEATSTATUS_1): return True return False def __post_init__(self) -> None: """fill in the command name, make the subsclass.""" try: self.cmdName = MsgType(self.cmdCode).name except ValueError: self.cmdName = "UNKNOWN" self.data = collections.deque(self.data) tmp = getattr(sys.modules[__name__], f"{self.cmdName}_{self.method}", None) if tmp: self.cls = tmp(self.data) # self.data should be empty once the message consumed it if len(self.data) != 0: print(f"ERROR!!! Not all data consumed: {self}") else: self.cls = None def calc_value(msb: int, lsb: int) -> int: return (256 * msb) + lsb class BaseMessage: def __init__(self, data: Deque[int]) -> None: pass def to_json(self) -> str: return json.dumps(self.to_json_basic()) def to_json_basic(self) -> dict[str, str]: """ Create JSON structure with generic attributes """ me = {} me["name"] = str(self.__class__.__name__) me.update(self.__dict__.copy()) for key in me.copy(): if key == "name": continue if isinstance(me[key], str): continue if callable(getattr(self, key)) or key.startswith("__"): del me[key] if isinstance(me[key], Enum): me[key] = me[key].name if isinstance(me[key], (bytes, bytearray)): me[key] = str(me[key], "utf-8") return me def __repr__(self) -> str: return self.to_json() class BaseNodeUnitMessage(BaseMessage): address: int unit: int def __init__(self, data: Deque[int]) -> None: super().__init__(data) self.address = data.popleft() self.unit = data.popleft() class BaseNodeUnitTypeMessage(BaseNodeUnitMessage): unitType: int def __init__(self, data: Deque[int]) -> None: super().__init__(data) self.unitType = data.popleft() class EV_HEARTBEATSTATUS_1(BaseMessage): pass class EV_CLIENTCONNECTSET_3(BaseMessage): loginOk: bool def __init__(self, data: Deque[int]) -> None: self.loginOK = data.popleft() @unique class DbState(Enum): Empty = 0 Busy = 1 Ready = 2 class EV_NODEDATABASEINFO_5(BaseMessage): state: DbState def __init__(self, data: Deque[int]) -> None: self.state = data.popleft() class EV_NODEDATABASEINFO_0(BaseMessage): numNode: int def __init__(self, data: Deque[int]) -> None: self.numNode = data.popleft() class EV_UNITMACROCOMMAND_0(BaseNodeUnitMessage): event: int state: int code1: int code2: int def __init__(self, data: Deque[int]) -> None: super().__init__(data) self.event = data.popleft() self.state = data.popleft() self.code1 = data.popleft() self.code2 = data.popleft() @unique class NodeType(Enum): Standard = 1 Gateway = 4 Modem = 8 Gui = 32 UNKNOWN = 255 @classmethod def _missing_(cls, value: object) -> NodeType: return cls.UNKNOWN class EV_NODEDATABASEINFO_1(BaseMessage): index: int address: int nodeName: str numUnits: int nodeType: NodeType nodeTypeName: str flags: int def __init__(self, data: Deque[int]) -> None: self.index = data.popleft() self.address = data.popleft() # next 4 are no needed [data.popleft() for _i in range(4)] self.nodeName = "".join([chr(data.popleft()) for _i in range(data.popleft())]) self.numUnits = data.popleft() self.nodeType = NodeType(data.popleft()) self.nodeTypeName = NodeType(self.nodeType).name self.flags = data.popleft() @unique class UnitType(Enum): DIM = 1 SWITCH = 2 CONTROL = 3 SENS = 4 AUDIO_EXT = 5 VIRTUAL = 7 DUOSWITCH = 8 AUDIO_BASIC = 10 AVMATRIC = 11 IRTX = 12 VIDEOMUX = 14 UNKNOWN = 255 @classmethod def _missing_(cls, value: object) -> UnitType: return cls.UNKNOWN class EV_NODEDATABASEINFO_2(BaseMessage): address: int unit: int laddress: int lunit: int unitName: str unitType: int unitTypeName: str unitFlags: int def __init__(self, data: Deque[int]) -> None: self.address = data.popleft() self.unit = data.popleft() self.laddress = data.popleft() self.lunit = data.popleft() self.unitName = "".join([chr(data.popleft()) for _i in range(data.popleft())]) self.unitType = data.popleft() self.unitTypeName = UnitType(self.unitType).name self.unitFlags = data.popleft() @final @unique class SwitchStatus(Enum): OFF = 0 ON = 1 PIRTIMED = 2 class EV_UNITSWITCHSTATUS_0(BaseNodeUnitTypeMessage): state: int stateName: str def __init__(self, data: Deque[int]) -> None: super().__init__(data) # config, reserved data.popleft() self.state = data.popleft() self.stateName = SwitchStatus(self.state).name class EV_UNITDIMSTATUS_0(BaseNodeUnitTypeMessage): state: int stateName: str dimValue: int def __init__(self, data: Deque[int]) -> None: super().__init__(data) # config, reserved data.popleft() self.state = data.popleft() self.stateName = SwitchStatus(self.state).name self.dimValue = data.popleft() @final @unique class DuoswitchStatus(Enum): IDLE = 0 IDLE_DOWN = 1 IDLE_UP = 2 BUSY_DOWN = 3 BUSY_UP = 4 class EV_UNITDUOSWITCHSTATUS_0(BaseNodeUnitTypeMessage): state: int stateName: str def __init__(self, data: Deque[int]) -> None: super().__init__(data) # config, reserved data.popleft() self.state = data.popleft() self.stateName = DuoswitchStatus(self.state).name @final @unique class SensType(Enum): TEMPERATURE = 0 PH = 1 LUX = 2 AMPERE = 3 @final @unique class SensControl(Enum): OFF = 0 ON = 1 @final @unique class SensState(Enum): IDLE = 0 HEATING = 1 COOLING = 2 @final @unique class SensPreset(Enum): SUN = 0 HALF_SUN = 1 MOON = 2 HALF_MOON = 3 @final @unique class SensWorkingmode(Enum): AUTO = 0 HEATING = 1 COOLING = 2 DRY = 3 FAN = 4 UNKNOWN = 255 @final @unique class SensFanspeed(Enum): SPEED1 = 0 SPEED2 = 1 SPEED3 = 2 SPEED4 = 3 SPEED5 = 4 AUTO = 255 def sens_calc_value(msb: int, lsb: int) -> float: val = calc_value(msb, lsb) if (val & (1 << 15)) != 0: val = val - (1 << 16) return val / 10 class EV_UNITSENSSTATUS_0(BaseNodeUnitTypeMessage): config: int configName: str controlState: int controlStateName: str state: int stateName: str preset: int presetName: str value: float sun: float halfsun: float moon: float halfmoon: float def __init__(self, data: Deque[int]) -> None: super().__init__(data) self.config = data.popleft() self.configName = SensType(self.config).name self.controlState = data.popleft() self.controlStateName = SensControl(self.controlState).name self.state = data.popleft() self.stateName = SensState(self.state).name self.preset = data.popleft() self.presetName = SensPreset(self.preset).name self.value = sens_calc_value(data.popleft(), data.popleft()) self.sun = sens_calc_value(data.popleft(), data.popleft()) self.halfsun = sens_calc_value(data.popleft(), data.popleft()) self.moon = sens_calc_value(data.popleft(), data.popleft()) self.halfmoon = sens_calc_value(data.popleft(), data.popleft()) class EV_UNITSENSSTATUS_1(EV_UNITSENSSTATUS_0): offset: float swing: float workingMode: int workingModeName: str fanSpeed: int fanSpeedName: str swingMode: int swingModeName: str def __init__(self, data: Deque[int]) -> None: super().__init__(data) self.offset = sens_calc_value(data.popleft(), data.popleft()) self.swing = sens_calc_value(data.popleft(), data.popleft()) self.workingMode = data.popleft() self.workingModeName = SensWorkingmode(self.workingMode).name self.fanSpeed = data.popleft() self.fanSpeedName = SensFanspeed(self.fanSpeed).name self.swingMode = data.popleft() self.swingModeName = SensControl(self.swingMode).name @final @unique class ControLStatus(Enum): OFF = 0 ON = 1 class EV_UNITCONTROLSTATUS_0(BaseNodeUnitTypeMessage): status: int statusName: str def __init__(self, data: Deque[int]) -> None: super().__init__(data) # config ignore data.popleft() self.status = data.popleft() self.statusName = ControLStatus(self.status).name pyDuotecno-2024.10.1/duotecno/py.typed000066400000000000000000000000001470566666300175020ustar00rootroot00000000000000pyDuotecno-2024.10.1/duotecno/unit.py000066400000000000000000000234451470566666300173560ustar00rootroot00000000000000from __future__ import annotations from typing import Awaitable, Callable, TYPE_CHECKING import logging from duotecno.protocol import ( EV_UNITDUOSWITCHSTATUS_0, EV_UNITDIMSTATUS_0, EV_UNITSWITCHSTATUS_0, EV_UNITSENSSTATUS_0, EV_UNITSENSSTATUS_1, EV_UNITCONTROLSTATUS_0, EV_UNITMACROCOMMAND_0, calc_value, ) if TYPE_CHECKING: from duotecno.node import Node from duotecno.protocol import BaseMessage class BaseUnit: _unitType: int = 0 _available: bool = True _on_status_update: list[Callable[[], Awaitable[None]]] = [] name: str = "" unit: int = 0 available: bool = True def __init__( self, node: Node, name: str, unit: int, writer: Callable[[str], Awaitable[None]], ) -> None: self._log = logging.getLogger("pyduotecno-unit") self.node = node self.name = name self.unit = unit self.writer = writer self._log.info( f"New Unit: '{self.node.name}' => '{self.name}' = {type(self).__name__}" ) async def enable(self) -> None: await self._update({"available": True}) async def disable(self) -> None: await self._update({"available": False}) def is_available(self) -> bool: return self._available def get_node_address(self) -> int: return self.node.get_address() def get_node_name(self) -> str: return self.node.get_name() def get_name(self) -> str: return self.name def get_number(self) -> int: return self.unit def on_status_update(self, meth: Callable[[], Awaitable[None]]) -> None: self._on_status_update.append(meth) def __repr__(self) -> str: items = [] for k, v in self.__dict__.items(): if k not in ["_log", "writer", "node"]: items.append(f"{k} = {v!r}") return "{}[{}]".format(type(self), ", ".join(items)) async def handlePacket(self, packet: BaseMessage) -> None: self._log.debug(f"Unhandled unit packet: {packet}") async def requestStatus(self) -> None: if self._unitType: await self.writer( f"[209,3,{self.node.address},{self.unit},{self._unitType}]" ) async def _update(self, data: dict[str, str | int | float | bool]) -> None: for key, new_val in data.items(): cur_val = getattr(self, f"_{key}", None) if cur_val is None or cur_val != new_val: setattr(self, f"_{key}", new_val) for m in self._on_status_update: await m() class SensUnit(BaseUnit): _unitType: int = 4 _state: int = 0 _preset: int = 0 _cur_temp: float = 0.0 _setp_sun: float = 0.0 _setp_hsun: float = 0.0 _setp_moon: float = 0.0 _setp_hmoon: float = 0.0 _offset: float = 0.0 _swing_angle: float = 0.0 _woking_mode: float = 0.0 _fan_speed: float = 0.0 _swing_mode: float = 0.0 async def handlePacket(self, packet: BaseMessage) -> None: if isinstance(packet, EV_UNITSENSSTATUS_0) or isinstance( packet, EV_UNITSENSSTATUS_1 ): tmp: dict[str, str | int | float] = {} if packet.controlState == 0: tmp["state"] = 0 else: tmp["state"] = packet.state tmp["preset"] = packet.preset tmp["cur_temp"] = packet.value tmp["setp_sun"] = packet.sun tmp["setp_hsun"] = packet.halfsun tmp["setp_moon"] = packet.moon tmp["setp_hmoon"] = packet.halfmoon if isinstance(packet, EV_UNITSENSSTATUS_1): tmp["offset"] = packet.offset tmp["swing_angle"] = packet.swingMode tmp["working_mode"] = packet.workingMode tmp["fan_speed"] = packet.fanSpeed tmp["swing_mode"] = packet.swingMode await self._update(tmp) return if isinstance(packet, EV_UNITMACROCOMMAND_0): if packet.event == 9: await self._update({"state": packet.state}) elif packet.event == 10: await self._update({"mode": packet.state}) # TODO event 11 elif packet.event == 12: await self._update({"working_mode": packet.state}) elif packet.event == 13: await self._update({"fan_speed": packet.state}) # TODO event 14 elif packet.event == 15: await self._update({"Swing_mode": packet.state}) return await super().handlePacket(packet) async def requestStatus(self) -> None: # We should never do this for sensunits, as not all senseunits will work pass async def set_preset(self, preset: int) -> None: await self.writer(f"[136,13,{self.node.address},{self.unit},{preset}]") async def turn_off(self) -> None: await self.writer(f"[136,3,{self.node.address},{self.unit},0]") async def turn_on(self) -> None: await self.writer(f"[136,3,{self.node.address},{self.unit},1]") async def set_temp(self, temp: float) -> None: msb, lsb = divmod(temp * 10, 256) msb = int(msb) lsb = int(lsb) await self.writer( f"[136,1,{self.node.address},{self.unit},{self._preset},{msb},{lsb}]" ) def get_state(self) -> int: return self._state def get_cur_temp(self) -> float: return self._cur_temp def get_target_temp(self) -> float: if self._preset == 0: return self._setp_sun elif self._preset == 1: return self._setp_hsun elif self._preset == 2: return self._setp_moon else: return self._setp_hmoon def get_preset(self) -> int: return self._preset class DimUnit(BaseUnit): _unitType: int = 1 _state: int = 0 _value: int = 0 async def handlePacket(self, packet: BaseMessage) -> None: if isinstance(packet, EV_UNITDIMSTATUS_0): await self._update({"state": packet.state, "value": packet.dimValue}) return if isinstance(packet, EV_UNITMACROCOMMAND_0): if packet.event == 6: await self._update({"state": packet.state}) elif packet.state == 8: await self._update({"value": calc_value(packet.code1, packet.code2)}) return await super().handlePacket(packet) def is_on(self) -> bool: if self._state == 0: return False return True def get_dimmer_state(self) -> int: return self._value async def set_dimmer_state(self, value: int | None = None) -> None: # val > 0 => turn on # val 0 but not None => turn off # val = None => restore if value and value > 0: # set state and turn on await self.writer(f"[162,10,{self.node.address},{self.unit}]") await self.writer(f"[162,3,{self.node.address},{self.unit},{value}]") elif value is not None: # turn off await self.writer(f"[162,9,{self.node.address},{self.unit}]") else: # send turn on (restore state) await self.writer(f"[162,10,{self.node.address},{self.unit}]") class SwitchUnit(BaseUnit): _unitType: int = 2 _state: int = 0 async def handlePacket(self, packet: BaseMessage) -> None: if isinstance(packet, EV_UNITSWITCHSTATUS_0): await self._update({"state": packet.state}) return if isinstance(packet, EV_UNITMACROCOMMAND_0): if packet.event == 5: # pir timed await self._update({"state": 2}) else: await self._update({"state": packet.state}) return await super().handlePacket(packet) def is_on(self) -> bool: if self._state == 0: return False return True async def turn_on(self) -> None: """Switch on.""" await self.writer(f"[163,3,{self.node.address},{self.unit}]") async def turn_off(self) -> None: """Switch off.""" await self.writer(f"[163,2,{self.node.address},{self.unit}]") class DuoswitchUnit(BaseUnit): _unitType: int = 8 _state: int = 1 async def handlePacket(self, packet: BaseMessage) -> None: if isinstance(packet, EV_UNITDUOSWITCHSTATUS_0): await self._update({"state": packet.state}) return await super().handlePacket(packet) def is_opening(self) -> bool: if self._state == 4: return True return False def is_closing(self) -> bool: if self._state == 3: return True return False def is_closed(self) -> bool: if self._state == 1: return True return False async def open(self) -> None: """Move up.""" await self.stop() await self.writer(f"[182,4,{self.node.address},{self.unit}]") async def close(self) -> None: """Move down.""" await self.stop() await self.writer(f"[182,5,{self.node.address},{self.unit}]") async def stop(self) -> None: """Stop the motor.""" await self.writer(f"[182,3,{self.node.address},{self.unit}]") class VirtualUnit(BaseUnit): _unitType: int = 7 _status: int = 0 async def handlePacket(self, packet: BaseMessage) -> None: if isinstance(packet, EV_UNITCONTROLSTATUS_0): await self._update({"status": packet.status}) return if isinstance(packet, EV_UNITMACROCOMMAND_0): await self._update({"status": packet.state}) return await super().handlePacket(packet) def is_on(self) -> bool: if self._status == 0: return False return True class ControlUnit(VirtualUnit): _unitType: int = 3 pass pyDuotecno-2024.10.1/examples/000077500000000000000000000000001470566666300160135ustar00rootroot00000000000000pyDuotecno-2024.10.1/examples/read.py000066400000000000000000000015371470566666300173060ustar00rootroot00000000000000"""Basic example to read the bus.""" import argparse import asyncio import logging import sys from duotecno.controller import PyDuotecno async def test(host, port, passw): """Basic aio call.""" tmp = PyDuotecno() await tmp.connect(host, port, passw) await asyncio.sleep(6000000000) parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument("--host", help="The host to connect to") parser.add_argument("--port", help="The port to connect to") parser.add_argument("--password", help="The password") args = parser.parse_args() logging.basicConfig( stream=sys.stdout, level=logging.DEBUG, format="%(asctime)s %(levelname)-8s %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) logging.getLogger("asyncio").setLevel(logging.DEBUG) asyncio.run(test(args.host, args.port, args.password)) pyDuotecno-2024.10.1/mypy.ini000066400000000000000000000013151470566666300156740ustar00rootroot00000000000000[mypy] python_version = 3.10 plugins = pydantic.mypy show_error_codes = true follow_imports = silent local_partial_types = true strict_equality = true no_implicit_optional = true warn_incomplete_stub = true warn_redundant_casts = true warn_unused_configs = true warn_unused_ignores = true enable_error_code = ignore-without-code, redundant-self, truthy-iterable check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true disallow_untyped_calls = true disallow_untyped_decorators = true disallow_untyped_defs = true warn_return_any = true warn_unreachable = true [pydantic-mypy] init_forbid_extra = true init_typed = true warn_required_dynamic_aliases = true warn_untyped_fields = true pyDuotecno-2024.10.1/pyproject.toml000066400000000000000000000050251470566666300171130ustar00rootroot00000000000000[build-system] requires = ["setuptools", "wheel"] [project] name = "pyDuotecno" license = {text = "Apache"} version = "2024.10.1" description = "Open-source home automation platform running on Python 3." readme = "README.md" authors = [ {name = "Maikel Punie", email = "maikel.punie@gmail.com"} ] keywords = ["home", "duotecno", "automation"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Topic :: Home Automation", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules", ] requires-python = ">=3.10.0" dependencies = [] [project.urls] "Source Code" = "https://github.com/Cereal2nd/pyDuotecno" "Bug Reports" = "https://github.com/Cereal2nd/pyDuotecno/issues" [tool.setuptools] platforms = ["any"] zip-safe = false include-package-data = true [tool.setuptools.packages.find] exclude = ["tests", "tests.*", "examples", "examples/*"] [tool.bumpver] current_version = "2024.10.1" version_pattern = "YYYY.MM.INC0" commit_message = "bump version {old_version} -> {new_version}" commit = true tag = true push = true [tool.bumpver.file_patterns] "pyproject.toml" = [ '^version = "{version}"', '^current_version = "{version}"', ] [tool.ruff] # Exclude a variety of commonly ignored directories. exclude = [ ".eggs", ".git", ".git-rewrite", ".mypy_cache", ".ruff_cache", ".venv", "__pypackages__", "_build", "build", "dist", "venv", ] # Same as Black. line-length = 88 indent-width = 4 # Assume Python 3.8 target-version = "py39" [tool.ruff.lint] # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. select = ["E", "F"] ignore = [] # Allow fix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] unfixable = [] # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" [tool.ruff.format] # Like Black, use double quotes for strings. quote-style = "double" # Like Black, indent with spaces, rather than tabs. indent-style = "space" # Like Black, respect magic trailing commas. skip-magic-trailing-comma = false # Like Black, automatically detect the appropriate line ending. line-ending = "auto" pyDuotecno-2024.10.1/requirements-dev.txt000066400000000000000000000000471470566666300202360ustar00rootroot00000000000000-e . pre-commit pytest pytest-asyncio pyDuotecno-2024.10.1/requirements.txt000066400000000000000000000000131470566666300174530ustar00rootroot00000000000000setuptools pyDuotecno-2024.10.1/setup.py000066400000000000000000000001101470566666300156770ustar00rootroot00000000000000"""Setuptools voor pyDuotecno.""" from setuptools import setup setup()