pax_global_header00006660000000000000000000000064145770135630014525gustar00rootroot0000000000000052 comment=01efb17de11e5aff4b40dde564f74ea483baf204 zweckj-aiotedee-01efb17/000077500000000000000000000000001457701356300152065ustar00rootroot00000000000000zweckj-aiotedee-01efb17/.github/000077500000000000000000000000001457701356300165465ustar00rootroot00000000000000zweckj-aiotedee-01efb17/.github/workflows/000077500000000000000000000000001457701356300206035ustar00rootroot00000000000000zweckj-aiotedee-01efb17/.github/workflows/pypi.yaml000066400000000000000000000014641457701356300224550ustar00rootroot00000000000000on: workflow_dispatch: push: branches: - 'main' - 'releases/**' - 'feature/**' paths: - 'setup.py' jobs: pypi-publish: name: Upload release to PyPI runs-on: ubuntu-latest # permissions: # id-token: write steps: - name: Checkout uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.x" - name: Build source and wheel distributions run: | python -m pip install --upgrade build twine python -m build twine check --strict dist/* - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} zweckj-aiotedee-01efb17/.gitignore000066400000000000000000000035611457701356300172030ustar00rootroot00000000000000#Eclipse .settings/ .project .pydevproject # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so pytedee/pytedee/example2.py config.json # 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 pytedee/.pyre/ #Pip Pipfile zweckj-aiotedee-01efb17/LICENSE000066400000000000000000000020631457701356300162140ustar00rootroot00000000000000MIT License Copyright (c) 2020 joerg.wolff@gmx.de Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. zweckj-aiotedee-01efb17/README.md000066400000000000000000000023511457701356300164660ustar00rootroot00000000000000# Python Tedee Async Client Package This is a Tedee Lock Client package. It is an async implementation of [joerg65's original package](https://github.com/joerg65/pytedee.git). ## Install: ### From pip ``` pip install pytedee-async ``` ### Locally ```python pipenv install -e . # or python3 setup.py install ``` ## Try it out - Generate personal key. Instructions: https://tedee-tedee-api-doc.readthedocs-hosted.com/en/latest/howtos/authenticate.html#personal-access-key Minimal scopes required for enable integration are: - Devices.Read - Lock.Operate - with `example.py`: Create a file `config.json` next to `example.py`: ```json { "personalToken": "" } ``` cd into the directory of those to files and run ``` python example.py ``` - Initiate an instance of `TedeeClient` ```python from pytedee_async import TedeeClient pk = "" # through init client = TedeeClient(pk) # is initialized with no locks client.get_locks() # get the locks # through classmethod # will initialize directly with all locks you have client = await TedeeClient.create(pk) ``` - the locks are avialable in a dictionary `client.locks_dict` with the key of the dict being the serial number of each lock, or in a list `client.locks` zweckj-aiotedee-01efb17/example.py000066400000000000000000000037341457701356300172220ustar00rootroot00000000000000"""Test run.""" import asyncio import json from pathlib import Path from pytedee_async import TedeeClient from pytedee_async.lock import TedeeLock async def main(): with open(f"{Path(__file__).parent}/config.json", encoding="utf-8") as f: data = json.load(f) # personal_token = data["personalToken"] ip = data["ip"] local_token = data["localToken"] # client = await TedeeClient.create(personal_token, local_token, ip) client = TedeeClient(local_ip=ip, local_token=local_token) # await client.cleanup_webhooks_by_host("test") # bridge = await client.get_local_bridge() # await client.delete_webhook(5) # await client.register_webhook("http://192.168.1.151/events") await client.get_locks() # await client.sync() # await client.sync() # bridges = await client.get_bridges() # client = await TedeeClient.create( # personal_token, local_token, ip, bridge_id=bridges[0].bridge_id # ) locks = client.locks print(1) await client.sync() print(2) await client.sync() for lock in locks: print("----------------------------------------------") print("Lock name: " + lock.lock_name) print("Lock id: " + str(lock.lock_id)) print("Lock Battery: " + str(lock.battery_level)) print("Is Locked: " + str(client.is_locked(lock.lock_id))) print("Is Unlocked: " + str(client.is_unlocked(lock.lock_id))) # await client.register_webhook("http://test.local", headers=[{"Authorization": "Basic " + "test"}]) print(3) await client.sync() await client.unlock(lock.lock_id) # await asyncio.sleep(5) # await client.open(lock.id) # await asyncio.sleep(5) # await client.open(lock.id) # await asyncio.sleep(5) # await client.lock(lock.id) # await asyncio.sleep(5) # await client.unlock(lock.id) # await asyncio.sleep(5) # await client.pull(lock.id) asyncio.run(main()) zweckj-aiotedee-01efb17/pytedee_async/000077500000000000000000000000001457701356300200425ustar00rootroot00000000000000zweckj-aiotedee-01efb17/pytedee_async/__init__.py000066400000000000000000000001351457701356300221520ustar00rootroot00000000000000"""Import modules for pytedee_async.""" from .exception import * from .tedee_client import * zweckj-aiotedee-01efb17/pytedee_async/bridge.py000066400000000000000000000011011457701356300216410ustar00rootroot00000000000000""" Class describing a tedee bridge. """ class TedeeBridge: """Dataclass for tedee bridge.""" def __init__(self, bridge_id: int, serial: str, name: str): self._bridge_id = bridge_id self._serial = serial self._name = name @property def bridge_id(self) -> int: """Return bridge id.""" return self._bridge_id @property def serial(self) -> str: """Return bridge serial.""" return self._serial @property def name(self) -> str: """Return bridge name.""" return self._name zweckj-aiotedee-01efb17/pytedee_async/const.py000066400000000000000000000007101457701356300215400ustar00rootroot00000000000000"""Constants for pytedee_async.""" API_URL_BASE = "https://api.tedee.com/api/v1.32/" API_URL_DEVICE = API_URL_BASE + "my/device/" API_URL_LOCK = API_URL_BASE + "my/lock/" API_URL_SYNC = API_URL_LOCK + "sync" API_URL_BRIDGE = API_URL_BASE + "my/bridge/" API_PATH_UNLOCK = "/operation/unlock" API_PATH_LOCK = "/operation/lock" API_PATH_PULL = "/operation/pull" API_LOCAL_VERSION = "v1.0" API_LOCAL_PORT = "80" TIMEOUT = 10 UNLOCK_DELAY = 5 LOCK_DELAY = 5 zweckj-aiotedee-01efb17/pytedee_async/exception.py000066400000000000000000000010641457701356300224130ustar00rootroot00000000000000"""Exceptions for pytedee_async.""" class TedeeClientException(Exception): """General Tedee client exception.""" class TedeeAuthException(Exception): """Authentication exception against remote API.""" class TedeeLocalAuthException(Exception): """Authentication exception against local API.""" class TedeeRateLimitException(Exception): """Rate limit exception (only happens on cloud API).""" class TedeeWebhookException(Exception): """Webhook exception.""" class TedeeDataUpdateException(Exception): """Data update exception.""" zweckj-aiotedee-01efb17/pytedee_async/helpers.py000066400000000000000000000046531457701356300220660ustar00rootroot00000000000000"""Helper functions for pytedee_async.""" import asyncio from http import HTTPStatus from typing import Any, Mapping import aiohttp from .const import API_URL_DEVICE, TIMEOUT from .exception import TedeeAuthException, TedeeClientException, TedeeRateLimitException async def is_personal_key_valid( personal_key: str, session: aiohttp.ClientSession, timeout: int = TIMEOUT, ) -> bool: """Check if personal key is valid.""" try: response = await session.get( API_URL_DEVICE, headers={ "Content-Type": "application/json", "Authorization": "PersonalKey " + personal_key, }, timeout=timeout, ) except (aiohttp.ClientError, aiohttp.ServerConnectionError, TimeoutError): return False await asyncio.sleep(0.1) if response.status in ( HTTPStatus.OK, HTTPStatus.CREATED, HTTPStatus.ACCEPTED, ): return True return False async def http_request( url: str, http_method: str, headers: Mapping[str, str] | None, session: aiohttp.ClientSession, timeout: int = TIMEOUT, json_data: Any = None, ) -> Any: """HTTP request wrapper.""" try: response = await session.request( http_method, url, headers=headers, json=json_data, timeout=timeout, ) except ( aiohttp.ServerConnectionError, aiohttp.ClientError, TimeoutError, ) as exc: raise TedeeClientException(f"Error during http call: {exc}") from exc await asyncio.sleep(0.1) status_code = response.status if response.status in ( HTTPStatus.OK, HTTPStatus.CREATED, HTTPStatus.ACCEPTED, HTTPStatus.NO_CONTENT, ): return await response.json() if status_code == HTTPStatus.UNAUTHORIZED: raise TedeeAuthException("Authentication failed.") if status_code == HTTPStatus.TOO_MANY_REQUESTS: raise TedeeRateLimitException("Tedee API Rate Limit.") if status_code == HTTPStatus.NOT_FOUND: raise TedeeClientException("Resource not found.") if status_code == HTTPStatus.NOT_ACCEPTABLE: raise TedeeClientException("Request not acceptable.") if status_code == HTTPStatus.CONFLICT: raise TedeeClientException("Conflict.") raise TedeeClientException(f"Error during HTTP request. Status code {status_code}") zweckj-aiotedee-01efb17/pytedee_async/lock.py000066400000000000000000000107441457701356300213520ustar00rootroot00000000000000"""Tedee Lock Object.""" from enum import IntEnum class TedeeLockState(IntEnum): """Tedee Lock State.""" UNCALIBRATED = 0 CALIBRATING = 1 UNLOCKED = 2 HALF_OPEN = 3 UNLOCKING = 4 LOCKING = 5 LOCKED = 6 PULLED = 7 PULLING = 8 UNKNOWN = 9 UPDATING = 18 class TedeeLock: """Tedee Lock.""" def __init__( self, lock_name: str, lock_id: int, lock_type: int, state: int = 0, battery_level: int | None = None, is_connected: bool = False, is_charging: bool = False, state_change_result: int = 0, is_enabled_pullspring: bool = False, duration_pullspring: int = 0, ) -> None: """Initialize a new lock.""" self._lock_name = lock_name self._lock_id = lock_id self._lock_type = lock_type self._state = state self._battery_level = battery_level self._is_connected = is_connected self._is_charging = is_charging self._state_change_result = state_change_result self._duration_pullspring = duration_pullspring self._is_enabled_pullspring = is_enabled_pullspring @property def lock_name(self) -> str: """Return the name of the lock.""" return self._lock_name @property def lock_id(self) -> int: """Return the id of the lock.""" return self._lock_id @property def lock_type(self) -> str: """Return the type of the lock.""" if self._lock_type == 2: return "Tedee PRO" elif self._lock_type == 4: return "Tedee GO" else: return "Unknown Model" @property def is_state_locked(self) -> bool: """Return true if the lock is locked.""" return self._state == TedeeLockState.LOCKED @property def is_state_unlocked(self) -> bool: """Return true if the lock is unlocked.""" return self._state == TedeeLockState.UNLOCKED @property def is_state_jammed(self) -> bool: """Return true if the lock is jammed.""" return self._state_change_result == 1 @property def state(self) -> TedeeLockState: """Return the state of the lock.""" return TedeeLockState(self._state) @state.setter def state(self, status: int): self._state = status @property def state_change_result(self) -> int: """Return the state change result of the lock.""" return self._state_change_result @state_change_result.setter def state_change_result(self, result: int): self._state_change_result = result @property def battery_level(self) -> int | None: """Return the battery level of the lock.""" return self._battery_level @battery_level.setter def battery_level(self, level): self._battery_level = level @property def is_connected(self) -> bool: """Return true if the lock is connected.""" return self._is_connected @is_connected.setter def is_connected(self, connected): self._is_connected = connected @property def is_charging(self) -> bool: """Return true if the lock is charging.""" return self._is_charging @is_charging.setter def is_charging(self, value: bool): self._is_charging = value @property def is_enabled_pullspring(self) -> bool: """Return true if the lock is charging.""" return bool(self._is_enabled_pullspring) @is_enabled_pullspring.setter def is_enabled_pullspring(self, value: bool): self._is_enabled_pullspring = value @property def duration_pullspring(self) -> int: """Return the duration of the pullspring.""" return self._duration_pullspring @duration_pullspring.setter def duration_pullspring(self, duration: int): self._duration_pullspring = duration def to_dict(self) -> dict[str, str | int | bool | None]: """Return a dict representation of the lock.""" return { "lock_name": self._lock_name, "lock_id": self._lock_id, "lock_type": self._lock_type, "state": self._state, "battery_level": self._battery_level, "is_connected": self._is_connected, "is_charging": self._is_charging, "state_change_result": self._state_change_result, "is_enabled_pullspring": self._is_enabled_pullspring, "duration_pullspring": self._duration_pullspring, } zweckj-aiotedee-01efb17/pytedee_async/py.typed000066400000000000000000000000001457701356300215270ustar00rootroot00000000000000zweckj-aiotedee-01efb17/pytedee_async/tedee_client.py000066400000000000000000000463561457701356300230560ustar00rootroot00000000000000"""The TedeeClient class.""" from __future__ import annotations import asyncio import hashlib import logging import time from http import HTTPMethod from typing import Any, ValuesView import aiohttp from .bridge import TedeeBridge from .const import ( API_LOCAL_PORT, API_LOCAL_VERSION, API_PATH_LOCK, API_PATH_PULL, API_PATH_UNLOCK, API_URL_BRIDGE, API_URL_LOCK, API_URL_SYNC, LOCK_DELAY, TIMEOUT, UNLOCK_DELAY, ) from .exception import ( TedeeAuthException, TedeeClientException, TedeeDataUpdateException, TedeeLocalAuthException, TedeeRateLimitException, TedeeWebhookException, ) from .helpers import http_request from .lock import TedeeLock, TedeeLockState NUM_RETRIES = 3 _LOGGER = logging.getLogger(__name__) class TedeeClient: """Client for interacting with the Tedee API.""" def __init__( self, personal_token: str | None = None, local_token: str | None = None, local_ip: str | None = None, timeout: int = TIMEOUT, bridge_id: int | None = None, session: aiohttp.ClientSession | None = None, ): """Constructor""" self._available = False self._personal_token = personal_token self._locks_dict: dict[int, TedeeLock] = {} self._local_token = local_token self._local_ip = local_ip self._timeout = timeout self._bridge_id = bridge_id self._use_local_api: bool = bool(local_token and local_ip) self._last_local_call: float | None = None if session is None: self._session = aiohttp.ClientSession() else: self._session = session _LOGGER.debug("Using local API: %s", str(self._use_local_api)) # Create the api header with new token" self._api_header: dict[str, str] = { "Content-Type": "application/json", "Authorization": "PersonalKey " + str(self._personal_token), } self._local_api_path: str = ( f"http://{local_ip}:{API_LOCAL_PORT}/{API_LOCAL_VERSION}" ) @classmethod async def create( cls, personal_token: str | None = None, local_token: str | None = None, local_ip: str | None = None, bridge_id: int | None = None, timeout=TIMEOUT, ) -> TedeeClient: """Create a new instance of the TedeeClient, which is initialized.""" self = cls(personal_token, local_token, local_ip, timeout, bridge_id) await self.get_locks() return self @property def locks(self) -> ValuesView: """Return a list of locks""" return self._locks_dict.values() @property def locks_dict(self) -> dict[int, TedeeLock]: """Return all locks.""" return self._locks_dict async def get_locks(self) -> None: """Get the list of registered locks""" local_call_success, result = await self._local_api_call("/lock", HTTPMethod.GET) if not local_call_success: r = await http_request( API_URL_LOCK, HTTPMethod.GET, self._api_header, self._session, self._timeout, ) result = r["result"] _LOGGER.debug("Locks %s", result) if result is None: raise TedeeClientException('No data returned in "result" from get_locks') for lock_json in result: if self._bridge_id: # if bridge id is set, only get locks for that bridge connected_to_id: int | None = lock_json.get("connectedToId") if connected_to_id is not None and connected_to_id != self._bridge_id: continue lock_id = lock_json["id"] lock_name = lock_json["name"] lock_type = lock_json["type"] ( is_connected, state, battery_level, is_charging, state_change_result, ) = self.parse_lock_properties(lock_json) ( is_enabled_pullspring, duration_pullspring, ) = self.parse_pull_spring_settings(lock_json) lock = TedeeLock( lock_name, lock_id, lock_type, state, battery_level, is_connected, is_charging, state_change_result, is_enabled_pullspring, duration_pullspring, ) self._locks_dict[lock_id] = lock if lock_id is None: raise TedeeClientException("No lock found") _LOGGER.debug("Locks retrieved successfully...") async def sync(self) -> None: """Sync locks""" _LOGGER.debug("Syncing locks") local_call_success, result = await self._local_api_call("/lock", HTTPMethod.GET) if not local_call_success: r = await http_request( API_URL_SYNC, HTTPMethod.GET, self._api_header, self._session, self._timeout, ) result = r["result"] if result is None: raise TedeeClientException('No data returned in "result" from sync') for lock_json in result: if self._bridge_id: # if bridge id is set, only get locks for that bridge connected_to_id: int | None = lock_json.get("connectedToId") if connected_to_id is not None and connected_to_id != self._bridge_id: continue lock_id = lock_json["id"] lock = self.locks_dict[lock_id] ( lock.is_connected, lock.state, lock.battery_level, lock.is_charging, lock.state_change_result, ) = self.parse_lock_properties(lock_json) if local_call_success: ( lock.is_enabled_pullspring, lock.duration_pullspring, ) = self.parse_pull_spring_settings(lock_json) self._locks_dict[lock_id] = lock _LOGGER.debug("Locks synced successfully") async def get_local_bridge(self) -> TedeeBridge: """Get the local bridge""" if not self._use_local_api: raise TedeeClientException("Local API not configured.") local_call_success, result = await self._local_api_call( "/bridge", HTTPMethod.GET ) if not local_call_success or not result: raise TedeeClientException("Unable to get local bridge") bridge_serial = result["serialNumber"] bridge_name = result["name"] return TedeeBridge(0, bridge_serial, bridge_name) async def get_bridges(self) -> list[TedeeBridge]: """List all bridges.""" _LOGGER.debug("Getting bridges...") r = await http_request( API_URL_BRIDGE, HTTPMethod.GET, self._api_header, self._session, self._timeout, ) result = r["result"] bridges = [] for bridge_json in result: bridge_id = bridge_json["id"] bridge_serial = bridge_json["serialNumber"] bridge_name = bridge_json["name"] bridge = TedeeBridge( bridge_id, bridge_serial, bridge_name, ) bridges.append(bridge) _LOGGER.debug("Bridges retrieved successfully...") return bridges async def unlock(self, lock_id: int) -> None: """Unlock method""" _LOGGER.debug("Unlocking lock %s...", str(lock_id)) local_call_success, _ = await self._local_api_call( f"/lock/{lock_id}/unlock?mode=3", HTTPMethod.POST ) if not local_call_success: url = API_URL_LOCK + str(lock_id) + API_PATH_UNLOCK + "?mode=3" await http_request( url, HTTPMethod.POST, self._api_header, self._session, self._timeout, ) _LOGGER.debug("unlock command successful, id: %d ", lock_id) await asyncio.sleep(UNLOCK_DELAY) async def lock(self, lock_id: int) -> None: """'Lock method""" _LOGGER.debug("Locking lock %s...", str(lock_id)) local_call_success, _ = await self._local_api_call( f"/lock/{lock_id}/lock", HTTPMethod.POST ) if not local_call_success: url = API_URL_LOCK + str(lock_id) + API_PATH_LOCK await http_request( url, HTTPMethod.POST, self._api_header, self._session, self._timeout, ) _LOGGER.debug("lock command successful, id: %s", lock_id) await asyncio.sleep(LOCK_DELAY) # pulling async def open(self, lock_id: int) -> None: """Unlock the door and pull the door latch""" _LOGGER.debug("Opening lock %s...", str(lock_id)) local_call_success, _ = await self._local_api_call( f"/lock/{lock_id}/unlock?mode=4", HTTPMethod.POST ) if not local_call_success: url = API_URL_LOCK + str(lock_id) + API_PATH_UNLOCK + "?mode=4" await http_request( url, HTTPMethod.POST, self._api_header, self._session, self._timeout, ) _LOGGER.debug("Open command successful, id: %s", lock_id) await asyncio.sleep(self._locks_dict[lock_id].duration_pullspring + 1) async def pull(self, lock_id: int) -> None: """Only pull the door latch""" _LOGGER.debug("Pulling latch for lock %s...", str(lock_id)) local_call_success, _ = await self._local_api_call( f"/lock/{lock_id}/pull", HTTPMethod.POST ) if not local_call_success: url = API_URL_LOCK + str(lock_id) + API_PATH_PULL await http_request( url, HTTPMethod.POST, self._api_header, self._session, self._timeout, ) _LOGGER.debug("Open command not successful, id: %s", lock_id) await asyncio.sleep(self._locks_dict[lock_id].duration_pullspring + 1) def is_unlocked(self, lock_id: int) -> bool: """Return is a specific lock is unlocked""" lock = self._locks_dict[lock_id] return lock.state == TedeeLockState.UNLOCKED def is_locked(self, lock_id: int) -> bool: """Return is a specific lock is locked""" lock = self._locks_dict[lock_id] return lock.state == TedeeLockState.LOCKED def parse_lock_properties(self, json_properties: dict): """Parse the lock properties""" connected = bool(json_properties.get("isConnected", False)) lock_properties = json_properties.get("lockProperties") if lock_properties is not None: state = lock_properties.get("state", 9) battery_level = lock_properties.get("batteryLevel", 50) is_charging = lock_properties.get("isCharging", False) state_change_result = lock_properties.get("stateChangeResult", 0) else: # local call does not have lock properties state = json_properties.get("state", 9) battery_level = json_properties.get("batteryLevel", 50) is_charging = bool(json_properties.get("isCharging", False)) state_change_result = json_properties.get("jammed", 0) return connected, state, battery_level, is_charging, state_change_result def parse_pull_spring_settings(self, settings: dict): """Parse the pull spring settings""" device_settings = settings.get("deviceSettings", {}) pull_spring_enabled = bool(device_settings.get("pullSpringEnabled", False)) pull_spring_duration = device_settings.get("pullSpringDuration", 5) return pull_spring_enabled, pull_spring_duration def _calculate_secure_local_token(self) -> str: """Calculate the secure token""" if not self._local_token: return "" ms = time.time_ns() // 1_000_000 secure_token = self._local_token + str(ms) secure_token = hashlib.sha256(secure_token.encode("utf-8")).hexdigest() secure_token += str(ms) return secure_token def _get_local_api_header(self, secure: bool = True) -> dict[str, str]: """Get the local api header""" if not self._local_token: return {} token = self._calculate_secure_local_token() if secure else self._local_token return {"Content-Type": "application/json", "api_token": token} async def _local_api_call( self, path: str, http_method: str, json_data=None ) -> tuple[bool, Any | None]: """Call the local api""" if not self._use_local_api: return False, None for retry_number in range(1, NUM_RETRIES + 1): try: _LOGGER.debug("Getting locks from Local API...") self._last_local_call = time.time() r = await http_request( self._local_api_path + path, http_method, self._get_local_api_header(), self._session, self._timeout, json_data, ) except TedeeAuthException as ex: msg = "Local API authentication failed." if not self._personal_token and (retry_number == NUM_RETRIES): raise TedeeLocalAuthException(msg) from ex _LOGGER.debug(msg) except (TedeeClientException, TedeeRateLimitException) as ex: if not self._personal_token and (retry_number == NUM_RETRIES): _LOGGER.debug( "Error while calling local API endpoint %s. Error: %s. Full error: %s", path, {type(ex).__name__}, str(ex), exc_info=True, ) raise TedeeDataUpdateException( f"Error while calling local API endpoint {path}." ) from ex _LOGGER.debug( "Error while calling local API endpoint %s, retrying with cloud call. Error: %s", path, type(ex).__name__, ) _LOGGER.debug("Full error: %s", str(ex), exc_info=True) else: return True, r await asyncio.sleep(0.5) return False, None def parse_webhook_message(self, message: dict) -> None: """Parse the webhook message sent from the bridge""" message_type = message.get("event") data = message.get("data") if data is None: raise TedeeWebhookException("No data in webhook message.") if message_type == "backend-connection-changed": return lock_id = data.get("deviceId", 0) lock = self._locks_dict.get(lock_id) if lock is None: return if message_type == "device-connection-changed": lock.is_connected = data.get("isConnected", 0) == 1 elif message_type == "device-settings-changed": pass elif message_type == "lock-status-changed": lock.state = data.get("state", 0) lock.state_change_result = data.get("jammed", 0) elif message_type == "device-battery-level-changed": lock.battery_level = data.get("batteryLevel", 50) elif message_type == "device-battery-start-charging": lock.is_charging = True elif message_type == "device-battery-stop-charging": lock.is_charging = False elif message_type == "device-battery-fully-charged": lock.is_charging = False lock.battery_level = 100 self._locks_dict[lock_id] = lock async def update_webhooks( self, webhook_url: str, headers_bridge_sends: list | None = None ) -> None: """Overrites all webhooks""" if headers_bridge_sends is None: headers_bridge_sends = [] _LOGGER.debug("Registering webhook %s", webhook_url) data = [{"url": webhook_url, "headers": headers_bridge_sends}] await self._local_api_call("/callback", HTTPMethod.PUT, data) _LOGGER.debug("Webhook registered successfully.") async def register_webhook( self, webhook_url: str, headers_bridge_sends: list | None = None ) -> int: """Register a webhook, return the webhook id""" if headers_bridge_sends is None: headers_bridge_sends = [] _LOGGER.debug("Registering webhook %s", webhook_url) data = {"url": webhook_url, "headers": headers_bridge_sends} try: success, result = await self._local_api_call("/callback", "POST", data) except TedeeDataUpdateException as ex: raise TedeeWebhookException("Unable to register webhook") from ex if not success: raise TedeeWebhookException("Unable to register webhook") _LOGGER.debug("Webhook registered successfully.") if isinstance(result, dict) and "id" in result: return result["id"] # get the webhook id try: success, result = await self._local_api_call("/callback", HTTPMethod.GET) except TedeeDataUpdateException as ex: raise TedeeWebhookException("Unable to get webhooks") from ex if not success or result is None: raise TedeeWebhookException("Unable to get webhooks") for webhook in result: if webhook["url"] == webhook_url: return webhook["id"] raise TedeeWebhookException("Webhook id not found") async def delete_webhooks(self) -> None: """Delete all webhooks""" _LOGGER.debug("Deleting webhooks...") try: await self._local_api_call("/callback", "PUT", []) except TedeeDataUpdateException as ex: _LOGGER.debug("Unable to delete webhooks: %s", str(ex)) _LOGGER.debug("Webhooks deleted successfully.") async def delete_webhook(self, webhook_id: int) -> None: """Delete a specific webhook""" _LOGGER.debug("Deleting webhook %s", str(webhook_id)) try: await self._local_api_call(f"/callback/{webhook_id}", HTTPMethod.DELETE) except TedeeDataUpdateException as ex: _LOGGER.debug("Unable to delete webhook: %s", str(ex)) _LOGGER.debug("Webhook deleted successfully.") async def cleanup_webhooks_by_host(self, host: str) -> None: """Delete all webhooks for a specific host""" _LOGGER.debug("Deleting webhooks for host %s", host) try: success, result = await self._local_api_call("/callback", HTTPMethod.GET) except TedeeDataUpdateException as ex: _LOGGER.debug("Unable to get webhooks: %s", str(ex)) return if not success or result is None: _LOGGER.debug("Unable to get webhooks") return for webhook in result: if host in webhook["url"]: await self.delete_webhook(webhook["id"]) _LOGGER.debug("Webhooks deleted successfully.") zweckj-aiotedee-01efb17/setup.py000066400000000000000000000016011457701356300167160ustar00rootroot00000000000000import setuptools with open("README.md", "r") as fh: long_description = fh.read() setuptools.setup( name="pytedee_async", version="0.2.17", author="Josef Zweck", author_email="24647999+zweckj@users.noreply.github.com", description="A Tedee Lock Client package", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/zweckj/pytedee_async", packages=setuptools.find_packages(), install_requires=["aiohttp"], license="MIT", classifiers=[ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Natural Language :: English", "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], python_requires=">=3.9", package_data={"pytedee_async": ["py.typed"]}, )