pax_global_header00006660000000000000000000000064146412243350014516gustar00rootroot0000000000000052 comment=97d40196fbcb127278ee1b4cfed249e657334696 laundrify-laundrify-pypi-ea940f1/000077500000000000000000000000001464122433500171005ustar00rootroot00000000000000laundrify-laundrify-pypi-ea940f1/.gitignore000066400000000000000000000032251464122433500210720ustar00rootroot00000000000000# 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/ 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/ cover/ # 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 .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: .python-version # 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/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/laundrify-laundrify-pypi-ea940f1/CHANGELOG.md000066400000000000000000000014521464122433500207130ustar00rootroot00000000000000# Changelog ## v1.2.2 (2024-07-03) - **⚠️ BREAKING CHANGES:** - `get_machines()` now returns a list of `LaundrifyDevice` objects - leading underscores are removed in property names (i.e. `_id` becomes `id`) - feat: query devices locally for latest power measurements - feat: add `dist_wheel` command to build the package - refactor: raise an error by default if response code is 400 or higher - refactor: replace InvalidTokenException by a general UnauthorizedException ## v1.1.2 (2022-05-27) - fix: use correct API endpoint ## v1.1.1 (2022-03-02) - feat: pass external ClientSession ## v1.1.0 (2022-03-01) - refactor: streamline exception naming - fix: raise UnauthorizedException - feat: implement token validation and account ID retrieval ## v1.0.0 (2022-01-26) Initial releaselaundrify-laundrify-pypi-ea940f1/LICENSE000066400000000000000000000020561464122433500201100ustar00rootroot00000000000000MIT License Copyright (c) 2022 Mike Mülhaupt 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.laundrify-laundrify-pypi-ea940f1/README.md000066400000000000000000000023731464122433500203640ustar00rootroot00000000000000# laundrify_aio `laundrify_aio` is a python package to communicate with the [laundrify](https://laundrify.de) API. It has primarily been developed for the Home Assistant integration. ## Usage Example ```python import asyncio from laundrify_aio import LaundrifyAPI async def main(): # Generate a new AuthCode in the laundrify App (`Smart-Home Integration -> Home Assistant -> Integration aktivieren`) auth_code = '123-456' # exchange your auth code for a long-lived access token access_token = await LaundrifyAPI.exchange_auth_code(auth_code) # initialize a new client with the previously obtained access token laundrify_client = LaundrifyAPI(access_token) # get all machines machines = await laundrify_client.get_machines() if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run_until_complete(main()) ``` ## Development/Build To build and publish the package, run the following: ```bash # Use the custom `dist_wheel` command that stashes uncommitted changes and runs `sdist` and `bdist_wheel` python3 setup.py dist_wheel # Make sure the long description will render correctly twine check dist/* # (optional) upload to TestPyPI twine upload --repository-url https://test.pypi.org/legacy/ dist/* # upload to PyPI twine upload dist/* ``` laundrify-laundrify-pypi-ea940f1/examples/000077500000000000000000000000001464122433500207165ustar00rootroot00000000000000laundrify-laundrify-pypi-ea940f1/examples/main.py000066400000000000000000000013311464122433500222120ustar00rootroot00000000000000"""Example script to show how to use laundrify_aio.""" import asyncio import json from laundrify_aio import LaundrifyAPI async def main(): # Generate a new AuthCode in the laundrify App (`Smart-Home Integration -> Home Assistant -> Integration aktivieren`) auth_code = '999-001' # exchange your auth code for a long-lived access token access_token = await LaundrifyAPI.exchange_auth_code(auth_code) # initialize a new client with the previously obtained access token laundrify_client = LaundrifyAPI(access_token) # get all machines machines = await laundrify_client.get_machines() print( json.dumps(machines, indent=4) ) if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run_until_complete(main())laundrify-laundrify-pypi-ea940f1/laundrify_aio/000077500000000000000000000000001464122433500217255ustar00rootroot00000000000000laundrify-laundrify-pypi-ea940f1/laundrify_aio/__init__.py000066400000000000000000000053501464122433500240410ustar00rootroot00000000000000from aiohttp import ClientSession, ClientResponse, ClientError import jwt from .utils import validate_auth_code from .exceptions import ( ApiConnectionException, InvalidFormat, UnknownAuthCode, UnauthorizedException ) from .laundrify_device import LaundrifyDevice class LaundrifyAPI: """Class to communicate with the laundrify API.""" host = "https://api.laundrify.de" def __init__(self, access_token: str, session = None): """Initialize the API and store the auth so we can make requests.""" # raise_for_status: Raise an aiohttp.ClientResponseError if the response status is 400 or higher. self.client_session = session if session else ClientSession(raise_for_status=True) self.access_token = access_token @classmethod async def exchange_auth_code(cls, auth_code: str): """Exchange an Auth Code for an Access Token""" if not validate_auth_code(auth_code): raise InvalidFormat("The provided AuthCode ({auth_code}) doesn't match the expected pattern (xxx-xxx).") async with ClientSession(base_url=cls.host) as session: try: async with session.post('/auth/home-assistant/token', json={'authCode': auth_code}) as res: if (res.status == 404): raise UnknownAuthCode(f"The given AuthCode ({auth_code}) could not be found.") data = await res.json() return data["token"] except ClientError as err: raise ApiConnectionException( str(err) ) async def request(self, method: str, path: str, **kwargs) -> ClientResponse: """Make a request.""" headers = kwargs.get("headers") res = None if headers is None: headers = {} else: headers = dict(headers) headers["authorization"] = f"Bearer ha|{self.access_token}" try: res = await self.client_session.request(method, f"{self.host}{path}", **kwargs, headers=headers) except ClientError as err: if (res and res.status == 401): raise UnauthorizedException() raise ApiConnectionException(err) return res async def validate_token(self): """Make sure the given access token is valid""" token_payload = jwt.decode(self.access_token, options={"verify_signature": False}) account_id = await self.get_account_id() if token_payload["_id"] != account_id: raise UnauthorizedException("The access token doesn't match the account ID.") async def get_account_id(self): """Retrieve the account ID""" res = await self.request('get', '/api/users/me') if res.status == 401: raise UnauthorizedException("The access token is invalid.") acc = await res.json() return acc["_id"] async def get_machines(self): """Read all Machines from backend""" res = await self.request('get', '/api/machines') machines = await res.json() # return a list of LaundrifyDevice objects return [LaundrifyDevice(machine, self) for machine in machines] laundrify-laundrify-pypi-ea940f1/laundrify_aio/exceptions.py000066400000000000000000000014011464122433500244540ustar00rootroot00000000000000"""laundrify API errors""" class LaundrifyApiException(Exception): """Base exception for laundrify_aio""" class InvalidFormat(LaundrifyApiException): """AuthCode has an invalid format.""" class UnknownAuthCode(LaundrifyApiException): """AuthCode could not be found.""" class ApiConnectionException(LaundrifyApiException): """Could not connect the the API.""" class UnauthorizedException(LaundrifyApiException): """Request is not authorized.""" class LaundrifyDeviceException(Exception): """Base exception for laundrify devices.""" class DeviceConnectionException(LaundrifyDeviceException): """Could not connect to the device (locally).""" class UnsupportedFunctionException(LaundrifyDeviceException): """The function is not supported by this device.""" laundrify-laundrify-pypi-ea940f1/laundrify_aio/laundrify_device.py000066400000000000000000000030151464122433500256120ustar00rootroot00000000000000"""Base class for a laundrify WiFi plug.""" from asyncio import TimeoutError from aiohttp.client_exceptions import ClientError from .exceptions import DeviceConnectionException, UnsupportedFunctionException class LaundrifyDevice: def __init__(self, machine_data: dict, laundrify_api): self.laundrify_api = laundrify_api # assign all machine_data properties to the object # and remove leading underscores to avoid linting errors with semi-private properties for key, value in machine_data.items(): setattr(self, key.lstrip('_'), value) async def is_pingable(self): return False async def get_power(self): """Read current power consumption for a machine""" try: res = await self.laundrify_api.client_session.request('get', f"http://{self.internalIP}/status", timeout=3) res = await res.json() if self.model == "M01": return res["sensor"]["power"] elif self.model == "SU02": if self.firmwareVersion >= "2.0.0": return res["power"]["watts"] else: # v1.x firmware doesn't report power, so we raise an exception raise UnsupportedFunctionException("Querying power measurements is not supported by this device.") except (TimeoutError, ClientError) as err: raise DeviceConnectionException(f"{type(err).__name__} while requesting http://{self.internalIP}/status") from err laundrify-laundrify-pypi-ea940f1/laundrify_aio/utils.py000066400000000000000000000001361464122433500234370ustar00rootroot00000000000000import re def validate_auth_code(code: str): return bool( re.match(r"^\d{3}-\d{3}$", code) )laundrify-laundrify-pypi-ea940f1/setup.py000066400000000000000000000051441464122433500206160ustar00rootroot00000000000000import sys import subprocess from setuptools import setup, find_packages from distutils.core import Command VERSION = "1.2.2" # read the contents of your README file from pathlib import Path this_directory = Path(__file__).parent long_description = (this_directory / "README.md").read_text() class DistWheelCommand(Command): """Custom command to ensure all changes are committed or stashed, and build sdist and bdist_wheel.""" description = "Check for local changes, stash them, run sdist and bdist_wheel, then pop the stash" user_options = [] def initialize_options(self): pass def finalize_options(self): pass def run(self): # Check if there are uncommitted changes result = subprocess.run(['git', 'status', '--porcelain'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) if result.returncode != 0: print("Error running git status") sys.exit(1) if result.stdout: print("You have uncommitted changes:") print(result.stdout.decode()) response = input("Would you like to stash these changes and proceed with the build? [y/N]: ").strip().lower() if response == 'y': # Stash changes stash_result = subprocess.run(['git', 'stash', '--include-untracked'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) if stash_result.returncode != 0: print("Error stashing changes") sys.exit(1) stash_applied = True else: print("Please commit or stash your changes before building the package.") sys.exit(1) else: stash_applied = False try: # Run the original sdist and bdist_wheel commands self.run_command('sdist') self.run_command('bdist_wheel') finally: if stash_applied: # Reapply the stash stash_pop_result = subprocess.run(['git', 'stash', 'pop'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) if stash_pop_result.returncode != 0: print("Error popping stash") sys.exit(1) setup( name="laundrify_aio", version=VERSION, author="Mike Mülhaupt", author_email="mike@laundrify.de", cmdclass={ 'dist_wheel': DistWheelCommand, }, license="MIT", url="https://github.com/laundrify/laundrify-pypi", description="A Python package to communicate with the laundrify API", long_description=long_description, long_description_content_type="text/markdown", packages=find_packages(), install_requires=["aiohttp", "pyjwt"], keywords=["home-assistant", "laundrify"], classifiers=[ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.7", "Intended Audience :: Developers" ], )