pax_global_header00006660000000000000000000000064146611060260014514gustar00rootroot0000000000000052 comment=16587f17db32367519d9c4930f92f2d9fd2399f9 blebox-blebox_uniapi-16587f1/000077500000000000000000000000001466110602600160705ustar00rootroot00000000000000blebox-blebox_uniapi-16587f1/.ackrc000066400000000000000000000001701466110602600171520ustar00rootroot00000000000000--ignore-dir=.mypy_cache --ignore-dir=venv --ignore-dir=.tox --ignore-dir=htmlcov --ignore-dir=build --ignore-dir=.eggs blebox-blebox_uniapi-16587f1/.editorconfig000066400000000000000000000004441466110602600205470ustar00rootroot00000000000000# http://editorconfig.org root = true [*] indent_style = space indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 end_of_line = lf [*.bat] indent_style = tab end_of_line = crlf [LICENSE] insert_final_newline = false [Makefile] indent_style = tab blebox-blebox_uniapi-16587f1/.github/000077500000000000000000000000001466110602600174305ustar00rootroot00000000000000blebox-blebox_uniapi-16587f1/.github/ISSUE_TEMPLATE.md000066400000000000000000000005131466110602600221340ustar00rootroot00000000000000* BleBox Python UniAPI version: * Python version: * Operating System: ### Description Describe what you were trying to get done. Tell us what happened, what went wrong, and what you expected to happen. ### What I Did ``` Paste the command(s) you ran and the output. If there was a crash, please include the traceback here. ``` blebox-blebox_uniapi-16587f1/.github/workflows/000077500000000000000000000000001466110602600214655ustar00rootroot00000000000000blebox-blebox_uniapi-16587f1/.github/workflows/ci-test.yml000066400000000000000000000015331466110602600235620ustar00rootroot00000000000000name: Python application on: push: branches: [ "master" ] pull_request: branches: [ "master" ] permissions: contents: read jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.9", "3.10"] steps: - uses: actions/checkout@v3 - uses: actions/checkout@v4 - uses: chartboost/ruff-action@v1 with: args: 'check' - uses: chartboost/ruff-action@v1 with: args: 'format' - name: Set up Python ${{matrix.python-version}} uses: actions/setup-python@v3 with: python-version: ${{matrix.python-version}} - name: Install dependencies run: | python -m pip install --upgrade pip if [ -f requirements_tests.txt ]; then pip install -r requirements_tests.txt; fi - name: Test with pytest run: | pytest blebox-blebox_uniapi-16587f1/.gitignore000066400000000000000000000022551466110602600200640ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # 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/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # IDE settings .vscode/ blebox-blebox_uniapi-16587f1/.pre-commit-config.yaml000066400000000000000000000005701466110602600223530ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.3.0 hooks: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: v0.3.0 hooks: # Run the linter. - id: ruff args: [ --fix ] # Run the formatter. - id: ruff-format blebox-blebox_uniapi-16587f1/.travis.yml000066400000000000000000000016531466110602600202060ustar00rootroot00000000000000language: python python: - 3.8 - 3.7 install: pip install -U tox-travis script: tox deploy: provider: pypi distributions: sdist bdist_wheel user: __token__ password: secure: xu5unRZkE5IXUH4qzR1j8H4nsNw9sJ0mTuEoBS8Zy1WK9K0sZVzY3sgEY0+hOtI+Vv4hMs6MSr0YgTuiNu4iljeHW+mjPEOrX7Ho8d6g0X49bMOzYQEpOvIilcdMcwRR95SeVBrsmLSr0NF8sDFliWTtj8XgpjNEYDv+2uT2i67SEnH33PzMFIfCoOSf0ue91caldYByE5BfQB90WvHhCvND+BWWSVFPWgK8QZHSXRevkIamVrNLRoO+BKUn+ypphULvAzC1jfFBLVVgPODwiJdk8LB6RCU38rzyIC02/XY02IUIOWmwpgfsjoFXg5oZwZkiOq21KEpmDPGSCUy5fT5z5JZCxx7MHn1nghDVx3s8M+lx7a5JdRU4wdvV3LN76EXq/SEpY3bwg/pRBlb8gPooOwDALNoq11jvOtD6p88waAJogyTj7ga5wcReFJaPqJQuHtCUBpvbskNMoNBaPA5vNXS2puBOb8ASiJWJZQYC7+qpwVEXWk/8kEoqoJQgwO673aWw4YpDaPIgIwf0KHUC87cthtk8B/Nzp0C+O32+JI6KA33C0lQeRgXQ6iQDUSju8iMBmW3R/ffJacKgzY25bGcsznucGeGH7oc4ibvCmDlzlCre9t4ofLcpPgZ6qoJCjYe/pMTnL/spOlo2zodXuD13Z6kudgn6SuhUSnQ= on: tags: true repo: blebox/blebox_uniapi python: 3.8 blebox-blebox_uniapi-16587f1/CONTRIBUTING.rst000066400000000000000000000070051466110602600205330ustar00rootroot00000000000000.. highlight:: shell ============ Contributing ============ Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. You can contribute in many ways: Types of Contributions ---------------------- Report Bugs ~~~~~~~~~~~ Report bugs at https://github.com/blebox/blebox_uniapi/issues. If you are reporting a bug, please include: * Your operating system name and version. * Any details about your local setup that might be helpful in troubleshooting. * Detailed steps to reproduce the bug. Fix Bugs ~~~~~~~~ Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. Implement Features ~~~~~~~~~~~~~~~~~~ Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. Write Documentation ~~~~~~~~~~~~~~~~~~~ BleBox Python UniAPI could always use more documentation, whether as part of the official BleBox Python UniAPI docs, in docstrings, or even on the web in blog posts, articles, and such. Submit Feedback ~~~~~~~~~~~~~~~ The best way to send feedback is to file an issue at https://github.com/blebox/blebox_uniapi/issues. If you are proposing a feature: * Explain in detail how it would work. * Keep the scope as narrow as possible, to make it easier to implement. * Remember that this is a volunteer-driven project, and that contributions are welcome :) Get Started! ------------ Ready to contribute? Here's how to set up `blebox_uniapi` for local development. 1. Fork the `blebox_uniapi` repo on GitHub. 2. Clone your fork locally:: $ git clone git@github.com:your_name_here/blebox_uniapi.git 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: $ mkvirtualenv blebox_uniapi $ cd blebox_uniapi/ $ python setup.py develop 4. Create a branch for local development:: $ git checkout -b name-of-your-bugfix-or-feature Now you can make your changes locally. 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: $ flake8 blebox_uniapi tests $ python setup.py test or pytest $ tox To get flake8 and tox, just pip install them into your virtualenv. 6. Commit your changes and push your branch to GitHub:: $ git add . $ git commit -m "Your detailed description of your changes." $ git push origin name-of-your-bugfix-or-feature 7. Submit a pull request through the GitHub website. Pull Request Guidelines ----------------------- Before you submit a pull request, check that it meets these guidelines: 1. The pull request should include tests. 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in README.rst. 3. The pull request should work for Python 3.6, 3.7 and 3.8, and for PyPy. Check https://travis-ci.com/gadgetmobile/blebox_uniapi/pull_requests and make sure that the tests pass for all supported Python versions. Tips ---- To run a subset of tests:: $ pytest tests.test_blebox_uniapi Deploying --------- A reminder for the maintainers on how to deploy. Make sure all your changes are committed (including an entry in HISTORY.rst). Then run:: $ bump2version patch # possible: major / minor / patch $ git push $ git push --tags Travis will then deploy to PyPI if tests pass. blebox-blebox_uniapi-16587f1/CREDITS.md000066400000000000000000000006311466110602600175070ustar00rootroot00000000000000# Credits Some credits are due ## Folks Project contributors (in alphabetical order) - @bbx-jp - @gadgetmobile - @gralin - @jkarnasiewicz - @riokuu - @swistakm - @Pastucha - @pvsti ## Projects - Initial scaffolding for this project was created using [cookiecutter](https://github.com/audreyr/cookiecutter) and [audreyr/cookiecutter-pypackage](https://github.com/audreyr/cookiecutter-pypackage) projects blebox-blebox_uniapi-16587f1/HISTORY.rst000066400000000000000000000122521466110602600177650ustar00rootroot00000000000000======= History ======= 2.5.0 (2024-08-20) ------------------ * feature: expose sensor_id in sensors and alias in all features by @swistakm in https://github.com/blebox/blebox_uniapi/pull/176 2.4.2 (2024-06-04) ------------------ * fix: add missing support for active power sensors on switchbox/switchboxd devices by @swistakm in https://github.com/blebox/blebox_uniapi/pull/175 2.4.1 (2024-06-04) ------------------ * fix: rectify ambiguity around powerConsumption and wind sensor types 2.4.0 (2024-06-03) ------------------ * Fix: Refactor sensor_factory by @Pastucha in https://github.com/blebox/blebox_uniapi/pull/163 * Order in BOX_TYPES by @Pastucha in https://github.com/blebox/blebox_uniapi/pull/164 * Feature: smart meter by @swistakm in https://github.com/blebox/blebox_uniapi/pull/168 * Smartmeter by @pvsti in https://github.com/blebox/blebox_uniapi/pull/170 * fix: resolve regressions in cover and climate due to jmespath introduction by @swistakm in https://github.com/blebox/blebox_uniapi/pull/173 2.3.0 (2024-03-13) ------------------ * feat: add new methods to cover feature enabling handling of tilt open/close actions by @swistakm in https://github.com/blebox/blebox_uniapi/pull/154 * add support for flood sensing for multisensors as binary moisture sensor by @swistakm in https://github.com/blebox/blebox_uniapi/pull/153 * feat/fix: Add Ruff and Pre-commit Configuration, Resolve Undefined Name by @Pastucha in https://github.com/blebox/blebox_uniapi/pull/158 * gatebox and shutterbox improvements: by @swistakm in https://github.com/blebox/blebox_uniapi/pull/156 * BleBox Multisensor Illuminance Integration by @Pastucha in https://github.com/blebox/blebox_uniapi/pull/161 2.2.2 (2024-02-07) ------------------ * fixed wind reading units to get proper raw m/s value (division by 10, see PR #150) 2.2.1 (2024-01-26) ------------------ * fixed support for power measurement capabilities of switchBox and switchBoxD devices 2.2.0 (2023-08-29) ------------------ * added last_reset to energy sensor class * added BasicAuth support to http client 2.1.4 (2023-01-03) ------------------ * added tilt position support for :code:`cover.Shutter` * added :code:`Wind` for wind sensor of multisensors * added :code:`Energy` sensor class for power consumption tracking * implementing :code:`default_api_level` for * dimmerBox * wLightBox * wLightBoxS 2.1.3 (2022-10-27) ------------------ * thermoBox boost mode doesn't corrupt state 2.1.2 (2022-10-17) ------------------ * fixed CCT, CCTx2 modes for wLightBox v1 & v2 2.1.1 (2022-10-11) ------------------ * added support for thermoBox devices: * added thermoBox config to :code:`BOX_TYPE_CONFIG` * :code:`Climate` uses factory method implementation * added test coverage 2.1.0 (2022-08-05) ------------------ * added support for multiSensor API: * :code:`airQuality` moved to sensor module * new binary_sensor module, introducing :code:`Rain` class 2.0.2 (2022-07-06) ------------------ * added :code:`query_string` property in :code:`Button` class * fixed test assertions after changes in error raised ValueError 2.0.1 (2022-06-01) ------------------ * used :code:`ValueError` type instead of :code:`BadOnValueError` in methods: * evaluate_brightness_from_rgb * apply_brightness * normalise_elements_of_rgb * _set_last_on_value * async_on 2.0.0 (2022-06-21) ------------------ * extended support for color modes in wLightBox devices * initial support for tvLiftBox device * major backward-incompatible architectural changes to enable dynamic configuration of devices * removed products.py module and replaced with factory method on Box class * general overhaul of public interfaces 1.3.3 (2021-05-12) ------------------ * fix support for wLightBoxS with wLightBox API * fix state detection in gateBox 1.3.2 (2020-04-2) ------------------ * use proper module-level logger by default * fix formatting 1.3.1 (2020-04-2) ------------------ * never skip command requests * improve error messages 1.2.0 (2020-03-30) ------------------ * expose device info * always add ip/port in connection errors * fixed gateController support * support for sauna min/max temp 1.1.0 (2020-03-24) ------------------ * fix bad wLightBox API path * wrap api calls in semaphore (to serialize reqests to each box) * throttle updates to 2/second (to avoid unnecessary requests) * rework error handling and hierarchy (for cleaner usage) * use actual device name (to help recognize the device) * handle asyncio.TimeoutError (to handle timeout-related errors nicely) * properly re-raise exceptions (to avoid lengthy call stacktraces) * rename wLightBoxS feature to "brightness" 1.1.0 (2020-03-24) ------------------ * fix switchBox support * fix minimum position handling * drop Python 3.6 support (still may work) * misc fixes, cleanup and increased test coverage 1.0.0 (2020-03-24) ------------------ * Fixed wLightBox issues * Fixed wLightBoxS issues * Fixed shutterBox issues * Handle unknown shutterBox position * Improved error handling + lots of new diagnostics * Increased tests and test coverage (almost 100%) * Lots of rework 0.1.1 (2020-03-15) ------------------ * Fixed switchBox support (newer API versions) 0.1.0 (2020-03-10) ------------------ * First release on PyPI. blebox-blebox_uniapi-16587f1/LICENSE000066400000000000000000000011101466110602600170660ustar00rootroot00000000000000Apache Software License 2.0 Copyright (c) 2020, Gadget Mobile 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. blebox-blebox_uniapi-16587f1/MANIFEST.in000066400000000000000000000003551466110602600176310ustar00rootroot00000000000000include AUTHORS.rst include CONTRIBUTING.rst include HISTORY.rst include LICENSE include README.rst recursive-exclude * __pycache__ recursive-exclude * *.py[co] recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif blebox-blebox_uniapi-16587f1/Makefile000066400000000000000000000042601466110602600175320ustar00rootroot00000000000000.PHONY: clean clean-test clean-pyc clean-build docs help .DEFAULT_GOAL := help define BROWSER_PYSCRIPT import os, webbrowser, sys from urllib.request import pathname2url webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) endef export BROWSER_PYSCRIPT define PRINT_HELP_PYSCRIPT import re, sys for line in sys.stdin: match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) if match: target, help = match.groups() print("%-20s %s" % (target, help)) endef export PRINT_HELP_PYSCRIPT BROWSER := python -c "$$BROWSER_PYSCRIPT" help: @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts clean-build: ## remove build artifacts rm -fr build/ rm -fr dist/ rm -fr .eggs/ find . -name '*.egg-info' -exec rm -fr {} + find . -name '*.egg' -exec rm -f {} + clean-pyc: ## remove Python file artifacts find . -name '*.pyc' -exec rm -f {} + find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + find . -name '__pycache__' -exec rm -fr {} + clean-test: ## remove test and coverage artifacts rm -fr .tox/ rm -f .coverage rm -fr htmlcov/ rm -fr .pytest_cache lint: ## check style with flake8 flake8 blebox_uniapi tests test: ## run tests quickly with the default Python pytest -s test-all: ## run tests on every Python version with tox tox coverage: ## check code coverage quickly with the default Python coverage run --source blebox_uniapi -m pytest coverage report -m coverage html $(BROWSER) htmlcov/index.html docs: ## generate Sphinx HTML documentation, including API docs rm -f docs/blebox_uniapi.rst rm -f docs/modules.rst sphinx-apidoc -o docs/ blebox_uniapi $(MAKE) -C docs clean $(MAKE) -C docs html $(BROWSER) docs/_build/html/index.html servedocs: docs ## compile the docs watching for changes watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . release: dist ## package and upload a release twine upload dist/* dist: clean ## builds source and wheel package python setup.py sdist python setup.py bdist_wheel ls -l dist install: clean ## install the package to the active Python's site-packages python setup.py install blebox-blebox_uniapi-16587f1/README.rst000066400000000000000000000017471466110602600175700ustar00rootroot00000000000000==================== BleBox Python UniAPI ==================== .. image:: https://img.shields.io/pypi/v/blebox_uniapi.svg :target: https://pypi.python.org/pypi/blebox_uniapi Python API for accessing BleBox smart home devices * Free software: Apache Software License 2.0 * Documentation: https://blebox-uniapi.readthedocs.io. Features -------- * supports `11 BleBox smart home devices`_ * contains functional/integration tests * every device supports at least minimum functionality for most common automation needs * insight of integration level are accesible from file `box_types.py `_ (devices with apiLevel lower than defined in BOX_TYPE_CONF will not be supported but higher will) Contributions are welcome! Credits ------- .. _Cookiecutter: https://github.com/audreyr/cookiecutter .. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage .. _`11 BleBox smart home devices`: https://blebox.eu/produkty/?lang=en blebox-blebox_uniapi-16587f1/blebox_uniapi/000077500000000000000000000000001466110602600207105ustar00rootroot00000000000000blebox-blebox_uniapi-16587f1/blebox_uniapi/__init__.py000066400000000000000000000002061466110602600230170ustar00rootroot00000000000000"""Top-level package for BleBox Python UniAPI.""" __author__ = """BleBox""" __email__ = "opensource@blebox.eu" __version__ = "2.5.0" blebox-blebox_uniapi-16587f1/blebox_uniapi/binary_sensor.py000066400000000000000000000053611466110602600241440ustar00rootroot00000000000000from typing import Union, TYPE_CHECKING from .feature import Feature if TYPE_CHECKING: from .box import Box class BinarySensor(Feature): """Class representing sensor with bool state.""" def __init__(self, product: "Box", alias: str, methods: dict): super().__init__(product, alias, methods) @classmethod def many_from_config( cls, product, box_type_config, extended_state ) -> list["Feature"]: type_class_map = { "rain": Rain, "flood": Flood, } output_list = list() sensors_list = extended_state.get("multiSensor").get("sensors", {}) alias, methods = box_type_config[0] for sensor in sensors_list: sensor_type = sensor.get("type") sensor_id = sensor.get("id") if sensor_type in type_class_map: klass = type_class_map[sensor_type] if methods.get(sensor_type) is not None: value_method = {sensor_type: methods[sensor_type](sensor_id)} output_list.append( klass( product=product, alias=sensor_type + "_" + str(sensor_id), methods=value_method, ) ) return output_list class Rain(BinarySensor): def __init__(self, product: "Box", alias: str, methods: dict): self._device_class = "moisture" super().__init__(product, alias, methods) @property def state(self) -> bool: return self._current > 0 @property def device_class(self) -> str: return self._device_class def _read_rain(self, field: str) -> Union[float, int, None]: product = self._product if product.last_data is not None: raw = self.raw_value(field) if raw is not None: # no reading return self.raw_value("rain") return 0 def after_update(self) -> None: self._current = self._read_rain("rain") class Flood(BinarySensor): def __init__(self, product: "Box", alias: str, methods: dict): self._device_class = "moisture" super().__init__(product, alias, methods) @property def state(self) -> bool: return self._current > 0 @property def device_class(self) -> str: return self._device_class def _read_flood(self, field: str) -> Union[float, int, None]: product = self._product if product.last_data is not None: raw = self.raw_value(field) if raw is not None: # no reading return self.raw_value("flood") return 0 def after_update(self) -> None: self._current = self._read_flood("flood") blebox-blebox_uniapi-16587f1/blebox_uniapi/box.py000066400000000000000000000300201466110602600220450ustar00rootroot00000000000000from __future__ import annotations import asyncio import time from typing import Optional, Any, Dict from .box_types import _DEFAULT_API_LEVEL, get_conf, get_conf_set from .button import Button from .climate import Climate from .cover import Cover from .light import Light from .sensor import SensorFactory from .binary_sensor import BinarySensor from .session import ApiHost from .switch import Switch from .error import ( UnsupportedBoxResponse, UnsupportedBoxVersion, BadFieldExceedsMax, BadFieldLessThanMin, BadFieldMissing, BadFieldNotANumber, BadFieldNotAString, BadFieldNotRGBW, HttpError, ) DEFAULT_PORT = 80 class Box: _name: str _data_path: str _last_real_update: Optional[float] _last_data: Optional[Any] api_session: ApiHost info: dict config: dict extended_state: Optional[Dict[Any, Any]] def __init__( self, api_session, info, config, extended_state, ) -> None: self._last_data = None self._last_real_update = None self._sem = asyncio.BoundedSemaphore() self._session = api_session address = f"{api_session.host}:{api_session.port}" location = f"Device at {address}" # NOTE: get ID first for better error messages try: unique_id = info["id"] except KeyError as ex: raise UnsupportedBoxResponse(info, f"{location} has no id") from ex location = f"Device:{unique_id} at {address}" try: type = info["type"] except KeyError as ex: raise UnsupportedBoxResponse(info, f"{location} has no type") from ex try: product = info["product"] except KeyError: product = type # TODO: make wLightBox API support multiple products # in 2020 wLightBoxS API has been deprecated and it started using wLightBox API # current codebase needs a refactor to support multiple product sharing one API # as a temporary workaround we are using 'alias' type wLightBoxS2 if type == "wLightBox" and product == "wLightBoxS": type = "wLightBoxS" location = f"{product}:{unique_id} at {address}" try: name = info["deviceName"] except KeyError as ex: raise UnsupportedBoxResponse(info, f"{location} has no name") from ex location = f"'{name}' ({product}:{unique_id} at {address})" try: firmware_version = info["fv"] except KeyError as ex: raise UnsupportedBoxResponse( info, f"{location} has no firmware version" ) from ex location = f"'{name}' ({product}:{unique_id}/{firmware_version} at {address})" try: hardware_version = info["hv"] except KeyError as ex: raise UnsupportedBoxResponse( info, f"{location} has no hardware version" ) from ex level = int(info.get("apiLevel", _DEFAULT_API_LEVEL)) self._data_path = config["api_path"] self._type = type self._unique_id = unique_id self._name = name self._address = address self._firmware_version = firmware_version self._hardware_version = hardware_version self._api_version = level self._model = config.get("model", type) self._api = config.get("api", {}) self._features = self.create_features(config, info, extended_state) self._config = config self._update_last_data(extended_state) def create_features( self, config: dict, info: dict, extended_state: Optional[dict] ) -> dict: features = {} for field, klass in { "covers": Cover, "sensors": SensorFactory, "binary_sensors": BinarySensor, "lights": Light, "climates": Climate, "switches": Switch, "buttons": Button, }.items(): try: if box_type_config := config.get(field): features[field] = klass.many_from_config( self, box_type_config=box_type_config, extended_state=extended_state, ) except KeyError: raise UnsupportedBoxResponse("Failed to initialize:", info) return features @classmethod async def async_from_host(cls, api_host: ApiHost) -> Box: try: path = "/api/device/state" data = await api_host.async_api_get(path) except HttpError: path = "/info" data = await api_host.async_api_get(path) info = data.get("device", data) # type: ignore extended_state = None config = cls._match_device_config(info) if extended_state_path := config.get("extended_state_path"): try: extended_state = await api_host.async_api_get(extended_state_path) except (HttpError, KeyError): extended_state = None return cls(api_host, info, config, extended_state) @classmethod def _match_device_config(cls, info: dict) -> dict: try: device_type = info["type"] except KeyError as ex: raise UnsupportedBoxResponse(info, "Device info has no type key") from ex try: product = info["product"] except KeyError: product = device_type if device_type == "wLightBox" and product == "wLightBoxS": device_type = "wLightBoxS" level = int(info.get("apiLevel", _DEFAULT_API_LEVEL)) config_set = get_conf_set(device_type) if not config_set: raise UnsupportedBoxResponse(f"{device_type} is not a supported type") config = get_conf(level, config_set) if not config: raise UnsupportedBoxVersion( f"{device_type} has unsupported version ({level})." ) return config @property def name(self) -> str: return self._name @property def address(self) -> str: return self._address @property def last_data(self) -> Optional[Dict[Any, Any]]: return self._last_data # Used in full_name, track down and refactor. @property def type(self) -> str: return self._type @property def unique_id(self) -> Any: return self._unique_id @property def firmware_version(self) -> Any: return self._firmware_version @property def hardware_version(self) -> Any: return self._hardware_version @property def api_version(self) -> int: return self._api_version @property def features(self) -> dict: return self._features @property def brand(self) -> str: return "BleBox" @property def model(self) -> Any: return self._model async def async_update_data(self) -> None: await self._async_api(True, "GET", self._data_path) def _update_last_data(self, new_data: Optional[dict]) -> None: # Note: on certain more complex devices that inlcude multiple features # like switches and sensors (e.g. SwitchboxD) it may happen that activating # single feature would result only in partial update of the self._last_data. # We can know that by comparing keys of both states. # # Notable example of device exhibiting this behavior is SwitchboxD (20200831) # It has two relays (switches) and power measurement capabilities (sensor). # Toggling the relay does return state of all relays, but does not return # sensor information (partial state). Accepting the new state as-is would # break the update of sensory information. # # Note that SwitchboxD is just an example. It is possible that APIs of other # box types also exhibit this kind of behavior. if ( isinstance(self._last_data, dict) and isinstance(new_data, dict) and self._last_data.keys() != new_data.keys() ): # ... In such a case we need to merge both states instead of overwriting # the old one as-is. # # The only risk is that if certain features are somehow coupled inside # the device, we will have inconsistent information about the device. # However, this should be eventually consistent as new updates arrive. # In the end, it is better to have an inconsistent state, rather than # have broken one (e.g. missing keys) that results in broken features. # # Refs: # - https://github.com/blebox/blebox_uniapi/pull/152 # - https://github.com/blebox/blebox_uniapi/issues/137 new_data = {**self._last_data, **new_data} self._last_data = new_data for feature_set in self._features.values(): for feature in feature_set: feature.after_update() async def async_api_command(self, command: str, value: Any = None) -> None: method, *args = self._api[command](value) self._last_real_update = None # force update return await self._async_api(False, method, *args) def expect_int( self, field: str, raw_value: int, maximum: int = -1, minimum: int = 0 ) -> int: return self.check_int(raw_value, field, maximum, minimum) def expect_hex_str( self, field: str, raw_value: int, maximum: int = -1, minimum: int = 0 ) -> int: return self.check_hex_str(raw_value, field, maximum, minimum) def expect_rgbw(self, field: str, raw_value: int) -> int: return self.check_rgbw(raw_value, field) def check_int_range( self, value: int, field: str, max_value: int, min_value: int ) -> int: if max_value >= min_value: if value > max_value: raise BadFieldExceedsMax(self.name, field, value, max_value) if value < min_value: raise BadFieldLessThanMin(self.name, field, value, min_value) return value def check_int(self, value: int, field: str, max_value: int, min_value: int) -> int: if value is None: raise BadFieldMissing(self.name, field) if not isinstance(value, int): raise BadFieldNotANumber(self.name, field, value) return self.check_int_range(value, field, max_value, min_value) def check_hex_str( self, value: int, field: str, max_value: int, min_value: int ) -> int: if value is None: raise BadFieldMissing(self.name, field) if not isinstance(value, str): raise BadFieldNotAString(self.name, field, value) if ( self.check_int_range(int(value, 16), field, max_value, min_value) is not None ): return value def check_rgbw(self, value: int, field: str) -> int: if value is None: raise BadFieldMissing(self.name, field) if not isinstance(value, str): raise BadFieldNotAString(self.name, field, value) if len(value) > 10 or len(value) % 2 != 0: raise BadFieldNotRGBW(self.name, field, value) return value def _has_recent_data(self) -> bool: last = self._last_real_update return (time.time() - 2) <= last if last is not None else False async def _async_api( self, is_update: bool, method: Any, path: str, post_data: dict = None, ) -> None: if method not in ("GET", "POST"): raise NotImplementedError(method) # pragma: no cover if is_update: if self._has_recent_data(): return async with self._sem: if is_update: if self._has_recent_data(): return if method == "GET": response = await self._session.async_api_get(path) else: response = await self._session.async_api_post(path, post_data) self._update_last_data(response) self._last_real_update = time.time() blebox-blebox_uniapi-16587f1/blebox_uniapi/box_types.py000066400000000000000000000664101466110602600233050ustar00rootroot00000000000000from .cover import Gate, GateBox, GateBoxB, Shutter from typing import Union, Any # default api level for all products that don't have one _DEFAULT_API_LEVEL = 20151206 def get_conf_set(product_type: str) -> dict: """Get all configurations for provided product type.""" conf_set = BOX_TYPE_CONF.get(product_type, {}) return conf_set def get_conf(api_level: Union[int, str], conf_set: dict) -> dict: """Get configuration from conf_set for provided api_level.""" for min_api_level in sorted(conf_set, reverse=True): if api_level >= min_api_level: return conf_set[min_api_level] return {} def get_latest_conf(product_type: str) -> dict: """Get latest configuration for provided product type.""" conf_set = get_conf_set(product_type) if conf_set: latest_min_api_level = sorted(conf_set, reverse=True)[0] return conf_set[latest_min_api_level] return conf_set def get_latest_api_level(product_type: str) -> Union[dict, int]: """Get latest supported api_level for provided product type.""" conf_set = get_conf_set(product_type) if conf_set: return sorted(conf_set, reverse=True)[0] return 0 # Configuration for all box types BOX_TYPE_CONF: dict[str, dict[int, dict[str, Any]]] = { # tvLiftBox; in comments api level config description "tvLiftBox": { # apiType to match devices apiType 20200518: { # apiLevel to match integration level "api_path": "/api/device/state", # path to devices state "extended_state_path": "/state/extended", # path to devices extended state "api": { "set": lambda command: ("GET", f"/s/c/{command}") }, # dictionary with interaction methods "buttons": [ "tvLift", {"lift": ""}, ], # key used to set platform, list elements used in cls init, e.g. [, {"path": "state_value"}] } }, # airSensor "airSensor": { 20180403: { "api_path": "/api/air/state", "sensors": [ [ "0.air", { "pm1.value": "air.sensors[?type == 'pm1']|[0]|value", "pm1.state": "air.sensors[?type == 'pm1']|[0]|state", "pm2_5.value": "air.sensors[?type == 'pm2.5']|[0]|value", "pm2_5.state": "air.sensors[?type == 'pm2.5']|[0]|state", "pm10.value": "air.sensors[?type == 'pm10']|[0]|value", "pm10.state": "air.sensors[?type == 'pm10']|[0]|state", }, ] ], } }, # dimmerBox "dimmerBox": { # default_API 20151206: { "api_path": "/api/dimmer/state", "api": { "set": lambda x: ( "POST", "/api/dimmer/set", '{"dimmer":{"desiredBrightness": ' + str(x) + "}}", ), }, "lights": [["brightness", {"desired": "dimmer.desiredBrightness"}]], }, 20170829: { "api_path": "/api/dimmer/state", "api": { "set": lambda x: ( "POST", "/api/dimmer/set", '{"dimmer":{"desiredBrightness": ' + str(x) + "}}", ), }, "lights": [["brightness", {"desired": "dimmer.desiredBrightness"}]], }, }, # gateBox "gateBox": { # default_API 20151206: { "api_path": "/api/gate/state", "api": { "primary": lambda x=None: ("GET", "/s/p", None), "secondary": lambda x=None: ("GET", "/s/s", None), }, "covers": [ [ "position", { "position": "currentPos", "desired": "desiredPos", "extraButtonType": "extraButtonType", }, "gatebox", GateBox, ] ], }, 20200831: { "api_path": "/state/extended", "extended_state_path": "/state/extended", "api": { "primary": lambda x=None: ("GET", "/s/p", None), "secondary": lambda x=None: ("GET", "/s/s", None), }, "covers": [ [ "position", { "position": "gate.currentPos", "gate_type": "gate.gateType", }, "gatebox", GateBoxB, ] ], }, }, # gateController "gateController": { 20180604: { "api_path": "/api/gatecontroller/state", "extended_state_path": "/api/gatecontroller/extended/state", "api": { "open": lambda x=None: ("GET", "/s/o", None), "close": lambda x=None: ("GET", "/s/c", None), "position": lambda x: ("GET", "/s/p/" + str(x), None), "stop": lambda x=None: ("GET", "/s/s", None), # "walk": lambda x=None: ("GET", "/s/w", None), # "next": lambda x=None: ("GET", "/s/n", None), }, "covers": [ [ "position", { "desired": "gateController.desiredPos.positions|[0]", # "current": "gateController/currentPos/positions/[0]", "state": "gateController.state", }, "gate", Gate, ] ], } }, # thermoBox "thermoBox": { 20200229: { "api_path": "/state/extended", "extended_state_path": "/state/extended", "api": { "on": lambda x=None: ("GET", "/s/1", None), "off": lambda x=None: ("GET", "/s/0", None), "set": lambda x=None: ("GET", "/s/t/" + str(x), None), }, "climates": [ [ "thermostat", { "desired": "thermo.desiredTemp", "minimum": "thermo.minimumTemp", "maximum": "thermo.maximumTemp", "temperature": lambda x: f"sensors[?id == `{x}`]|[0]|value", "state": "thermo.state", "mode": "thermo.mode", "safetySensorId": "thermo.safetyTempSensor.sensorId", "operatingState": "thermo.operatingState", }, ] ], } }, # saunaBox "saunaBox": { 20180604: { # TODO: read extended state only once on startup "api_path": "/api/heat/extended/state", "extended_state_path": "/api/heat/extended/state", # TODO: use an api map (map to semver)? Or constraints? "api": { "on": lambda x=None: ("GET", "/s/1", None), "off": lambda x=None: ("GET", "/s/0", None), "set": lambda x=None: ("GET", "/s/t/" + str(x), None), }, "climates": [ [ "thermostat", { "desired": "heat.desiredTemp", "minimum": "heat.minimumTemp", "maximum": "heat.maximumTemp", "temperature": "heat.sensors[?id == `0`]|[0]|value", "state": "heat.state", }, ] ], } }, # shutterBox "shutterBox": { 20180604: { "api_path": "/api/shutter/state", "extended_state_path": "/api/shutter/extended/state", "api": { "open": lambda x=None: ("GET", "/s/u", None), "close": lambda x=None: ("GET", "/s/d", None), "position": lambda x: ("GET", "/s/p/" + str(x), None), "stop": lambda x=None: ("GET", "/s/s", None), "tilt": lambda x=None: ("GET", "/s/t/" + str(x), None), }, "covers": [ [ "position", { "desired": "shutter.desiredPos.position", # "current": "shutter/currentPos/position", "tilt": "shutter.desiredPos.tilt", "state": "shutter.state", }, "shutter", Shutter, ] ], } }, # switchBox "switchBox": { 20180604: { "model": "switchBox", "api_path": "/api/relay/state", "extended_state_path": "/api/relay/extended/state", "api": { "on": lambda x=None: ("GET", "/s/1", None), "off": lambda x=None: ("GET", "/s/0", None), }, "switches": [["0.relay", {"state": "[?relay==`0`]|[0]|state"}, "relay"]], # note: does not support power measurement }, 20190808: { "api_path": "/api/relay/extended/state", "extended_state_path": "/api/relay/extended/state", "api": { "on": lambda x=None: ("GET", "/s/1", None), "off": lambda x=None: ("GET", "/s/0", None), }, "switches": [ [ "0.relay", {"state": lambda x: f"relays[?relay==`{x}`]|[0]|state"}, "relay", ] ], "sensors": [ [ "switchBox.energy", { # note: switchbox/switchboxD sensors are currently not indexed (singletons) "powerConsumption": lambda x: "powerMeasuring.powerConsumption[0]|value", "activePower": lambda x: "sensors[?type == 'activePower']|[0]|value", "periodS": "powerMeasuring.powerConsumption[0]|periodS", "measurement_enabled": "powerMeasuring.enabled", }, ] ], }, 20200229: { "extended_state_path": "/state/extended", "api_path": "/state/extended", "api": { "on": lambda x=None: ("GET", "/s/1", None), "off": lambda x=None: ("GET", "/s/0", None), }, "switches": [ [ "0.relay", {"state": lambda x: f"relays[?relay==`{x}`]|[0]|state"}, "relay", ] ], "sensors": [ [ "switchBox.energy", { # note: switchbox/switchboxD sensors are currently not indexed (singletons) "powerConsumption": lambda x: "powerMeasuring.powerConsumption[0]|value", "activePower": lambda x: "sensors[?type == 'activePower']|[0]|value", "periodS": "powerMeasuring.powerConsumption[0]|periodS", "measurement_enabled": "powerMeasuring.enabled", }, ] ], }, 20200831: { "extended_state_path": "/state/extended", "api_path": "/state/extended", "api": { "on": lambda x=None: ("GET", "/s/1", None), "off": lambda x=None: ("GET", "/s/0", None), }, "switches": [ [ "0.relay", {"state": lambda x: f"relays[?relay==`{x}`]|[0]|state"}, "relay", ] ], "sensors": [ [ "switchBox.energy", { # note: switchbox/switchboxD sensors are currently not indexed (singletons) "powerConsumption": lambda x: "powerMeasuring.powerConsumption[0]|value", "activePower": lambda x: "sensors[?type == 'activePower']|[0]|value", "periodS": "powerMeasuring.powerConsumption[0]|periodS", "measurement_enabled": "powerMeasuring.enabled", }, ] ], }, 20220114: { "api_path": "/state/extended", "extended_state_path": "/state/extended", "api": { # note: old control api (i.e. /s/0, /s/1, /s/2) still supported but # now deprecated. switchBox has now API consistent with switchBoxD "on": lambda x=None: ("GET", f"/s/{x}/1", None), "off": lambda x=None: ("GET", f"/s/{x}/0", None), }, "switches": [ [ "relay", {"state": lambda x: f"relays[?relay==`{x}`]|[0]|state"}, "relay", ] ], "sensors": [ [ "switchBox.energy", { # note: switchbox/switchboxD sensors are currently not indexed (singletons) "powerConsumption": lambda x: "powerMeasuring.powerConsumption[0]|value ", "activePower": lambda x: "sensors[?type == 'activePower']|[0]|value", "periodS": "powerMeasuring.powerConsumption[0]|periodS", "measurement_enabled": "powerMeasuring.enabled", }, ] ], }, }, # switchBoxD "switchBoxD": { 20190808: { "extended_state_path": "/api/relay/extended/state", "api_path": "/api/relay/extended/state", "api": { "on": lambda x: ("GET", f"/s/{int(x)}/1", None), "off": lambda x=None: ("GET", f"/s/{int(x)}/0", None), }, "switches": [ [ "0.relay", {"state": lambda x: f"relays[?relay==`{x}`]|[0]|state"}, "relay", 0, ], [ "1.relay", {"state": lambda x: f"relays[?relay==`{x}`]|[0]|state"}, "relay", 1, ], ], "sensors": [ [ "switchBox.energy", { # note: switchbox/switchboxD sensors are currently not indexed (singletons) "powerConsumption": lambda x: "powerMeasuring.powerConsumption[0]|value ", "activePower": lambda x: "sensors[?type == 'activePower']|[0]|value", "periodS": "powerMeasuring.powerConsumption[0]|periodS", "measurement_enabled": "powerMeasuring.enabled", }, ] ], }, 20200229: { "extended_state_path": "/state/extended", "api_path": "/state/extended", "api": { "on": lambda x: ("GET", f"/s/{int(x)}/1", None), "off": lambda x=None: ("GET", f"/s/{int(x)}/0", None), }, "switches": [ [ "0.relay", {"state": lambda x: f"relays[?relay==`{x}`]|[0]|state"}, "relay", 0, ], [ "1.relay", {"state": lambda x: f"relays[?relay==`{x}`]|[0]|state"}, "relay", 1, ], ], "sensors": [ [ "switchBox.energy", { # note: switchbox/switchboxD sensors are currently not indexed (singletons) "powerConsumption": lambda x: "powerMeasuring.powerConsumption[0]|value ", "activePower": lambda x: "sensors[?type == 'activePower']|[0]|value", "periodS": "powerMeasuring.powerConsumption[0]|periodS", "measurement_enabled": "powerMeasuring.enabled", }, ] ], }, 20200831: { "extended_state_path": "/state/extended", "api_path": "/state/extended", "api": { "on": lambda x: ("GET", f"/s/{int(x)}/1", None), "off": lambda x=None: ("GET", f"/s/{int(x)}/0", None), }, "switches": [ [ "0.relay", {"state": lambda x: f"relays[?relay==`{x}`]|[0]|state"}, "relay", 0, ], [ "1.relay", {"state": lambda x: f"relays[?relay==`{x}`]|[0]|state"}, "relay", 1, ], ], "sensors": [ [ "switchBox.energy", { # note: switchbox/switchboxD sensors are currently not indexed (singletons) "powerConsumption": lambda x: "powerMeasuring.powerConsumption[0]|value ", "activePower": lambda x: "sensors[?type == 'activePower']|[0]|value", "periodS": "powerMeasuring.powerConsumption[0]|periodS", "measurement_enabled": "powerMeasuring.enabled", }, ] ], }, }, # tempSensor "tempSensor": { 20180604: { "api_path": "/api/tempsensor/state", "sensors": [ [ "0.temperature", { "temperature": "tempSensor.sensors[?id == `0`]|[0]|value", "trend": "tempSensor.sensors[?id == `0`]|[0]|trend", "state": "tempSensor.sensors[?id == `0`]|[0]|state", "elapsed": "tempSensor.sensors[?id == `0`]|[0]|elapsedTimeS", }, ] ], } }, # wLightBox "wLightBox": { # default_API 20151206: { "api_path": "/api/device/state", "api": { "set": lambda x: ( "POST", "/api/rgbw/set", f'{{"rgbw":{{"desiredColor": "{str(x)}"}}}}', ), }, "lights": [ [ "color", { "desired": "rgbw.desiredColor", "last_color": "rgbw.lastOnColor", "currentEffect": "rgbw.effectID", "colorMode": "rgbw.colorMode", }, ] ], }, 20190808: { "api_path": "/api/rgbw/state", "extended_state_path": "/api/rgbw/extended/state", "api": { "set": lambda x: ( "POST", "/api/rgbw/set", f'{{"rgbw":{{"desiredColor": "{str(x)}"}}}}', ), "effect": lambda x: ("GET", f"/s/x/{x}", None), }, "lights": [ [ "color", { "desired": "rgbw.desiredColor", "last_color": "rgbw.lastOnColor", "currentEffect": "rgbw.effectID", "colorMode": "rgbw.colorMode", }, ] ], }, 20200229: { "api_path": "/api/rgbw/state", "extended_state_path": "/api/rgbw/extended/state", "api": { "set": lambda x: ( "POST", "/api/rgbw/set", f'{{"rgbw": {{"desiredColor": "{x}"}}}}', ), "effect": lambda x: ("GET", f"/s/x/{x}"), }, "lights": [ [ "color", { "desired": "rgbw.desiredColor", "last_color": "rgbw.lastOnColor", "currentEffect": "rgbw.effectID", "colorMode": "rgbw.colorMode", }, ], ], }, }, # wLightBoxS "wLightBoxS": { # default_API 20151206: { "api_path": "/api/device/state", "api": { "set": lambda x: ( "POST", "/api/light/set", f'{{"light": {{"desiredColor": "{x}"}}}}', ), }, "lights": [ [ "brightness", { "desired": "light.desiredColor", }, ] ], }, 20180718: { "api_path": "/api/light/state", "api": { "set": lambda x: ( "POST", "/api/light/set", f'{{"light": {{"desiredColor": "{x}"}}}}', ), "effect": lambda x: ("GET", f"/s/x/{x}"), }, "lights": [ [ "brightness", { "desired": "light.desiredColor", }, ] ], }, 20200229: { "api_path": "/api/rgbw/state", "extended_state_path": "/api/rgbw/extended/state", "api": { "set": lambda x: ( "POST", "/api/rgbw/set", f'{{"rgbw": {{"desiredColor": "{x}"}}}}', ), "effect": lambda x: ("GET", f"/s/x/{x}"), }, "lights": [ [ "brightness", { "desired": "rgbw.desiredColor", "colorMode": "rgbw.colorMode", "currentEffect": "rgbw.effectID", "last_color": "rgbw.lastOnColor", }, ] ], }, }, # multiSensor "multiSensor": { 20200831: { "api_path": "/state", "extended_state_path": "/state/extended", "sensors": [ [ "multiSensor", { "temperature": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", "wind": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", }, ] ], "binary_sensors": [ [ "multiSensor", { "rain": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", "flood": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", }, ] ], }, 20210413: { "api_path": "/state", "extended_state_path": "/state/extended", "sensors": [ [ "multiSensor", { "temperature": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", "wind": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", "humidity": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", }, ] ], "binary_sensors": [ [ "multiSensor", { "rain": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", "flood": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", }, ] ], }, 20220114: { "api_path": "/state", "extended_state_path": "/state/extended", "sensors": [ [ "multiSensor", { "illuminance": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", "temperature": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", "wind": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'wind']|[0]|value", "humidity": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", }, ] ], "binary_sensors": [ [ "multiSensor", { "rain": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", "flood": lambda x: f"multiSensor.sensors[?id == `{x}`]|[0]|value", }, ] ], }, 20230606: { "api_path": "/state", "extended_state_path": "/state/extended", "sensors": [ [ "multiSensor", { "frequency": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'frequency']|[0]|value", "current": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'current']|[0]|value", "voltage": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'voltage']|[0]|value", "apparentPower": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'apparentPower']|[0]|value", "activePower": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'activePower']|[0]|value", "reactivePower": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'reactivePower']|[0]|value", "reverseActiveEnergy": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'reverseActiveEnergy']|[0]|value", "forwardActiveEnergy": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'forwardActiveEnergy']|[0]|value", "illuminance": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'illuminance']|[0]|value", "temperature": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'temperature']|[0]|value", "wind": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'wind']|[0]|value", "humidity": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'humidity']|[0]|value", }, ] ], "binary_sensors": [ [ "multiSensor", { "rain": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'rain']|[0]|value", "flood": lambda x: f"multiSensor.sensors[?id == `{x}`]|[?type == 'flood']|[0]|value", }, ] ], }, }, } blebox-blebox_uniapi-16587f1/blebox_uniapi/button.py000066400000000000000000000043501466110602600225770ustar00rootroot00000000000000from .feature import Feature from typing import TYPE_CHECKING, Optional from enum import Enum, auto if TYPE_CHECKING: from .box import Box TV_LIFT_CONTROL_TYPES_API = { 0: {"1": "open_or_stop", "2": "close_or_stop"}, 1: {"1": "up_or_stop", "2": "down_or_stop"}, 2: {"1": "up_or_stop", "2": "down_or_stop"}, 3: {"1": "up_or_stop", "2": "down_or_stop"}, 4: {"1": "open_or_stop", "2": "close_or_stop", "3": "to_fav"}, } class ControlType(Enum): UP = auto() DOWN = auto() FAVORITE = auto() OPEN = auto() CLOSE = auto() class Button(Feature): def __init__( self, product: "Box", alias: str, methods: dict, query_string: str ) -> None: super().__init__(product, alias, methods) self._device_class = "UPDATE" self._query_string: str = query_string @classmethod def many_from_config(cls, product, box_type_config, extended_state): object_list = list() if len(box_type_config) > 0: alias = box_type_config[0] if isinstance(extended_state, dict) and extended_state is not None: lift_mode = extended_state.get("tvLift", {}).get("controlType", None) for row in TV_LIFT_CONTROL_TYPES_API[lift_mode].items(): indicator, endpoint = row object_list.append( cls(product, alias + "_" + endpoint, {}, endpoint) ) return object_list else: return [] async def set(self): await self.async_api_command("set", self.query_string) def after_update(self) -> None: pass @property def control_type(self) -> Optional[ControlType]: """Return icon for endpoint.""" if "up" in self.query_string: return ControlType.UP elif "down" in self.query_string: return ControlType.DOWN elif "fav" in self.query_string: return ControlType.FAVORITE elif "open" in self.query_string: return ControlType.OPEN elif "close" in self.query_string: return ControlType.CLOSE else: return None @property def query_string(self) -> str: return self._query_string blebox-blebox_uniapi-16587f1/blebox_uniapi/climate.py000066400000000000000000000123111466110602600226760ustar00rootroot00000000000000from .sensor import Temperature from typing import Optional, Any, Union from .feature import Feature from blebox_uniapi.jfollow import follow class Climate(Temperature): _is_on: Optional[bool] _desired: Union[float, int, None] _is_heating: Optional[bool] _is_cooling: Optional[bool] _min_temp: Union[float, int, None] _max_temp: Union[float, int, None] _mode: Optional[int] _havc_action: Optional[int] def __init__(self, product, alias, methods, mode): super().__init__(product, alias, methods) self._mode = mode @property def mode(self) -> Optional[int]: return self._mode @property def is_on(self) -> Optional[bool]: return self._is_on @property def desired(self) -> Any: return self._desired @property def current(self) -> Any: return self._current @property def max_temp(self) -> Union[float, int, None]: return self._max_temp @property def min_temp(self) -> Union[float, int, None]: return self._min_temp @property def is_heating(self) -> Optional[bool]: return self._is_heating @property def is_cooling(self) -> Optional[bool]: return self._is_cooling @property def hvac_action(self) -> Optional[int]: return self._havc_action @classmethod def many_from_config( cls, product, box_type_config, extended_state ) -> list["Feature"]: # note: by default single config entry yields single feature instance but certain feature # domains (e.g. lights) may handle this differently depending on their `extended_state` alias, methods = box_type_config[0] if extended_state is not None: safetyIdPath = box_type_config[0][1].get("safetySensorId") if safetyIdPath: safety_sensor_id = follow(extended_state, safetyIdPath) temp_sensor_id = cls.get_temp_sensor_id( safety_sensor_id, extended_state["sensors"] ) methods = Feature.resolve_access_method_paths(methods, temp_sensor_id) args = [alias, methods] mode = extended_state.get("thermo", {}).get("mode", 1) return [cls(product, *args, mode)] return [cls(product, *args, 1) for args in box_type_config] async def async_on(self) -> None: await self.async_api_command("on") async def async_off(self) -> None: await self.async_api_command("off") async def async_set_temperature(self, value: Any) -> None: await self.async_api_command("set", int(round(value * 100.0))) def _read_is_on(self) -> Optional[bool]: product = self._product if product.last_data is not None: raw = self.raw_value("state") if raw is not None: # no reading alias = self._alias return product.expect_int(alias, raw, 3, 0) in ( 1, 3, ) # 1: On, 3: Boost(thermoBox max temp of mode) return None def _read_operating_state(self) -> Optional[int]: """Return current operating state""" if self._product.last_data is not None: try: raw = self.raw_value("operatingState") if raw in range(0, 4) and isinstance(raw, int): return raw except KeyError: return None def _read_is_heating(self) -> Optional[bool]: if not self._product.last_data: return None return self.is_on and (self.current < self.desired) def _read_is_cooling(self) -> Optional[bool]: if not self._product.last_data: return None return self.is_on and (self.current > self.desired) def _read_mode(self) -> Optional[int]: if self._product.last_data is not None: try: raw = self.raw_value("mode") if raw in range(0, 3) and isinstance(raw, int): return raw except KeyError: return 1 return None def after_update(self) -> None: self._is_on = self._read_is_on() self._desired = self._read_temperature("desired") self._current = self._read_temperature("temperature") self._is_heating = self._read_is_heating() self._is_cooling = self._read_is_cooling() self._havc_action = self._read_operating_state() if self._product.last_data is None: self._min_temp = None self._max_temp = None return raw_min = self.raw_value("minimum") if raw_min is None: return self._min_temp = self._read_temperature("minimum") self._max_temp = self._read_temperature("maximum") @staticmethod def get_temp_sensor_id(safety_sensor_id: int, sensor_list) -> Optional[int]: """Return ID of the first sensor which is not a safety sensor.""" li_sensor_id = [ sensor.get("id") for sensor in sensor_list if sensor.get("id") is not None and sensor.get("id") != safety_sensor_id ] li_sensor_id.sort() if not li_sensor_id: return None return li_sensor_id[0] blebox-blebox_uniapi-16587f1/blebox_uniapi/cover.py000066400000000000000000000300321466110602600223760ustar00rootroot00000000000000from enum import IntEnum, auto from .error import MisconfiguredDevice from .feature import Feature from typing import TYPE_CHECKING, Any, Optional, Type, TypeVar if TYPE_CHECKING: from .box import Box class BleboxCoverState(IntEnum): """BleboxCoverState defines possible states of cover devices. Note that enumeration of states is partially shared between different types of devices (shutterBox, gateController) but not all states are possible for every type. For details of states refer to blebox official API documentation. """ MOVING_DOWN = 0 MOVING_UP = 1 MANUALLY_STOPPED = 2 LOWER_LIMIT_REACHED = 3 UPPER_LIMIT_REACHED = 4 OVERLOAD = 5 MOTOR_FAILURE = 6 UNUSED = 7 SAFETY_STOP = 8 class ShutterBoxControlType(IntEnum): """ShutterBoxControlType defines shuterBox command semantics""" SEGMENTED_SHUTTER = 1 NO_CALIBRATION = 2 TILT_SHUTTER = 3 WINDOW_OPENER = 4 MATERIAL_SHUTTER = 5 AWNING = 6 SCREEN = 7 CURTAIN = 8 class GateBoxControlType(IntEnum): """GateBoxControlType defines gateBox command semantics known as `openCloseMode`. Control type affects mainly [o]pen, [c]lose, and [n]ext commands which are wrappers around [p]rimary and [s]econdary outputs. The only exception is OPEN_CLOSE (2) control type that also means that the gateBox lacks stop action because typical stop output is wired to [c]lose/[s]econdary command. """ STEP_BY_STEP = 0 ONLY_OPEN = 1 OPEN_CLOSE = 2 class GateBoxGateType(IntEnum): """GateBoxGateType defines possible gate/cover types reported by gateBox""" SLIDING_DOOR = 0 GARAGE_DOOR = 1 OVER_DOOR = 2 DOOR = 3 class UnifiedCoverType(IntEnum): """UnifiedCoverType defines single "cover type" concept shared between different devices. Some device types have concept of control type/mode that affects how device operates and how it is being used (e.g. control type in shutterBox/gateControler), but others have these two concepts separated (e.g. open mode vs. gate type in gateBox). This enum provides unified concept of controlled cover type that can be infered from internal device information end exposed to library user. """ AWNING = auto() BLIND = auto() CURTAIN = auto() DAMPER = auto() DOOR = auto() GARAGE = auto() GATE = auto() SHADE = auto() SHUTTER = auto() WINDOW = auto() class Gate: _control_type: Optional[int] def __init__(self, control_type: int): self._control_type = control_type def read_state(self, alias: str, raw_value: Any, product: "Box") -> int: raw = raw_value("state") return product.expect_int(alias, raw, max(BleboxCoverState).value, 0) def read_desired(self, alias: str, raw_value: Any, product: "Box") -> Optional[int]: raw = raw_value("desired") min_position = self.min_position return product.expect_int(alias, raw, 100, min_position) def read_tilt(self, alias: str, raw_value: Any, product: "Box") -> int: raw = raw_value("tilt") min_position = self.min_position return product.expect_int(alias, raw, 100, min_position) def read_cover_type( self, alias: str, raw_value: Any, product: "Box" ) -> UnifiedCoverType: return UnifiedCoverType.GATE @property def min_position(self) -> int: return 0 @property def is_slider(self) -> bool: return True @property def has_tilt(self) -> bool: return False @property def open_command(self) -> str: return "open" @property def close_command(self) -> str: return "close" def stop_command(self, has_stop: bool) -> str: return "stop" def read_has_stop(self, alias: str, raw_value: Any, product: "Box") -> bool: return True class Shutter(Gate): _control_type: Optional[ShutterBoxControlType] @property def min_position(self) -> int: return -1 # "unknown" @property def has_tilt(self) -> bool: return self._control_type == ShutterBoxControlType.TILT_SHUTTER def read_cover_type( self, alias: str, raw_value: Any, product: "Box" ) -> UnifiedCoverType: if self._control_type == ShutterBoxControlType.SEGMENTED_SHUTTER: return UnifiedCoverType.SHUTTER if self._control_type == ShutterBoxControlType.NO_CALIBRATION: return UnifiedCoverType.SHUTTER if self._control_type == ShutterBoxControlType.TILT_SHUTTER: return UnifiedCoverType.SHUTTER if self._control_type == ShutterBoxControlType.WINDOW_OPENER: return UnifiedCoverType.WINDOW if self._control_type == ShutterBoxControlType.MATERIAL_SHUTTER: return UnifiedCoverType.SHADE if self._control_type == ShutterBoxControlType.AWNING: return UnifiedCoverType.AWNING if self._control_type == ShutterBoxControlType.SCREEN: return UnifiedCoverType.SHADE if self._control_type == ShutterBoxControlType.CURTAIN: return UnifiedCoverType.CURTAIN class GateBox(Gate): _control_type: Optional[GateBoxControlType] @property def is_slider(self) -> bool: return False @property def open_command(self) -> str: return "primary" @property def close_command(self) -> str: return "primary" def read_state(self, alias: str, raw_value: Any, product: "Box") -> int: # Reinterpret state to match shutterBox # NOTE: shutterBox is inverted (0 == closed), gateBox isn't current = raw_value("position") desired = raw_value("desired") # gate with gateBox visualized: # (0) [ <#####] (100) if current == -1: return None if desired < current: return 0 # closing if desired > current: return 1 # opening if current == 0: # closed return 3 # closed (lower/left limit) if current == 100: # opened return 4 # open (upper/right limit) return 2 # manually stopped def stop_command(self, has_stop: bool) -> str: if not has_stop: raise MisconfiguredDevice("second button not configured as 'stop'") return "secondary" def read_has_stop(self, alias: str, raw_value: Any, product: "Box") -> bool: if product.last_data is None: return False button_type = raw_value("extraButtonType") if button_type is None: return False return button_type == 1 class GateBoxB(GateBox): def read_state(self, alias: str, raw_value: Any, product: "Box") -> int: current = raw_value("position") # gate with gateBox visualized: # (0) [ <#####] (100) if current == -1: return None elif 0 < current < 100: return 2 # manually stopped elif current == 0: # closed return 3 # closed (lower/left limit) return 4 # open (upper/right limit) def read_desired(self, alias: str, raw_value: Any, product: "Box") -> Optional[int]: return raw_value("position") def read_has_stop(self, alias: str, raw_value: Any, product: "Box") -> bool: # note: if control type is unknown we assume it is not open/close # and has the stop feature via secondary button command. return self._control_type != GateBoxControlType.OPEN_CLOSE def read_cover_type( self, alias: str, raw_value: Any, product: "Box" ) -> Optional[UnifiedCoverType]: if (gate_type := raw_value("gate_type")) is None: return if gate_type == GateBoxGateType.GARAGE_DOOR: return UnifiedCoverType.GARAGE if gate_type == GateBoxGateType.SLIDING_DOOR: return UnifiedCoverType.GATE return UnifiedCoverType.DOOR @property def close_command(self) -> str: if self._control_type == GateBoxControlType.OPEN_CLOSE: return "secondary" return super().close_command GateT = TypeVar("GateT", bound=Gate) # TODO: handle tilt class Cover(Feature): _desired: Optional[int] _state: Optional[BleboxCoverState] _has_stop: Optional[bool] _cover_type: Optional[UnifiedCoverType] def __init__( self, product: "Box", alias: str, methods: dict, dev_class: str, subclass: Type[GateT], extended_state: dict, ) -> None: control_type = None if extended_state and issubclass(subclass, Shutter): control_type = extended_state.get("shutter", {}).get("controlType", None) elif extended_state and issubclass(subclass, GateBoxB): control_type = extended_state.get("gate", {}).get("openCloseMode", None) self._device_class = dev_class self._attributes: GateT = subclass(control_type) self._tilt_current = None super().__init__(product, alias, methods) @classmethod def many_from_config( cls, product, box_type_config, extended_state ) -> list["Feature"]: return [cls(product, *args, extended_state) for args in box_type_config] @property def current(self) -> Any: return self._desired @property def state(self) -> Any: return self._state @property def tilt_current(self): return self._tilt_current @property def is_slider(self) -> Any: return self._attributes.is_slider @property def has_tilt(self) -> bool: return self._attributes.has_tilt @property def has_stop(self) -> bool: return self._has_stop @property def cover_type(self) -> Optional[UnifiedCoverType]: return self._cover_type async def async_open(self) -> None: await self.async_api_command(self._attributes.open_command) async def async_close(self) -> None: await self.async_api_command(self._attributes.close_command) async def async_stop(self) -> None: await self.async_api_command(self._attributes.stop_command(self._has_stop)) async def async_set_position(self, value: Any) -> None: if not self.is_slider: raise NotImplementedError await self.async_api_command("position", value) async def async_set_tilt_position(self, value: Any) -> None: if self.has_tilt: await self.async_api_command("tilt", value) else: raise NotImplementedError async def async_close_tilt(self, **kwargs: Any) -> None: await self.async_api_command("tilt", 100) async def async_open_tilt(self, **kwargs: Any) -> None: await self.async_api_command("tilt", 0) def _read_cover_type(self) -> Optional[UnifiedCoverType]: product = self._product if not product.last_data: return None alias = self._alias return self._attributes.read_cover_type(alias, self.raw_value, self._product) def _read_desired(self) -> Any: product = self._product if not product.last_data: return None alias = self._alias return self._attributes.read_desired(alias, self.raw_value, self._product) # TODO: refactor def _read_state(self) -> Any: product = self._product if not product.last_data: return None alias = self._alias return self._attributes.read_state(alias, self.raw_value, self._product) def _read_tilt(self) -> Any: product = self._product if not product.last_data: return None alias = self._alias return self._attributes.read_tilt(alias, self.raw_value, self._product) def _read_has_stop(self) -> bool: return self._attributes.read_has_stop( self._alias, self.raw_value, self._product ) def after_update(self) -> None: self._desired = self._read_desired() self._state = self._read_state() self._has_stop = self._read_has_stop() self._cover_type = self._read_cover_type() if self._attributes.has_tilt: self._tilt_current = self._read_tilt() blebox-blebox_uniapi-16587f1/blebox_uniapi/error.py000066400000000000000000000053141466110602600224160ustar00rootroot00000000000000class Error(RuntimeError): """Generic blebox_uniapi error.""" pass # Likely fixable/retriable network/busy errors class ConnectionError(Error): pass class TimeoutError(ConnectionError): pass # Likely unfixable device errors (do not setup) class ClientError(Error): pass class HttpError(ClientError): pass class UnauthorizedRequest(ClientError): pass # API errors class BoxError(Error): pass class UnsupportedBoxResponse(BoxError): pass class UnsupportedBoxVersion(BoxError): pass class BadFieldExceedsMax(BoxError): def __init__(self, dev_name: str, field: str, value: int, max_value: int): self._dev_name = dev_name self._field = field self._value = value self._max_value = max_value def __str__(self) -> str: return f"{self._dev_name}.{self._field} is {self._value} which exceeds max ({self._max_value})" class BadFieldLessThanMin(BoxError): def __init__(self, dev_name: str, field: str, value: int, min_value: int): self._dev_name = dev_name self._field = field self._value = value self._min_value = min_value def __str__(self) -> str: return f"{self._dev_name}.{self._field} is {self._value} which is less than minimum ({self._min_value})" class BadFieldMissing(BoxError): def __init__(self, dev_name: str, field: str): self._dev_name = dev_name self._field = field def __str__(self) -> str: return f"{self._dev_name}.{self._field} is missing" class BadFieldNotANumber(BoxError): def __init__(self, dev_name: str, field: str, value: int): self._dev_name = dev_name self._field = field self._value = value def __str__(self) -> str: return ( f"{self._dev_name}.{self._field} is '{self._value}' which is not a number" ) class BadFieldNotAString(BoxError): def __init__(self, dev_name: str, field: str, value: int): self._dev_name = dev_name self._field = field self._value = value def __str__(self) -> str: return f"{self._dev_name}.{self._field} is {self._value} which is not a string" class BadFieldNotRGBW(BoxError): def __init__(self, dev_name: str, field: str, value: int): self._dev_name = dev_name self._field = field self._value = value def __str__(self) -> str: return f"{self._dev_name}.{self._field} is {self._value} which is not a rgbw string" # misc errors class MisconfiguredDevice(BoxError): pass # development bugs that shouldn't normally be possible class DeviceStateNotAvailable(BoxError): def __str__(self) -> str: return "device state not available yet" # pragma: no cover blebox-blebox_uniapi-16587f1/blebox_uniapi/feature.py000066400000000000000000000047071466110602600227250ustar00rootroot00000000000000from .error import DeviceStateNotAvailable from typing import Any, TYPE_CHECKING, Union from blebox_uniapi.jfollow import follow if TYPE_CHECKING: from .box import Box class Feature: _device_class: str def __init__(self, product: "Box", alias: str, methods: dict): self._product = product self._alias = alias self._methods = methods @classmethod def many_from_config( cls, product, box_type_config, extended_state ) -> list["Feature"]: # note: by default single config entry yields single feature instance but certain feature # domains (e.g. lights) may handle this differently depending on their `extended_state` return [cls(product, *args) for args in box_type_config] @property def unique_id(self) -> str: return f"BleBox-{self._product.type}-{self._product.unique_id}-{self._alias}" async def async_update(self) -> None: await self._product.async_update_data() @property def full_name(self) -> str: product = self._product return f"{product.name} ({product.type}#{self._alias})" @property def device_class(self) -> str: return self._device_class @property def product(self) -> "Box": return self._product @property def alias(self): return self._alias # TODO: (cleanup) move to product/box ? def raw_value(self, name: str) -> Any: product = self._product # TODO: better exception? if product.last_data is None: # TODO: coverage raise DeviceStateNotAvailable # pragma: no cover methods = self._methods if method := methods.get(name): return follow(product.last_data, method) return None async def async_api_command(self, *args: Any, **kwargs: Any) -> None: await self._product.async_api_command(*args, **kwargs) @staticmethod def resolve_access_method_paths( methods: dict[str, Union[str, callable]], id_val: str = None ) -> dict[str, str]: """Return dict with resolved callable used as data path.""" new = dict() if not isinstance(methods, dict): raise TypeError( f"Parameter methods should be dict, instead of {type(methods)}." ) for key, value in methods.items(): if callable(value): new[key] = value(id_val) else: new[key] = value return new blebox-blebox_uniapi-16587f1/blebox_uniapi/jfollow.py000066400000000000000000000004261466110602600227400ustar00rootroot00000000000000from typing import Any, Union import jmespath def follow(data: Union[dict, list], path: str) -> Any: if data is None: raise RuntimeError(f"bad argument: data {data}") # pragma: no cover expression = jmespath.compile(path) return expression.search(data) blebox-blebox_uniapi-16587f1/blebox_uniapi/light.py000066400000000000000000000517231466110602600224010ustar00rootroot00000000000000from enum import IntEnum from .feature import Feature from typing import TYPE_CHECKING, Optional, Dict, Any, Union, Sequence if TYPE_CHECKING: from .box import Box # V3 onwards used while device is operating on 5 channels, refactor if new versions occurs ctx2_v3 = {"cct1": lambda x: f"{x}------", "cct2": lambda x: f"----{x}--"} ctx2 = {"cct1": lambda x: f"{x}----", "cct2": lambda x: f"----{x}"} mono = { "mono1": lambda x: f"{x}------", "mono2": lambda x: f"--{x}----", "mono3": lambda x: f"----{x}--", "mono4": lambda x: f"------{x}", } class BleboxColorMode(IntEnum): RGBW = 1 # RGB color-space with color brightness, white brightness RGB = 2 # RGB color-space with color brightness MONO = 3 RGBorW = 4 # RGBW entity, where white color is prioritised CT = 5 # color-temperature, brightness, effect CTx2 = 6 # color-temperature, brightness, effect, two instances RGBWW = 7 # RGB with two color-temperature sliders(warm, cold) @classmethod def invert(cls): return {item.value: item.name for item in cls} BLEBOX_COLOR_MODES = BleboxColorMode.invert() class Light(Feature): # TODO: better defaults? CURRENT_CONF = dict() CONFIG = { "wLightBox": { "default": "FFFFFFFF", "off": "00000000", "white?": True, "color?": True, "to_value": lambda int_value: f"{int_value:02x}", "validator": lambda product, alias, raw: product.expect_rgbw(alias, raw), }, "wLightBoxS": { "default": "FF", "off": "00", "white?": False, "color?": False, "to_value": lambda int_value: f"{int_value:02x}", "validator": lambda product, alias, raw: product.expect_hex_str( alias, raw, 255, 0 ), }, "dimmerBox": { "default": 0xFF, "off": 0x0, "white?": False, "color?": False, "to_value": lambda int_value: int_value, "validator": lambda product, alias, raw: product.expect_int( alias, raw, 255, 0 ), }, } COLOR_MODE_CONFIG = { "CT": { "default": "FFFFFFFF", "off": "0000", "white?": False, "color?": False, "to_value": lambda int_value: f"{int_value:02x}", "validator": lambda product, alias, raw: product.expect_hex_str( alias, raw, 255, 0 ), }, "CTx2": { "default": "FFFFFFFF", "off": "0000", "white?": False, "color?": False, "to_value": lambda int_value: f"{int_value:02x}", "validator": lambda product, alias, raw: product.expect_hex_str( alias, raw, 255, 0 ), }, "RGBWW": { "default": "FFFFFFFFFF", "off": "0000000000", "white?": True, "color?": True, "to_value": lambda int_value: f"{int_value:02x}", "validator": lambda product, alias, raw: product.expect_hex_str( alias, raw, 255, 0 ), }, } def __init__( self, product: "Box", alias: str, methods: dict, extended_state: Optional[Dict], mask: Any, desired_color, color_mode, effect_list, current_effect, ) -> None: super().__init__(product, alias, methods) config = self.CONFIG[product.type] self.mask = mask self.desired_color = desired_color self._color_mode = color_mode self._effect_list = effect_list if self._effect_list is not None: self._effect = current_effect if extended_state not in [None, {}]: self.extended_state = extended_state self.device_colorMode = color_mode if self.device_colorMode in [6, 7]: config = self.COLOR_MODE_CONFIG[ BLEBOX_COLOR_MODES[self.device_colorMode] ] else: if product.type == "dimmerBox": self.device_colorMode = BleboxColorMode.MONO self.CURRENT_CONF = config self._off_value = self.evaluate_off_value(config, desired_color) self._last_on_state = self._default_on_value = config["default"] @classmethod def many_from_config( cls, product, box_type_config, extended_state ) -> list["Light"]: if isinstance(extended_state, dict) and extended_state is not None: desired_color = extended_state.get("rgbw", {}).get("desiredColor") color_mode = extended_state.get("rgbw", {}).get("colorMode") current_effect = extended_state.get("rgbw", {}).get("effectID") effect_list = extended_state.get("rgbw", {}).get("effectsNames") else: desired_color = None color_mode = None current_effect = None effect_list = None alias, methods = box_type_config[0] const_kwargs = dict( methods=methods, extended_state=extended_state, desired_color=desired_color, color_mode=color_mode, current_effect=current_effect, effect_list=effect_list, ) if extended_state is not None and color_mode is not None: if BleboxColorMode(color_mode).name == "RGBW": if len(desired_color) == 10: def generate_mask(x): return f"{x}--" mask = generate_mask else: mask = None return [cls(product, alias=alias + "_RGBW", mask=mask, **const_kwargs)] if BleboxColorMode(color_mode).name == "RGB": return [cls(product, alias=alias + "_RGB", mask=None, **const_kwargs)] if BleboxColorMode(color_mode).name == "MONO": if len(desired_color) % 2 == 0: object_list = [] mono_item = list(mono.items()) for i in range(0, int(len(desired_color) / 2)): indicator, mask = mono_item[i] object_list.append( cls( product, alias=alias + "_" + indicator, mask=mask, **const_kwargs, ) ) return object_list if BleboxColorMode(color_mode).name == "RGBorW": return [ cls(product, alias=alias + "_RGBorW", mask=None, **const_kwargs) ] if BleboxColorMode(color_mode).name == "CT": mask = ctx2["cct1"] if len(desired_color) > 8: mask = ctx2_v3["cct1"] return [cls(product, alias=alias + "_cct", mask=mask, **const_kwargs)] if BleboxColorMode(color_mode).name == "CTx2": object_list = [] ct = ctx2 if len(desired_color) > 8: ct = ctx2_v3 for indicator, mask in ct.items(): object_list.append( cls( product, alias=alias + "_" + indicator, mask=mask, **const_kwargs, ) ) return object_list if BleboxColorMode(color_mode).name == "RGBWW": return [ cls(product, alias=alias + "_RGBCCT", mask=None, **const_kwargs) ] if len(box_type_config) > 0: del const_kwargs["methods"] if "Brightness" in box_type_config[0][1].get("desired"): const_kwargs["color_mode"] = BleboxColorMode.MONO return [ cls(product, *args, mask=None, **const_kwargs) for args in box_type_config ] else: return [] @property def brightness(self) -> Optional[int]: if self.color_mode in [6, 5]: _, bgt = self.color_temp_brightness_int_from_hex(self._desired) return bgt elif self.color_mode is not None and ( rgb_list := self.rgb_hex_to_rgb_list(self.rgb_hex) ): return self.evaluate_brightness_from_rgb(rgb_list) else: return None @property def effect_list(self): if isinstance(self._effect_list, dict): return list(self._effect_list.values()) else: return [] @property def color_temp(self): ct, _ = self.color_temp_brightness_int_from_hex(self._desired) return ct @staticmethod def evaluate_brightness_from_rgb(iterable: Sequence[int]) -> int: "return brightness from 0 to 255 evaluated basing rgb" if max(iterable) > 255: raise ValueError( f"evaluate_brightness_from_rgb values out of range, max is {max(iterable)}." ) elif min(iterable) < 0: raise ValueError( f"evaluate_brightness_from_rgb values out of range, min is {min(iterable)}." ) return int(max(iterable)) def apply_brightness(self, value: int, brightness: int) -> Any: """Return list of values with applied brightness.""" if not isinstance(brightness, int): raise ValueError( f"adjust_brightness called with bad parameter ({brightness} is {type(brightness)} instead of int)" ) if brightness > 255: raise ValueError( f"adjust_brightness called with bad parameter ({brightness} is greater than 255)" ) if self.product.type == "dimmerBox" or self.color_mode == BleboxColorMode.MONO: return [brightness] if brightness == 0: return [value] res = list(map(lambda x: round(x * (brightness / 255)), value)) return res def evaluate_off_value(self, config: dict, raw_hex: str) -> str: """ Return hex representing off state value without mask formatting for necessary channels if mask is applied. If no mask applied than return default from config :param config: :param raw_hex: :return: str """ if self.mask: if len(raw_hex) < len(self.mask("x").replace("x", "")): return "0" * len(raw_hex) else: return "0" * (len(raw_hex) - len(self.mask("x").replace("x", ""))) elif raw_hex is not None: if len(raw_hex) < len(config["off"]): return config["off"][: len(raw_hex)] return config["off"] @property def supports_white(self) -> Any: return self.CURRENT_CONF["white?"] @property def white_value(self) -> Optional[int]: return self._white_value def apply_white(self, value: str, white: int) -> Union[int, str]: if white is None: return value if not self.supports_white: return value rgbhex = value[0:6] white_raw = f"{white:02x}" return f"{rgbhex}{white_raw}" @property def supports_color(self) -> Any: return self.CURRENT_CONF["color?"] @property def color_mode(self) -> int: return self._color_mode def apply_color(self, value: str, rgb_hex: str) -> Union[int, str]: if rgb_hex is None: return value if not self.supports_color: return value white_hex = value[6:8] return f"{rgb_hex}{white_hex}" def return_color_temp_with_brightness( self, value, brightness: Any ) -> Optional[str]: """Method returns value which will be send to""" if value < 128: warm = min(255, value * 2) cold = 255 else: warm = 255 cold = max(0, min(255, (255 - value) * 2)) cold = cold * brightness / 255 warm = warm * brightness / 255 cold = f"{int(round(cold)):02x}" warm = f"{int(round(warm)):02x}" return self.rgb_hex_to_rgb_list(warm + cold) def value_for_selected_channels_from_given_val(self, value: str): if self.color_mode in [BleboxColorMode.CT, BleboxColorMode.CTx2]: lambda_result = self.mask("xxxx") elif self.color_mode == BleboxColorMode.MONO: lambda_result = self.mask("xx") elif self.color_mode == BleboxColorMode.RGB: lambda_result = self.mask("xxxxxx") elif ( self.color_mode == BleboxColorMode.RGBW or self.color_mode == BleboxColorMode.RGBorW ): lambda_result = self.mask("xxxxxxxx") first_index = lambda_result.index("x") last_index = lambda_result.rindex("x") return value[first_index : last_index + 1] @staticmethod def color_temp_brightness_int_from_hex(val) -> (int, int): """Assuming that hex is 2channels, 4characters. Return values for front end""" cold = int(val[2:], 16) warm = int(val[0:2], 16) if cold > warm: if warm == 0: return 0, cold else: return round(int((128 * (warm * 255 / cold) / 255)), 2), cold if cold < warm: if cold == 0: return 255, warm else: return round(int(255 - 128 * (cold * (255 / warm) / 255)), 2), warm else: return 128, max(cold, warm) @staticmethod def normalise_elements_of_rgb(elements): max_val = max(elements) min_val = min(elements) if 0 > max_val or max_val > 255: raise ValueError(f"Max value in normalisation was outside range {max_val}.") elif min_val < 0: raise ValueError(f"Min value in normalisation was outside range {min_val}.") elif max_val == 0: return [255] * len(elements) return list(map(lambda x: round(x * 255 / max_val), elements)) @property def is_on(self) -> Optional[bool]: return self._is_on @property def effect(self) -> Optional[str]: if isinstance(self._effect_list, dict): return self._effect_list.get(str(self._effect)) return self._effect def after_update(self) -> None: alias = self._alias product = self._product if product.last_data is None: self._desired_raw = None self._desired = None self._is_on = None self._effect = None if self.mask is None: self._white_value = None return self._effect = self.raw_value("currentEffect") raw = self._return_desired_value(alias, product) self._set_last_on_value(alias, product, raw) self._set_is_on() def _set_last_on_value(self, alias, product, raw): if raw == self._off_value: if ( product.type == "wLightBox" ): # jezeli urzadzenie typu wLightBox ma wyciagnac last_color raw = product.expect_rgbw(alias, self.raw_value("last_color")) if self.mask is not None: raw = self.value_for_selected_channels_from_given_val(raw) if raw == self._off_value: raw = self.value_for_selected_channels_from_given_val( "ffffffffff" ) else: if raw == self._off_value: raw = "f" * len(raw) else: raw = self._default_on_value if raw in (self._off_value, None): raise ValueError(raw) # TODO: store as custom value permanently (exposed by API consumer) self._last_on_state = raw def _set_is_on(self): self._is_on = (self._off_value != self._desired) or ( self._effect != 0 and self._effect is not None ) if isinstance(self._desired, str): if int(self._desired, 16) == 0: self._is_on = False def _return_desired_value(self, alias, product) -> str: """ Return value representing desired device state, set desired fields :param alias: :param product: :return desired value including mask: """ response_desired_val = self.raw_value("desired") if self.mask is not None: raw = self.value_for_selected_channels_from_given_val(response_desired_val) self._desired = self.CONFIG[self._product.type]["validator"]( product, alias, raw ) if self.color_mode in [1, 4]: self._white_value = int(raw[6:8], 16) else: raw = response_desired_val self._desired_raw = raw self._desired = self.CONFIG[self._product.type]["validator"]( product, alias, raw ) # type: ignore if self.color_mode in [1, 4]: self._white_value = int(raw[6:8], 16) return raw @property def sensible_on_value(self) -> Any: """Return sensible on value in hass format.""" if self.mask is not None: if int(self._last_on_state, 16) == 0: if self.color_mode in (BleboxColorMode.RGBW, BleboxColorMode.RGBorW): return 255, 255, 255, 255 if self.color_mode == BleboxColorMode.MONO: return 255 if self.color_mode in (BleboxColorMode.CT, BleboxColorMode.CTx2): return 255, 255 else: if self.color_mode == BleboxColorMode.MONO: return self.rgb_hex_to_rgb_list(self._last_on_state) return self.normalise_elements_of_rgb( self.rgb_hex_to_rgb_list(self._last_on_state) ) else: if isinstance(self._last_on_state, str): if int(self._last_on_state, 16) == 0: return [255] * len(self.rgb_hex_to_rgb_list(self._last_on_state)) else: if self.color_mode == BleboxColorMode.RGB: return self.normalise_elements_of_rgb( self.rgb_hex_to_rgb_list(self._last_on_state[:6]) ) elif self.color_mode == BleboxColorMode.MONO: return self._last_on_state else: return self.rgb_hex_to_rgb_list(self._last_on_state) else: return self._last_on_state @property def rgb_hex(self) -> Any: """Return hex str representing rgb.""" if isinstance(self._desired, int): return f"{self._desired:02x}" else: return self._desired @property def rgbw_hex(self) -> Any: return self._desired @property def rgbww_hex(self) -> Any: if len(self._desired) < 10: return None else: hex_str = self._desired hex_str_warm_cold = hex_str[6:] output_str = hex_str[0:6] + "".join( [ hex_str_warm_cold[i - 2 : i] for i in range(len(hex_str_warm_cold), 0, -2) ] ) return output_str @staticmethod def rgb_hex_to_rgb_list(hex_str) -> list[int]: """Return an RGB color value list from a hex color string.""" if hex_str is not None: return [int(hex_str[i : i + 2], 16) for i in range(0, len(hex_str), 2)] return [] @staticmethod def rgb_list_to_rgb_hex_list(rgb_list) -> hex: return [f"{i:02x}" for i in rgb_list] async def async_on(self, value: Any) -> None: if isinstance(value, (list, tuple)): if self.color_mode == BleboxColorMode.RGBWW: value.insert(3, value.pop()) value = "".join(self.rgb_list_to_rgb_hex_list(value)) if self.product.type == "dimmerBox": if not isinstance(value, int): value = int(value, 16) if not isinstance(value, type(self._off_value)): raise ValueError( f"turn_on called with bad parameter ({value} is {type(value)}, compared to {self._off_value} which is " f"{type(self._off_value)})" ) if value == self._off_value: raise ValueError(f"turn_on called with invalid value ({value})") if self.mask is not None: value = self.mask(value) await self.async_api_command("set", value) async def async_off(self) -> None: if self.raw_value("colorMode") in [5, 6]: await self.async_api_command("set", self.mask("0000")) elif self.raw_value("colorMode") == 3 and self.product.type != "dimmerBox": await self.async_api_command("set", self.mask("00")) else: await self.async_api_command("set", self._off_value) blebox-blebox_uniapi-16587f1/blebox_uniapi/sensor.py000066400000000000000000000224271466110602600226020ustar00rootroot00000000000000import datetime import numbers from functools import partial from .feature import Feature from typing import TYPE_CHECKING, Union, Optional if TYPE_CHECKING: from .box import Box class SensorFactory: device_constructors: dict[str, type] = {} @classmethod def register(cls, sensor_type: str, **kwargs): if sensor_type in cls.device_constructors: raise RuntimeError("Can't register same sensor type twice") def decorator(registrable: type): constructor = registrable if kwargs: constructor = partial(registrable, sensor_type=sensor_type, **kwargs) cls.device_constructors[sensor_type] = constructor # note: returning unmodified, so we can register registrable # multiple times under different names and with different kwargs return registrable return decorator @staticmethod def _sensor_states(extended_state: dict): """Read potential sensor states from extended state dictionary""" # note: probably we should iterate extended state in future if there # are other api flavours other than multiSensor that provide sensors states = extended_state.get("multiSensor", {}).get("sensors", []) # note: but for now we are only able to support non-multisensor devices # that provide sensor data in extended data payload root states.extend(extended_state.get("sensors", [])) # note: power measuring feature predates multiSensor API, so we need a small # shim to adapt older shape of power measuring schema to the new sensor API if "powerMeasuring" in extended_state: power_states = extended_state["powerMeasuring"].get("powerConsumption", []) # note: be careful of names as this has been historically named differently # in home-assistant states.extend({"type": "powerConsumption", **s} for s in power_states) return states @classmethod def many_from_config(cls, product, box_type_config, extended_state): if extended_state: object_list = [] # note: first item was historically an alias, but it has been since # abandoned. We still keep it in the box config. _, methods = box_type_config[0] for sensor in cls._sensor_states(extended_state): device_class = sensor.get("type") sensor_id = sensor.get("id") alias = device_class if sensor_id is not None: alias = f"{device_class}_{sensor_id}" if constructor := cls.device_constructors.get(device_class): # note: methods for sensor readings are provided as template # functions (lambdas) in the box config. We need to "materialize" # them to make sure they are properly indexed by sensor ID materialized_methods = { **methods, device_class: methods[device_class](sensor_id), } feature = constructor( product=product, alias=alias, methods=materialized_methods, sensor_id=sensor_id, ) object_list.append(feature) return object_list # legacy handling of some old device API that do not provide extended state alias, methods = box_type_config[0] if alias.endswith("air"): method_list = [method for method in methods if "value" in method] return [ AirQuality(product=product, alias=method.split(".")[0], methods=methods) for method in method_list ] if alias.endswith("temperature"): return [Temperature(product=product, alias=alias, methods=methods)] else: return [] class BaseSensor(Feature): _unit: str _device_class: str _native_value: Union[float, int, str] _sensor_type: Optional[str] _sensor_id: Optional[int] def __init__( self, product: "Box", alias: str, methods: dict, sensor_type: str = None, sensor_id: Optional[int] = None, ): self._sensor_type = sensor_type self._sensor_id = sensor_id super().__init__(product, alias, methods) @property def unit(self) -> str: return self._unit @property def device_class(self) -> str: return self._device_class @property def native_value(self): return self._native_value @property def sensor_id(self): return self._sensor_id @property def probe_id(self): return self.sensor_id @classmethod def many_from_config(cls, product, box_type_config, extended_state): raise NotImplementedError("Please use SensorFactory") def __str__(self): return f"<{self.__class__.__name__} sensor_type={self._sensor_type}, alias={self._alias}>" @SensorFactory.register("frequency", unit="Hz", scale=1_000) @SensorFactory.register("current", unit="mA", scale=1_000) @SensorFactory.register("voltage", unit="V", scale=10) @SensorFactory.register("apparentPower", unit="va") @SensorFactory.register("reactivePower", unit="var") @SensorFactory.register("activePower", unit="W") @SensorFactory.register("reverseActiveEnergy", unit="kWh") @SensorFactory.register("forwardActiveEnergy", unit="kWh") @SensorFactory.register("illuminance", unit="lx", scale=100) @SensorFactory.register("humidity", unit="percentage", scale=100) @SensorFactory.register("wind", unit="m/s", scale=10) class GenericSensor(BaseSensor): def __init__( # base sensor params self, product: "Box", alias: str, methods: dict, sensor_id: Optional[int], *, # generalization params sensor_type: str, unit: str, scale: float = 1, precision: Optional[int] = None, ): super().__init__(product, alias, methods, sensor_id=sensor_id) self._unit = unit self._scale = scale self._precision = precision # note: this seems redundant but there is at least one sensor type that # has different mapping in home assistant (wind/wind_speed). Should be # fixed in upstream first. self._device_class = sensor_type self._sensor_type = sensor_type def after_update(self): product = self._product if product.last_data is None: return raw = self.raw_value(self._device_class) if not isinstance(raw, numbers.Number): raw = float("nan") native = raw / self._scale if self._precision: native = round(native, self._precision) self._native_value = native @SensorFactory.register("powerConsumption", unit="kWh") class PowerConsumption(GenericSensor): # note: almost the same as typical generic sensor but also provides extra property # to read last reset value @property def last_reset(self): return datetime.datetime.now() - datetime.timedelta( seconds=self._read_period_of_measurement() ) def _read_period_of_measurement(self) -> int: product = self._product if product.last_data is not None: raw = self.raw_value("periodS") if raw is not None: alias = self._alias return product.expect_int(alias, raw, 3600, 0) return 0 @SensorFactory.register("temperature") class Temperature(BaseSensor): _current: Union[float, int, None] def __init__( self, product: "Box", alias: str, methods: dict, sensor_id: Optional[int] = None, ): super().__init__(product, alias, methods, sensor_id=sensor_id) self._unit = "celsius" self._device_class = "temperature" @property def current(self) -> Union[float, int, None]: return self._current def _read_temperature(self, field: str) -> Union[float, int, None]: product = self._product if product.last_data is not None: raw = self.raw_value(field) if raw is not None: alias = self._alias return round(product.expect_int(alias, raw, 12500, -5500) / 100.0, 1) return None def after_update(self) -> None: self._current = self._read_temperature("temperature") self._native_value = self._read_temperature("temperature") @SensorFactory.register("airSensor") class AirQuality(BaseSensor): _pm: Optional[int] def __init__( self, product: "Box", alias: str, methods: dict, sensor_id: Optional[str] = None, ): super().__init__(product, alias, methods, sensor_id) self._unit = "concentration_of_mp" self._device_class = alias def _pm_value(self, name: str) -> Optional[int]: product = self._product if product.last_data is not None: raw = self.raw_value(name) if raw is not None: alias = self._alias return product.expect_int(alias, raw, 3000, 0) return None def after_update(self) -> None: self._native_value = self._pm_value(f"{self.device_class}.value") blebox-blebox_uniapi-16587f1/blebox_uniapi/session.py000066400000000000000000000064161466110602600227540ustar00rootroot00000000000000from typing import Any, Optional, Union import aiohttp import asyncio import logging from . import error DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=None, sock_connect=5, sock_read=5) DEFAULT_PORT = 80 logger = logging.getLogger(__name__) class ApiHost: def __init__( self, host: str, port: int, timeout: int, session: Any, loop: Any, logger: logging.Logger = logger, **auth, ): self._host = host self._port = port self._username = auth.get("username") self._password = auth.get("password") # TODO: handle empty logger? self._logger = logger self._timeout = timeout if timeout else DEFAULT_TIMEOUT self._session = session auth = None if any(data is not None for data in [self._username, self._password]): auth = aiohttp.BasicAuth(login=self._username, password=self._password) if not self._session: self._session = aiohttp.ClientSession(loop=loop, timeout=timeout, auth=auth) # TODO: remove? self._loop = loop async def async_request( self, path: str, async_method: Any, data: Union[dict, str, None] = None ) -> Optional[dict]: # TODO: check timeout client_timeout = self._timeout url = self.api_path(path) try: if data is not None: response = await async_method(url, timeout=client_timeout, data=data) else: response = await async_method(url, timeout=client_timeout) if response.status != 200: if response.status == 401: raise error.UnauthorizedRequest( f"Request to {url} failed with HTTP {response.status}, UNAUTHORISED" ) raise error.HttpError( f"Request to {url} failed with HTTP {response.status}" ) return await response.json() except asyncio.TimeoutError as ex: raise error.TimeoutError( f"Failed to connect to {self.host}:{self.port} within {client_timeout}s: ({ex})" ) from ex except aiohttp.ClientConnectionError as ex: raise error.ConnectionError( f"Failed to connect to {self.host}:{self.port}: {ex}" ) from ex except aiohttp.ClientError as ex: raise error.ClientError(f"API request {url} failed: {ex}") from ex async def async_api_get(self, path: str) -> Optional[dict]: try: return await self.async_request(path, self._session.get) except Exception as ex: logger.error(f"EXCEPTION DURING API CALL: {ex}") raise ex async def async_api_post( self, path: str, data: Union[dict, str, None] ) -> Optional[dict]: return await self.async_request(path, self._session.post, data) def api_path(self, path: str) -> str: host = self._host port = self._port # TODO: url lib return f"http://{host}:{port}/{path[1:]}" @property def logger(self) -> Any: return self._logger @property def host(self) -> str: return self._host @property def port(self) -> int: return self._port blebox-blebox_uniapi-16587f1/blebox_uniapi/switch.py000066400000000000000000000050311466110602600225620ustar00rootroot00000000000000from .feature import Feature from typing import TYPE_CHECKING, Optional, Any, Union if TYPE_CHECKING: from .box import Box class Switch(Feature): _is_on: Optional[bool] def __init__( self, product: "Box", alias: str, methods: dict, dev_class: str, unit_id: Union[str, int, None] = 0, ): methods = self.resolve_access_method_paths(methods, str(unit_id)) super().__init__(product, alias, methods) self._device_class = dev_class self._unit_id = unit_id @classmethod def many_from_config( cls, product, box_type_config, extended_state ) -> list["Switch"]: """ :param product: Object hosting device with specific feature. :param box_type_config: Default configuration providing following data [ [feature_alias, {method_name: method_path_or_method_path_callable}, relay_type, unit_id] ] :param extended_state: Object hosting extended state recieved from device :return: List of class objects instances """ if extended_state: relay_list = list() alias, methods, relay_type, *rest = box_type_config[0] for relay in extended_state.get("relays", []): relay_id = relay.get("relay") relay_list.append( cls( product, alias + "_" + str(relay_id), methods, relay_type, relay_id, ) ) return relay_list else: return [cls(product, *args) for args in box_type_config] def after_update(self) -> None: self._is_on = self._read_is_on() def _read_is_on(self) -> Optional[bool]: product = self._product if product.last_data is not None: raw = self.raw_value("state") if raw is not None: # no reading alias = self._alias return 1 == product.expect_int(alias, raw, 1, 0) return None @property def is_on(self) -> Optional[bool]: return self._is_on @property def _unit_args(self) -> list: unit = self._unit_id return [] if unit is None else [unit] async def async_turn_on(self, **kwargs: Any) -> None: await self.async_api_command("on", *self._unit_args) async def async_turn_off(self, **kwargs: Any) -> None: await self.async_api_command("off", *self._unit_args) blebox-blebox_uniapi-16587f1/docs/000077500000000000000000000000001466110602600170205ustar00rootroot00000000000000blebox-blebox_uniapi-16587f1/docs/Makefile000066400000000000000000000011461466110602600204620ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = python -msphinx SPHINXPROJ = blebox_uniapi SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) blebox-blebox_uniapi-16587f1/docs/authors.rst000066400000000000000000000000341466110602600212340ustar00rootroot00000000000000.. include:: ../AUTHORS.rst blebox-blebox_uniapi-16587f1/docs/conf.py000077500000000000000000000115231466110602600203240ustar00rootroot00000000000000#!/usr/bin/env python # # blebox_uniapi documentation build configuration file, created by # sphinx-quickstart on Fri Jun 9 13:47:02 2017. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. # If extensions (or modules to document with autodoc) are in another # directory, add these directories to sys.path here. If the directory is # relative to the documentation root, use os.path.abspath to make it # absolute, like shown here. # import os import sys sys.path.insert(0, os.path.abspath("..")) import blebox_uniapi # -- General configuration --------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = ".rst" # The master toctree document. master_doc = "index" # General information about the project. project = "BleBox Python UniAPI" copyright = "2020, Gadget Mobile" author = "Gadget Mobile" # The version info for the project you're documenting, acts as replacement # for |version| and |release|, also used in various other places throughout # the built documents. # # The short X.Y version. version = blebox_uniapi.__version__ # The full version, including alpha/beta/rc tags. release = blebox_uniapi.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # -- Options for HTML output ------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a # theme further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] # -- Options for HTMLHelp output --------------------------------------- # Output file base name for HTML help builder. htmlhelp_basename = "blebox_uniapidoc" # -- Options for LaTeX output ------------------------------------------ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass # [howto, manual, or own class]). latex_documents = [ ( master_doc, "blebox_uniapi.tex", "BleBox Python UniAPI Documentation", "Gadget Mobile", "manual", ), ] # -- Options for manual page output ------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, "blebox_uniapi", "BleBox Python UniAPI Documentation", [author], 1) ] # -- Options for Texinfo output ---------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( master_doc, "blebox_uniapi", "BleBox Python UniAPI Documentation", author, "blebox_uniapi", "One line description of project.", "Miscellaneous", ), ] blebox-blebox_uniapi-16587f1/docs/contributing.rst000066400000000000000000000000411466110602600222540ustar00rootroot00000000000000.. include:: ../CONTRIBUTING.rst blebox-blebox_uniapi-16587f1/docs/history.rst000066400000000000000000000000341466110602600212500ustar00rootroot00000000000000.. include:: ../HISTORY.rst blebox-blebox_uniapi-16587f1/docs/howtoaddsensor.md000066400000000000000000000063661466110602600224200ustar00rootroot00000000000000 Integration for Blebox devices in Home Assistant. ============= This documentation assumes you are familiar with Home Assistant and the Blebox devices. How to Add a New Sensor (Based on MultiSensor Illuminance) -------------------------------------------------------- 1. **Update SENSOR_TYPES tuple in homeassistant.components.blebox.sensor module to allow creation of proper homeassistant entities for light readings depending on device capability reported by blebox_uniapi library.**: ```python from homeassistant.components.blebox.sensor import SensorEntityDescription, SensorDeviceClass from homeassistant.const import LIGHT_LUX SENSOR_TYPES = ( # ... (existing entries) SensorEntityDescription( key="illuminance", device_class=SensorDeviceClass.ILLUMINANCE, native_unit_of_measurement=LIGHT_LUX, ), )``` 2. **Update box_types module in blebox_uniapi to support new sensor types. (API level will be given, use newest version of the sensor if u want to copy paste)** ```python "multiSensor": { 20220114: { "api_path": "/state", "extended_state_path": "/state/extended", "sensors": [ [ "multiSensor", { "illuminance": lambda x:f"multiSensor/sensors/[id={x}]/value", "temperature": lambda x: f"multiSensor/sensors/[id={x}]/value", "wind": lambda x: f"multiSensor/sensors/[id={x}]/value", "humidity": lambda x: f"multiSensor/sensors/[id={x}]/value", }, ] ], "binary_sensors": [ [ "multiSensor", { "rain": lambda x: f"multiSensor/sensors/[id={x}]/value", "flood": lambda x: f"multiSensor/sensors/[id={x}]/value", }, ] ], }, ``` 3. **Create new sensor class in blebox_uniapi.sensor module for light related readings updating type_class_mapper in blebox_uniapi.sensor.SensorFactory to return proper sensor class if device supports light related measurements** ```python class Illuminance(BaseSensor): def __init__(self, product: "Box", alias: str, methods: dict): super().__init__(product, alias, methods) self._unit = "lx" self._device_class = "illuminance" def _read_illuminance(self): product = self._product if product.last_data is not None: raw = self.raw_value("illuminance") if raw is not None: alias = self._alias return round(product.expect_int(alias, raw, 100000, 0)/100.0, 1) return None ``` ```python class SensorFactory: @classmethod def many_from_config( cls, product, box_type_config, extended_state ) -> list["BaseSensor"]: type_class_mapper = { "airSensor": AirQuality, "temperature": Temperature, "humidity": Humidity, "wind": Wind, "illuminance" : Illuminance } ``` blebox-blebox_uniapi-16587f1/docs/index.rst000066400000000000000000000004751466110602600206670ustar00rootroot00000000000000Welcome to BleBox Python UniAPI's documentation! ====================================== .. toctree:: :maxdepth: 2 :caption: Contents: readme installation usage modules contributing authors history Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` blebox-blebox_uniapi-16587f1/docs/installation.rst000066400000000000000000000022571466110602600222610ustar00rootroot00000000000000.. highlight:: shell ============ Installation ============ Stable release -------------- To install BleBox Python UniAPI, run this command in your terminal: .. code-block:: console $ pip install blebox_uniapi This is the preferred method to install BleBox Python UniAPI, as it will always install the most recent stable release. If you don't have `pip`_ installed, this `Python installation guide`_ can guide you through the process. .. _pip: https://pip.pypa.io .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ From sources ------------ The sources for BleBox Python UniAPI can be downloaded from the `Github repo`_. You can either clone the public repository: .. code-block:: console $ git clone git://github.com/gadgetmobile/blebox_uniapi Or download the `tarball`_: .. code-block:: console $ curl -OJL https://github.com/gadgetmobile/blebox_uniapi/tarball/master Once you have a copy of the source, you can install it with: .. code-block:: console $ python setup.py install .. _Github repo: https://github.com/gadgetmobile/blebox_uniapi .. _tarball: https://github.com/gadgetmobile/blebox_uniapi/tarball/master blebox-blebox_uniapi-16587f1/docs/make.bat000066400000000000000000000014071466110602600204270ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=python -msphinx ) set SOURCEDIR=. set BUILDDIR=_build set SPHINXPROJ=blebox_uniapi if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The Sphinx module was not found. Make sure you have Sphinx installed, echo.then set the SPHINXBUILD environment variable to point to the full echo.path of the 'sphinx-build' executable. Alternatively you may add the echo.Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% :end popd blebox-blebox_uniapi-16587f1/docs/readme.rst000066400000000000000000000000331466110602600210030ustar00rootroot00000000000000.. include:: ../README.rst blebox-blebox_uniapi-16587f1/docs/usage.rst000066400000000000000000000001301466110602600206500ustar00rootroot00000000000000===== Usage ===== To use BleBox Python UniAPI in a project:: import blebox_uniapi blebox-blebox_uniapi-16587f1/requirements_dev.txt000066400000000000000000000001231466110602600222060ustar00rootroot00000000000000pytest>=7,<8 pytest-asyncio>=0.14.0 deepmerge==0.1.1 flake8==3.8.4 jmespath==1.0.1 blebox-blebox_uniapi-16587f1/requirements_tests.txt000066400000000000000000000010701466110602600225740ustar00rootroot00000000000000aiohttp==3.8.5 aiosignal==1.2.0 async-timeout==4.0.2 attrs==22.1.0 certifi==2023.7.22 charset-normalizer==2.1.1 coverage==6.5.0 deepmerge==1.0.1 docopt==0.6.2 entrypoints==0.3 flake8==3.7.9 frozenlist==1.3.1 idna==3.4 iniconfig==1.1.1 mccabe==0.6.1 multidict==6.0.2 packaging==21.3 pipreqs==0.4.12 pluggy==1.0.0 py==1.11.0 pycodestyle==2.5.0 pyflakes==2.1.1 pyparsing==3.0.9 pytest==7.1.3 pytest-asyncio==0.19.0 pytest-cov==4.0.0 pytest-runner==6.0.0 requests==2.31.0 tomli==2.0.1 urllib3==1.26.12 yarg==0.1.9 yarl==1.8.1 ruff==0.3.0 pre-commit==3.6.2 jmespath>=1.0.0 blebox-blebox_uniapi-16587f1/setup.cfg000066400000000000000000000007511466110602600177140ustar00rootroot00000000000000[bumpversion] current_version = 2.5.0 commit = True tag = True [bumpversion:file:setup.py] search = version="{current_version}" replace = version="{new_version}" [bumpversion:file:blebox_uniapi/__init__.py] search = __version__ = "{current_version}" replace = __version__ = "{new_version}" [bdist_wheel] universal = 1 [flake8] exclude = docs [aliases] test = pytest [tool:pytest] asyncio_mode = auto [mypy] exclude = (?x)(^tests/ | ^docs/ | ^setup.py$) disallow_untyped_defs = True blebox-blebox_uniapi-16587f1/setup.py000066400000000000000000000026411466110602600176050ustar00rootroot00000000000000#!/usr/bin/env python """The setup script.""" from setuptools import setup, find_packages with open("README.rst") as readme_file: readme = readme_file.read() with open("HISTORY.rst") as history_file: history = history_file.read() requirements = ["aiohttp>=3", "jmespath>1.0.0"] setup_requirements = [ "pytest-runner", ] test_requirements = [ "pytest>=3", "pytest-asyncio>=0.10.0", "deepmerge", ] setup( version="2.5.0", author="BleBox", author_email="opensource@blebox.eu", python_requires=">=3.9", classifiers=[ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", ], description="Python API for accessing BleBox smart home devices", install_requires=requirements, license="Apache Software License 2.0", long_description=readme + "\n\n" + history, long_description_content_type="text/x-rst", include_package_data=False, keywords="blebox_uniapi", name="blebox_uniapi", packages=find_packages(include=["blebox_uniapi", "blebox_uniapi.*"]), setup_requires=setup_requirements, test_suite="tests", tests_require=test_requirements, url="https://github.com/blebox/blebox_uniapi", zip_safe=False, ) blebox-blebox_uniapi-16587f1/tests/000077500000000000000000000000001466110602600172325ustar00rootroot00000000000000blebox-blebox_uniapi-16587f1/tests/__init__.py000066400000000000000000000000531466110602600213410ustar00rootroot00000000000000"""Unit test package for blebox_uniapi.""" blebox-blebox_uniapi-16587f1/tests/conftest.py000066400000000000000000000227631466110602600214430ustar00rootroot00000000000000"""PyTest fixtures and test helpers.""" import copy import json as _json import re from datetime import date, timedelta import logging from unittest.mock import AsyncMock from deepmerge import Merger from unittest import mock from aiohttp import ClientResponseError, ClientSession import pytest from blebox_uniapi.box_types import get_latest_conf from blebox_uniapi.session import ApiHost from blebox_uniapi.box import Box from blebox_uniapi.error import UnsupportedBoxVersion _LOGGER = logging.getLogger(__name__) retype = type(re.compile("")) @pytest.fixture def aioclient_mock(): return AsyncMock(spec=ClientSession) def array_merge(config, path, base, nxt): """Replace an array element with the merge result of elements.""" if len(nxt): if isinstance(nxt[0], dict): index = 0 for item in nxt: if not isinstance(item, dict): raise NotImplementedError my_merger.merge(base[index], item) index += 1 return base elif isinstance(nxt[0], int): return [nxt[0]] else: raise NotImplementedError my_merger = Merger( # pass in a list of tuple, with the # strategies you are looking to apply # to each type. [(list, [array_merge]), (dict, ["merge"])], # next, choose the fallback strategies, # applied to all other types: ["override"], # finally, choose the strategies in # the case where the types conflict: ["override"], ) def jmerge(base, ext): """Create new fixtures by adjusting existing ones.""" result = copy.deepcopy(base) my_merger.merge(result, _json.loads(ext)) return result def future_date(delta_days=300): """Generate future date string in 'YYYYMMDD' format.""" future_date = date.today() + timedelta(days=delta_days) return future_date.strftime("%Y%m%d") HTTP_MOCKS = {} def json_get_expect(mock, url, **kwargs): json = kwargs["json"] if mock not in HTTP_MOCKS: HTTP_MOCKS[mock] = {} HTTP_MOCKS[mock][url] = json class EffectWhenGet: def __init__(self, key): self._key = key def __call__(self, url, **kwargs): data = HTTP_MOCKS[self._key][url] response = _json.dumps(data).encode("utf-8") status = 200 return AiohttpClientMockResponse("GET", url, status, response) mock.get = AsyncMock(side_effect=EffectWhenGet(mock)) def json_post_expect(mock, url, **kwargs): json = kwargs["json"] params = kwargs["params"] # TODO: check # headers = kwargs.get("headers") if mock not in HTTP_MOCKS: HTTP_MOCKS[mock] = {} if url not in HTTP_MOCKS[mock]: HTTP_MOCKS[mock][url] = {} HTTP_MOCKS[mock][url][params] = json class EffectWhenPost: def __init__(self, key): self._key = key def __call__(self, url, **kwargs): # TODO: timeout params = kwargs.get("data") # TODO: better checking of params (content vs raw json) data = HTTP_MOCKS[self._key][url][params] response = _json.dumps(data).encode("utf-8") status = 200 return AiohttpClientMockResponse("POST", url, status, response) mock.post = AsyncMock(side_effect=EffectWhenPost(mock)) class DefaultBoxTest: """Base class with methods common to BleBox integration tests.""" IP = "172.0.0.1" LOGGER = _LOGGER async def async_entities(self, session): """Get a created entity at the given index.""" host = self.IP port = 80 timeout = 2 api_host = ApiHost(host, port, timeout, session, None, self.LOGGER) product = await Box.async_from_host(api_host) return [ self.ENTITY_CLASS(feature) for feature in product.features[self.DEVCLASS] ] async def allow_get_info(self, aioclient_mock, info=None): """Stub a HTTP GET request for the device state.""" data = self.DEVICE_INFO if info is None else info json_get_expect( aioclient_mock, f"http://{self.IP}:80/api/device/state", json=data ) if (hasattr(self, "DEVICE_EXTENDED_INFO")) and ( path := getattr(self, "DEVICE_EXTENDED_INFO_PATH") ): data = self.DEVICE_EXTENDED_INFO or info json_get_expect( aioclient_mock, f"http://{self.IP}:80/{path.lstrip('/')}", json=data, ) def allow_get_state(self, aioclient_mock, data): """Stub a HTTP GET request for the product-specific state.""" json_get_expect( aioclient_mock, f"http://{self.IP}:80/{self.DEV_INFO_PATH}", json=data ) def allow_get(self, aioclient_mock, api_path, data): """Stub a HTTP GET request.""" json_get_expect( aioclient_mock, f"http://{self.IP}:80/{api_path[1:]}", json=data ) async def allow_post(self, code, aioclient_mock, api_path, post_data, response): """Stub a HTTP POST request.""" json_post_expect( aioclient_mock, f"http://{self.IP}:80/{api_path[1:]}", params=post_data, headers={"content-type": "application/json"}, json=response, ) await code() # TODO: rename? async def updated(self, aioclient_mock, state, index=0): """Return an entry on which update has already been called.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[index] self.allow_get_state(aioclient_mock, state) await entity.async_update() return entity async def test_future_version(self, aioclient_mock): """ Test support for future versions, that is last supported entry in config type file. """ await self.allow_get_info(aioclient_mock, self.DEVICE_INFO_FUTURE) entity = (await self.async_entities(aioclient_mock))[0] assert entity._feature.product._config is get_latest_conf( entity._feature.product.type ) async def test_latest_version(self, aioclient_mock): """ Test support for latest versions, that is last supported entry in config type file. """ await self.allow_get_info(aioclient_mock, self.DEVICE_INFO_LATEST) entity = (await self.async_entities(aioclient_mock))[0] assert entity._feature.product._config is get_latest_conf( entity._feature.product.type ) async def test_unsupported_version(self, aioclient_mock): """Test version support.""" # only gateBox is same if self.DEVICE_INFO != self.DEVICE_INFO_UNSUPPORTED: await self.allow_get_info(aioclient_mock, self.DEVICE_INFO_UNSUPPORTED) with pytest.raises(UnsupportedBoxVersion): await self.async_entities(aioclient_mock) async def test_unspecified_version(self, aioclient_mock): """ Test default_api_level when api level is not specified in device info. """ if self.DEVICE_INFO_UNSPECIFIED_API is not None: await self.allow_get_info(aioclient_mock, self.DEVICE_INFO_UNSPECIFIED_API) with pytest.raises(UnsupportedBoxVersion): await self.async_entities(aioclient_mock) class AiohttpClientMockResponse: """Mock Aiohttp client response.""" def __init__( self, method, url, status, response, cookies=None, exc=None, headers=None ): """Initialize a fake response.""" self.method = method self._url = url self.status = status self.response = response self.exc = exc self._headers = headers or {} self._cookies = {} if cookies: for name, data in cookies.items(): cookie = mock.MagicMock() cookie.value = data self._cookies[name] = cookie @property def headers(self): return self._headers @property def cookies(self): return self._cookies @property def url(self): return self._url @property def content_type(self): return self._headers.get("content-type") async def read(self): return self.response async def text(self, encoding="utf-8"): return self.response.decode(encoding) async def json(self, encoding="utf-8"): return _json.loads(self.response.decode(encoding)) async def release(self): pass def raise_for_status(self): """Raise error if status is 400 or higher.""" if self.status >= 400: request_info = mock.Mock(real_url="http://example.com") # TODO: coverage raise ClientResponseError( request_info=request_info, history=None, code=self.status, headers=self.headers, ) def close(self): pass class CommonEntity: def __init__(self, feature): self._feature = feature @property def name(self): return self._feature.full_name @property def unique_id(self): return self._feature.unique_id async def async_update(self): await self._feature.async_update() @property def device_info(self): product = self._feature.product return { "name": product.name, "mac": product.unique_id, "manufacturer": product.brand, "model": product.model, "sw_version": product.firmware_version, } blebox-blebox_uniapi-16587f1/tests/test_box.py000066400000000000000000000126261466110602600214420ustar00rootroot00000000000000import pytest from unittest import mock from blebox_uniapi.box import Box from blebox_uniapi import error from blebox_uniapi.jfollow import follow pytestmark = pytest.mark.asyncio @pytest.fixture def mock_session(): return mock.MagicMock(host="172.1.2.3", port=80) @pytest.fixture def sample_data(): return { "id": "abcd1234ef", "type": "airSensor", "deviceName": "foobar", "fv": "1.23", "hv": "4.56", "apiLevel": "20180403", } @pytest.fixture def config(sample_data): return Box._match_device_config(sample_data) async def test_without_type(mock_session, sample_data, config): del sample_data["type"] with pytest.raises(error.UnsupportedBoxResponse, match="has no type"): Box(mock_session, sample_data, config, None) async def test_with_unknown_type(mock_session, sample_data): sample_data["type"] = "unknownBox" with pytest.raises(error.UnsupportedBoxResponse, match="not a supported type"): Box._match_device_config(sample_data) async def test_without_name(mock_session, sample_data, config): del sample_data["deviceName"] with pytest.raises(error.UnsupportedBoxResponse, match="has no name"): Box(mock_session, sample_data, config, None) async def test_without_firmware_version(mock_session, sample_data, config): del sample_data["fv"] with pytest.raises(error.UnsupportedBoxResponse, match="has no firmware version"): Box(mock_session, sample_data, config, None) async def test_without_hardware_version(mock_session, sample_data, config): del sample_data["hv"] with pytest.raises(error.UnsupportedBoxResponse, match="has no hardware version"): Box(mock_session, sample_data, config, None) async def test_without_api_level(mock_session, sample_data, config): del sample_data["apiLevel"] with pytest.raises(error.UnsupportedBoxVersion, match=r"unsupported version"): Box._match_device_config(sample_data) async def test_json_path_extraction(mock_session, sample_data, config): # note: follow is thin wrapper over jmespath so there's no real reason to test it. # However, this tests makes sure we understand the syntax and know how it works. # We can also use it as a canary for any unexpected change in syntax that may come # in newer version. # succesfull extraction assert follow(["foo"], "[0]") == "foo" assert follow([{"foo": "3", "value": 4}], "[?foo=='3'].value") == [4] # "dud" extraction assert follow([{"foo": "ab", "value": 4}], "[?foo=='bc'].value") == [] assert follow([{"value": 4}], "[1].value") is None assert follow({"value": 4}, "foo") is None assert follow({"foo": [4]}, "[?bar==`0`].value") is None async def test_missing_device_id(mock_session, sample_data, config): del sample_data["id"] with pytest.raises(error.UnsupportedBoxResponse, match="has no id"): Box(mock_session, sample_data, config, None) # Add more test cases for other missing fields (type, name, versions, etc.) async def test_invalid_init(mock_session, sample_data, config): with mock.patch( "blebox_uniapi.sensor.SensorFactory.many_from_config", spec_set=True, autospec=True, ) as mock_sensor: mock_sensor.side_effect = KeyError with pytest.raises( error.UnsupportedBoxResponse, match=r"Failed to initialize:" ): Box(mock_session, sample_data, config, None) async def test_properties(mock_session, sample_data, config): box = Box(mock_session, sample_data, config, None) assert box.name == "foobar" assert box.last_data is None assert box.type == "airSensor" assert box.model == "airSensor" assert box.unique_id == "abcd1234ef" assert box.firmware_version == "1.23" assert box.hardware_version == "4.56" assert box.brand == "BleBox" assert box.api_version == 20180403 assert box.address == "172.1.2.3:80" async def test_field_validations(mock_session, sample_data, config): box = Box(mock_session, sample_data, config, None) with pytest.raises( error.BadFieldExceedsMax, match=r"foobar.field1 is 123 which exceeds max \(100\)", ): box.check_int_range(123, "field1", 100, 0) with pytest.raises( error.BadFieldLessThanMin, match=r"foobar.field1 is 123 which is less than minimum \(200\)", ): box.check_int_range(123, "field1", 300, 200) with pytest.raises(error.BadFieldMissing, match=r"foobar.field1 is missing"): box.check_int(None, "field1", 300, 200) with pytest.raises( error.BadFieldNotANumber, match=r"foobar.field1 is '123' which is not a number" ): box.check_int("123", "field1", 300, 200) with pytest.raises(error.BadFieldMissing, match=r"foobar.field1 is missing"): box.check_hex_str(None, "field1", 300, 200) with pytest.raises( error.BadFieldNotAString, match=r"foobar.field1 is 123 which is not a string" ): box.check_hex_str(123, "field1", 300, 200) with pytest.raises(error.BadFieldMissing, match=r"foobar.field1 is missing"): box.check_rgbw(None, "field1") with pytest.raises( error.BadFieldNotAString, match=r"foobar.field1 is 123 which is not a string" ): box.check_rgbw(123, "field1") with pytest.raises( error.BadFieldNotRGBW, match=r"foobar.field1 is 123 which is not a rgbw string" ): box.check_rgbw("123", "field1") blebox-blebox_uniapi-16587f1/tests/test_box_types.py000066400000000000000000000046321466110602600226640ustar00rootroot00000000000000from random import choice from blebox_uniapi.box_types import ( BOX_TYPE_CONF, get_conf, get_conf_set, get_latest_api_level, get_latest_conf, ) class TestBoxTypesOrder: def test_conf_order(self): for device_type, conf_set in BOX_TYPE_CONF.items(): api_levels = list(conf_set.keys()) sorted_api_levels = sorted(api_levels) assert ( api_levels == sorted_api_levels ), f"Entries for '{device_type}' are not ordered by API level." class TestBoxTypes: box_types = tuple(BOX_TYPE_CONF.keys()) simple_conf_set = {5: {"tag": "first_entry"}, 10: {"tag": "second_entry"}} async def test_get_conf_set_valid(self): conf_set = get_conf_set(choice(self.box_types)) assert isinstance(conf_set, dict) assert conf_set != {} async def test_get_conf_set_invalid(self): conf_set = get_conf_set("nonexistent_type") assert isinstance(conf_set, dict) assert conf_set == {} async def test_get_conf_valid(self): """Test choosing functionality of get_conf function on exemplary conf_set.""" for api_level, tag_value in ( (5, "first_entry"), # a marginal example (7, "first_entry"), # 'in between' example (10, "second_entry"), # a marginal example (17, "second_entry"), # future example ): conf = get_conf(api_level, self.simple_conf_set) assert isinstance(conf, dict) assert conf["tag"] == tag_value async def test_get_conf_invalid(self): conf = get_conf(3, self.simple_conf_set) # not supported example assert isinstance(conf, dict) assert conf == {} async def test_get_latest_conf_valid(self): conf = get_latest_conf(choice(self.box_types)) assert isinstance(conf, dict) assert conf != {} async def test_get_latest_conf_invalid(self): conf = get_latest_conf("nonexistent_type") assert isinstance(conf, dict) assert conf == {} async def test_get_latest_api_level_valid(self): api_level = get_latest_api_level(choice(self.box_types)) assert isinstance(api_level, int) assert api_level async def test_get_latest_api_level_invalid(self): api_level = get_latest_api_level("nonexistent_type") assert isinstance(api_level, int) assert not api_level blebox-blebox_uniapi-16587f1/tests/test_button.py000066400000000000000000000024521466110602600221610ustar00rootroot00000000000000from unittest.mock import Mock import pytest from blebox_uniapi.button import Button from blebox_uniapi.box import Box from blebox_uniapi.box_types import BOX_TYPE_CONF @pytest.fixture def product(): return Mock(spec=Box) @pytest.fixture def tv_lift_box_0(product): product.type = "tvLiftBox" extended_state = {"tvLift": {"controlType": 4}} many = Button.many_from_config( product, BOX_TYPE_CONF["tvLiftBox"][20200518]["buttons"], extended_state=extended_state, ) assert len(many) == 3 return many[0] @pytest.fixture def tv_lift_box_1(product): product.type = "tvLiftBox" extended_state = {"tvLift": {"controlType": 4}} many = Button.many_from_config( product, BOX_TYPE_CONF["tvLiftBox"][20200518]["buttons"], extended_state=extended_state, ) assert len(many) == 3 return many[1] async def test_tv_lift_0_box_pressed(tv_lift_box_0: Button, product: Box): await tv_lift_box_0.set() product.async_api_command.assert_called_with("set", "open_or_stop") assert tv_lift_box_0.control_type async def test_tv_lift_1_box_pressed(tv_lift_box_1: Button, product: Box): await tv_lift_box_1.set() product.async_api_command.assert_called_with("set", "close_or_stop") assert tv_lift_box_1.control_type blebox-blebox_uniapi-16587f1/tests/test_climate.py000066400000000000000000000274011466110602600222650ustar00rootroot00000000000000"""BleBox climate entities tests.""" import json from blebox_uniapi.box_types import get_latest_api_level from .conftest import CommonEntity, DefaultBoxTest, future_date, jmerge # TODO: remove SUPPORT_TARGET_TEMPERATURE = 1 HVAC_MODE_OFF = "hvac mode off" HVAC_MODE_HEAT = "hvac mode heat" CURRENT_HVAC_OFF = "current hvac mode off" CURRENT_HVAC_HEAT = "current hvac mode heat" CURRENT_HVAC_IDLE = "current hvac mode idle" ATTR_TEMPERATURE = "temperature" TEMP_CELSIUS = "celsius" class ClimateDevice: def __init__(self): self._state = None @property def state(self): if self._feature.is_on is None: return None if not self._feature.is_on: return HVAC_MODE_OFF return HVAC_MODE_HEAT @property def device_class(self): return None class BleBoxClimateEntity(CommonEntity, ClimateDevice): def __init__(self, feature): super().__init__(feature) ClimateDevice.__init__(self) pass """Representation of a BleBox climate feature.""" @property def supported_features(self): """Return the supported climate features.""" return SUPPORT_TARGET_TEMPERATURE @property def hvac_mode(self): """Return the desired HVAC mode.""" return {None: None, False: HVAC_MODE_OFF, True: HVAC_MODE_HEAT}[ self._feature.is_on ] @property def hvac_action(self): """Return the actual current HVAC action.""" on = self._feature.is_on if not on: return None if on is None else CURRENT_HVAC_OFF states = {None: None, False: CURRENT_HVAC_IDLE, True: CURRENT_HVAC_HEAT} heating = self._feature.is_heating return states[heating] @property def hvac_modes(self): """Return a list of possible HVAC modes.""" return (HVAC_MODE_OFF, HVAC_MODE_HEAT) @property def temperature_unit(self): """Return the temperature unit.""" return TEMP_CELSIUS @property def current_temperature(self): """Return the current temperature.""" return self._feature.current @property def max_temp(self): """Return the maximum thermostat setting.""" return self._feature.max_temp @property def min_temp(self): """Return the minimum thermostat setting.""" return self._feature.min_temp @property def target_temperature(self): """Return the desired thermostat temperature.""" return self._feature.desired async def async_set_hvac_mode(self, hvac_mode): """Set the climate entity mode.""" modemap = {HVAC_MODE_OFF: "async_off", HVAC_MODE_HEAT: "async_on"} await getattr(self._feature, modemap[hvac_mode])() async def async_set_temperature(self, **kwargs): """Set the thermostat temperature.""" value = kwargs[ATTR_TEMPERATURE] await self._feature.async_set_temperature(value) class TestSauna(DefaultBoxTest): """Tests for entities representing a BleBox saunaBox.""" DEVCLASS = "climates" ENTITY_CLASS = BleBoxClimateEntity DEV_INFO_PATH = "api/heat/extended/state" DEVICE_EXTENDED_INFO_PATH = "/state/extended" DEVICE_INFO = json.loads( """ { "device": { "deviceName": "My SaunaBox", "type": "saunaBox", "fv": "0.176", "hv": "0.6", "apiLevel": "20180604", "id": "1afe34db9437", "ip": "192.168.1.11" } } """ ) DEVICE_INFO_THERMO = json.loads( """ { "device": { "deviceName": "My ThermoBox", "type": "thermoBox", "product": "thermoBox", "hv": "thB.1.0", "fv": "0.1031", "universe": 0, "apiLevel": "20200229", "iconSet": 43, "categories": [ 7 ], "id": "f6cfa2f11cd3", "ip": "192.168.49.183", "availableFv": null } } """ ) DEVICE_EXTENDED_INFO_THERMO = json.loads( """ { "thermo": { "state": 0, "operatingState": 3, "desiredTemp": -70, "mode": 2, "minimumTemp": -1230, "maximumTemp": 6000, "safety": { "eventReason": 0, "triggered": [ ] }, "safetyTempSensor": { "sensorId": 1 } }, "sensors": [ { "id": 0, "type": "temperature", "value": 2098, "state": 2 }, { "id": 1, "type": "temperature", "value": 2775, "state": 2 } ] } """ ) def patch_version(apiLevel): """Generate a patch for a JSON state fixture.""" return f""" {{ "device": {{ "apiLevel": {apiLevel} }} }} """ DEVICE_INFO_FUTURE = jmerge(DEVICE_INFO, patch_version(future_date())) DEVICE_INFO_LATEST = jmerge( DEVICE_INFO, patch_version(get_latest_api_level("saunaBox")) ) DEVICE_INFO_UNSUPPORTED = jmerge(DEVICE_INFO, patch_version(20180603)) DEVICE_INFO_UNSPECIFIED_API = json.loads( """ { "device": { "deviceName": "My SaunaBox", "type": "saunaBox", "fv": "0.176", "hv": "0.6", "id": "1afe34db9437", "ip": "192.168.1.11" } } """ ) def patch_state(state, current, desired): """Generate a patch for a JSON state fixture.""" return f""" {{ "heat": {{ "state": {state}, "desiredTemp": {desired}, "sensors": [ {{ "value": {current} }} ] }} }} """ STATE_DEFAULT = json.loads( """ { "heat": { "state": 0, "desiredTemp": 6428, "maximumTemp": 12166, "minimumTemp": -5166, "sensors": [ { "type": "temperature", "id": 0, "value": 3996, "trend": 0, "state": 2, "elapsedTimeS": 0 } ] } } """ ) STATE_OFF_BELOW = STATE_DEFAULT STATE_NEEDS_HEATING = jmerge(STATE_DEFAULT, patch_state(1, 2320, 3871)) STATE_OFF_ABOVE = jmerge(STATE_DEFAULT, patch_state(0, 3871, 2876)) STATE_NEEDS_COOLING = jmerge(STATE_DEFAULT, patch_state(1, 3871, 2876)) STATE_REACHED = jmerge(STATE_DEFAULT, patch_state(1, 2320, 2320)) STATE_THERMO_SET = jmerge(STATE_DEFAULT, patch_state(1, 2320, 4320)) async def test_init(self, aioclient_mock): """Test default state.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[0] assert entity.name == "My SaunaBox (saunaBox#thermostat)" assert entity.unique_id == "BleBox-saunaBox-1afe34db9437-thermostat" assert entity.device_class is None assert entity.supported_features & SUPPORT_TARGET_TEMPERATURE assert entity.hvac_modes == (HVAC_MODE_OFF, HVAC_MODE_HEAT) assert entity.hvac_mode is None assert entity.hvac_action is None assert entity.target_temperature is None assert entity.temperature_unit == TEMP_CELSIUS assert entity.state is None assert entity.max_temp is None assert entity.min_temp is None async def test_thermo_init(self, aioclient_mock): """Test initialisation with device state""" self.DEVICE_INFO = self.DEVICE_INFO_THERMO self.DEVICE_EXTENDED_INFO = self.DEVICE_EXTENDED_INFO_THERMO await self.allow_get_info( aioclient_mock, ) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My ThermoBox" async def test_device_info(self, aioclient_mock): await self.allow_get_info(aioclient_mock, self.DEVICE_INFO) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My SaunaBox" assert entity.device_info["mac"] == "1afe34db9437" assert entity.device_info["manufacturer"] == "BleBox" assert entity.device_info["model"] == "saunaBox" assert entity.device_info["sw_version"] == "0.176" async def test_update(self, aioclient_mock): """Test updating.""" entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) assert entity.hvac_mode == HVAC_MODE_OFF assert entity.hvac_action == CURRENT_HVAC_OFF assert entity.target_temperature == 64.3 assert entity.current_temperature == 40.0 assert entity.temperature_unit == TEMP_CELSIUS assert entity.max_temp == 121.7 assert entity.min_temp == -51.7 async def test_on_when_below_target(self, aioclient_mock): """Test when temperature is below desired.""" entity = await self.updated(aioclient_mock, self.STATE_OFF_BELOW) assert entity.state == entity.hvac_mode == HVAC_MODE_OFF assert entity.hvac_action == CURRENT_HVAC_OFF self.allow_get(aioclient_mock, "/s/1", self.STATE_NEEDS_HEATING) await entity.async_set_hvac_mode(HVAC_MODE_HEAT) assert entity.target_temperature == 38.7 assert entity.current_temperature == 23.2 assert entity.state == entity.hvac_mode == HVAC_MODE_HEAT assert entity.hvac_action == CURRENT_HVAC_HEAT async def test_on_when_above_target(self, aioclient_mock): """Test when temperature is below desired.""" entity = await self.updated(aioclient_mock, self.STATE_OFF_ABOVE) assert entity.state == entity.hvac_mode == HVAC_MODE_OFF assert entity.hvac_action == CURRENT_HVAC_OFF self.allow_get(aioclient_mock, "/s/1", self.STATE_NEEDS_COOLING) await entity.async_set_hvac_mode(HVAC_MODE_HEAT) assert entity.target_temperature == 28.8 assert entity.current_temperature == 38.7 assert entity.state == entity.hvac_mode == HVAC_MODE_HEAT assert entity.hvac_action == CURRENT_HVAC_IDLE async def test_on_when_at_target(self, aioclient_mock): """Test when temperature is below desired.""" entity = await self.updated(aioclient_mock, self.STATE_OFF_ABOVE) assert entity.state == entity.hvac_mode == HVAC_MODE_OFF assert entity.hvac_action == CURRENT_HVAC_OFF self.allow_get(aioclient_mock, "/s/1", self.STATE_REACHED) await entity.async_set_hvac_mode(HVAC_MODE_HEAT) assert entity.target_temperature == 23.2 assert entity.current_temperature == 23.2 assert entity.state == entity.hvac_mode == HVAC_MODE_HEAT assert entity.hvac_action == CURRENT_HVAC_IDLE async def test_off(self, aioclient_mock): """Test turning off.""" entity = await self.updated(aioclient_mock, self.STATE_REACHED) self.allow_get(aioclient_mock, "/s/0", self.STATE_OFF_BELOW) await entity.async_set_hvac_mode(HVAC_MODE_OFF) assert entity.target_temperature == 64.3 assert entity.current_temperature == 40.0 assert entity.state == entity.hvac_mode == HVAC_MODE_OFF assert entity.hvac_action == CURRENT_HVAC_OFF async def test_set_thermo(self, aioclient_mock): """Test setting thermostat.""" entity = await self.updated(aioclient_mock, self.STATE_REACHED) self.allow_get(aioclient_mock, "/s/t/4321", self.STATE_THERMO_SET) await entity.async_set_temperature(**{ATTR_TEMPERATURE: 43.21}) assert entity.current_temperature == 23.2 # no change yet assert entity.target_temperature == 43.2 assert entity.state == entity.hvac_mode == HVAC_MODE_HEAT assert entity.hvac_action == CURRENT_HVAC_HEAT blebox-blebox_uniapi-16587f1/tests/test_cover.py000066400000000000000000000616101466110602600217650ustar00rootroot00000000000000"""BleBox cover entities tests.""" import json import pytest from blebox_uniapi.box_types import get_latest_api_level from blebox_uniapi import error from .conftest import CommonEntity, DefaultBoxTest, future_date, jmerge # TODO: remove ATTR_POSITION = "ATTR_POSITION" DEVICE_CLASS_DOOR = "DEVICE_CLASS_DOOR" DEVICE_CLASS_SHUTTER = "DEVICE_CLASS_SHUTTER" STATE_CLOSED = "STATE_CLOSED" STATE_CLOSING = "STATE_CLOSING" STATE_OPEN = "STATE_OPEN" STATE_OPENING = "STATE_OPENING" SUPPORT_OPEN = 1 SUPPORT_CLOSE = 2 SUPPORT_SET_POSITION = 4 SUPPORT_STOP = 8 def patch_version(apiLevel): """Helper function for generate a patch for a JSON state fixture.""" return f"""{{ "device": {{ "apiLevel": {apiLevel} }} }}""" class BleBoxCoverEntity(CommonEntity): """Representation of a BleBox cover feature.""" @property def state(self): """Return the equivalent HA cover state.""" states = { None: None, 0: STATE_CLOSING, # moving down 1: STATE_OPENING, # moving up 2: STATE_OPEN, # manually stopped 3: STATE_CLOSED, # lower limit 4: STATE_OPEN, # upper limit / open # gateController 5: STATE_OPEN, # overload 6: STATE_OPEN, # motor failure # 7: not used 8: STATE_OPEN, # safety stop } return states[self._feature.state] @property def device_class(self): """Return the device class.""" types = { "shutter": DEVICE_CLASS_SHUTTER, "gatebox": DEVICE_CLASS_DOOR, # "gateboxB": DEVICE_CLASS_DOOR, "gate": DEVICE_CLASS_DOOR, } return types[self._feature.device_class] # TODO: does changing this at runtime really work? @property def supported_features(self): """Return the supported cover features.""" position = SUPPORT_SET_POSITION if self._feature.is_slider else 0 stop = SUPPORT_STOP if self._feature.has_stop else 0 return position | stop | SUPPORT_OPEN | SUPPORT_CLOSE @property def current_cover_position(self): """Return the current cover position.""" current = self._invert_position(self._feature.current) return current if current else None @property def is_opening(self): """Return whether cover is opening.""" return self._is_state(STATE_OPENING) @property def is_closing(self): """Return whether cover is closing.""" return self._is_state(STATE_CLOSING) @property def is_closed(self): """Return whether cover is closed.""" return self._is_state(STATE_CLOSED) async def async_open_cover(self, **kwargs): """Open the cover position.""" await self._feature.async_open() async def async_close_cover(self, **kwargs): """Close the cover position.""" await self._feature.async_close() async def async_set_cover_position(self, **kwargs): """Set the cover position.""" value = kwargs[ATTR_POSITION] await self._feature.async_set_position(self._invert_position(value)) async def async_stop_cover(self, **kwargs): """Stop the cover.""" await self._feature.async_stop() def _is_state(self, state_name): value = self.state return None if value is None else value == state_name def _invert_position(self, position): # NOTE: in BleBox, 100% means 'closed' return None if position is None else 100 - position class CoverTest(DefaultBoxTest): """Shared test helpers for Cover tests.""" DEVCLASS = "covers" ENTITY_CLASS = BleBoxCoverEntity # TODO: refactor more def assert_state(self, entity, state): """Assert that cover state is correct.""" assert entity.state == state opening, closing, closed = { None: [None, None, None], STATE_OPEN: [False, False, False], STATE_OPENING: [True, False, False], STATE_CLOSING: [False, True, False], STATE_CLOSED: [False, False, True], }[state] assert entity.is_opening is opening assert entity.is_closing is closing assert entity.is_closed is closed class TestShutter(CoverTest): """Tests for cover devices representing a BleBox ShutterBox.""" DEV_INFO_PATH = "api/shutter/state" DEVICE_INFO = json.loads( """ { "device": { "deviceName": "My shutter 1", "type": "shutterBox", "fv": "0.147", "hv": "0.7", "apiLevel": "20180604", "id": "2bee34e750b8", "ip": "172.0.0.1" } } """ ) DEVICE_INFO_FUTURE = jmerge(DEVICE_INFO, patch_version(future_date())) DEVICE_INFO_LATEST = jmerge( DEVICE_INFO, patch_version(get_latest_api_level("shutterBox")) ) DEVICE_INFO_UNSUPPORTED = jmerge(DEVICE_INFO, patch_version(20180603)) DEVICE_INFO_UNSPECIFIED_API = json.loads( """ { "device": { "deviceName": "My shutter 1", "type": "shutterBox", "fv": "0.147", "hv": "0.7", "id": "2bee34e750b8", "ip": "172.0.0.1" } } """ ) STATE_DEFAULT = json.loads( """ { "shutter": { "state": 2, "currentPos": { "position": 34, "tilt": 3 }, "desiredPos": { "position": 78, "tilt": 97 }, "favPos": { "position": 13, "tilt": 17 } } } """ ) def patch_state(state, current, desired): """Generate a patch for a JSON state fixture.""" return f""" {{ "shutter": {{ "state": {state}, "currentPos": {{ "position": {current} }}, "desiredPos": {{ "position": {desired} }} }} }} """ STATE_CLOSING = jmerge(STATE_DEFAULT, patch_state(0, 78, 100)) STATE_CLOSED = jmerge(STATE_DEFAULT, patch_state(3, 100, 100)) STATE_OPENING = jmerge(STATE_DEFAULT, patch_state(1, 34, 0)) STATE_MINIMALLY_OPENING = jmerge(STATE_DEFAULT, patch_state(1, 97, 100)) STATE_STOPPED = jmerge(STATE_DEFAULT, patch_state(2, 34, 100)) STATE_UNKNOWN = jmerge(STATE_DEFAULT, patch_state(2, 34, -1)) async def test_init(self, aioclient_mock): """Test cover default state.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[0] assert entity.name == "My shutter 1 (shutterBox#position)" assert entity.unique_id == "BleBox-shutterBox-2bee34e750b8-position" assert entity.device_class == DEVICE_CLASS_SHUTTER assert entity.supported_features & SUPPORT_OPEN assert entity.supported_features & SUPPORT_CLOSE assert entity.supported_features & SUPPORT_STOP assert entity.supported_features & SUPPORT_SET_POSITION assert entity.current_cover_position is None # TODO: tilt # assert entity.supported_features & SUPPORT_SET_TILT_POSITION # assert entity.current_cover_tilt_position == None self.assert_state(entity, None) async def test_device_info(self, aioclient_mock): await self.allow_get_info(aioclient_mock, self.DEVICE_INFO) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My shutter 1" assert entity.device_info["mac"] == "2bee34e750b8" assert entity.device_info["manufacturer"] == "BleBox" assert entity.device_info["model"] == "shutterBox" assert entity.device_info["sw_version"] == "0.147" async def test_update(self, aioclient_mock): """Test cover updating.""" entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) assert entity.current_cover_position == 22 # 100 - 78 self.assert_state(entity, STATE_OPEN) # TODO: tilt # assert entity.current_tilt_position == 0 async def test_open(self, aioclient_mock): """Test cover opening.""" entity = await self.updated(aioclient_mock, self.STATE_CLOSED) self.assert_state(entity, STATE_CLOSED) self.allow_get(aioclient_mock, "/s/u", self.STATE_OPENING) await entity.async_open_cover() self.assert_state(entity, STATE_OPENING) async def test_close(self, aioclient_mock): """Test cover closing.""" entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) self.assert_state(entity, STATE_OPEN) self.allow_get(aioclient_mock, "/s/d", self.STATE_CLOSING) await entity.async_close_cover() self.assert_state(entity, STATE_CLOSING) async def test_set_position(self, aioclient_mock): """Test cover position setting.""" entity = await self.updated(aioclient_mock, self.STATE_CLOSED) self.assert_state(entity, STATE_CLOSED) self.allow_get(aioclient_mock, "/s/p/99", self.STATE_MINIMALLY_OPENING) await entity.async_set_cover_position(**{ATTR_POSITION: 1}) # almost closed self.assert_state(entity, STATE_OPENING) async def test_stop(self, aioclient_mock): """Test cover stopping.""" entity = await self.updated(aioclient_mock, self.STATE_OPENING) self.assert_state(entity, STATE_OPENING) self.allow_get(aioclient_mock, "/s/s", self.STATE_STOPPED) await entity.async_stop_cover() self.assert_state(entity, STATE_OPEN) async def test_unkown_position(self, aioclient_mock): """Test handling cover at unknown position.""" entity = await self.updated(aioclient_mock, self.STATE_UNKNOWN) self.assert_state(entity, STATE_OPEN) class TestGateBox(CoverTest): """Tests for cover devices representing a BleBox gateBox.""" DEV_INFO_PATH = "api/gate/state" DEVICE_INFO = json.loads( """ { "deviceName": "My gate 1", "type": "gateBox", "fv": "0.176", "hv": "0.6", "id": "1afe34db9437", "ip": "192.168.1.11" } """ ) DEVICE_INFO_FUTURE = jmerge(DEVICE_INFO, f'{{ "apiLevel":"{future_date()}" }}') DEVICE_INFO_LATEST = jmerge( DEVICE_INFO, f'{{ "apiLevel":"{get_latest_api_level("gateBox")}" }}' ) DEVICE_INFO_UNSUPPORTED = DEVICE_INFO DEVICE_INFO_UNSPECIFIED_API = None # already handled as default case STATE_DEFAULT = json.loads( """ { "currentPos": 50, "desiredPos": 50, "extraButtonType": 1, "extraButtonRelayNumber": 1, "extraButtonPulseTimeMs": 800, "extraButtonInvert": 1, "gateType": 0, "gateRelayNumber": 0, "gatePulseTimeMs": 800, "gateInvert": 0, "inputsType": 1, "openLimitSwitchInputNumber": 0, "closeLimitSwitchInputNumber": 1 } """ ) STATE_CLOSED = jmerge(STATE_DEFAULT, '{ "currentPos": 0, "desiredPos": 0 }') STATE_OPENING = jmerge(STATE_DEFAULT, '{ "currentPos": 50, "desiredPos": 100 }') STATE_CLOSING = jmerge(STATE_DEFAULT, '{ "currentPos": 50, "desiredPos": 0 }') STATE_STOPPED = jmerge(STATE_DEFAULT, '{ "currentPos": 50, "desiredPos": 50 }') STATE_FULLY_OPENED = jmerge( STATE_DEFAULT, '{ "currentPos": 100, "desiredPos": 100 }' ) STATE_OPENING_NO_STOP = jmerge(STATE_OPENING, '{ "extraButtonType": 3}') STATE_UNKNOWN = jmerge(STATE_DEFAULT, '{ "currentPos": -1, "desiredPos": 50 }') async def test_init(self, aioclient_mock): """Test cover default state.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[0] assert entity.name == "My gate 1 (gateBox#position)" assert entity.unique_id == "BleBox-gateBox-1afe34db9437-position" assert entity.device_class == DEVICE_CLASS_DOOR assert entity.supported_features & SUPPORT_OPEN assert entity.supported_features & SUPPORT_CLOSE # Not available since requires fetching state to detect assert not entity.supported_features & SUPPORT_STOP assert not entity.supported_features & SUPPORT_SET_POSITION assert entity.current_cover_position is None self.assert_state(entity, None) async def test_device_info(self, aioclient_mock): await self.allow_get_info(aioclient_mock, self.DEVICE_INFO) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My gate 1" assert entity.device_info["mac"] == "1afe34db9437" assert entity.device_info["manufacturer"] == "BleBox" assert entity.device_info["model"] == "gateBox" assert entity.device_info["sw_version"] == "0.176" async def test_update(self, aioclient_mock): """Test cover updating.""" entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) assert entity.current_cover_position == 50 # 100 - 34 self.assert_state(entity, STATE_OPEN) async def test_open(self, aioclient_mock): """Test cover opening.""" entity = await self.updated(aioclient_mock, self.STATE_CLOSED) assert entity.state == STATE_CLOSED self.assert_state(entity, STATE_CLOSED) self.allow_get(aioclient_mock, "/s/p", self.STATE_OPENING) await entity.async_open_cover() self.assert_state(entity, STATE_OPENING) async def test_fully_opened(self, aioclient_mock): entity = await self.updated(aioclient_mock, self.STATE_FULLY_OPENED) assert entity.state == STATE_OPEN async def test_close(self, aioclient_mock): """Test cover closing.""" entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) self.assert_state(entity, STATE_OPEN) self.allow_get(aioclient_mock, "/s/p", self.STATE_CLOSING) await entity.async_close_cover() self.assert_state(entity, STATE_CLOSING) async def test_closed(self, aioclient_mock): """Test cover closed state.""" entity = await self.updated(aioclient_mock, self.STATE_CLOSED) self.assert_state(entity, STATE_CLOSED) async def test_stop(self, aioclient_mock): """Test cover stopping.""" entity = await self.updated(aioclient_mock, self.STATE_OPENING) self.assert_state(entity, STATE_OPENING) self.allow_get(aioclient_mock, "/s/s", self.STATE_STOPPED) await entity.async_stop_cover() self.assert_state(entity, STATE_OPEN) async def test_with_stop(self, aioclient_mock): """Test stop capability is available.""" entity = await self.updated(aioclient_mock, self.STATE_OPENING) assert entity.supported_features & SUPPORT_STOP async def test_with_no_stop(self, aioclient_mock): """Test stop capability is not available.""" entity = await self.updated(aioclient_mock, self.STATE_OPENING_NO_STOP) assert not entity.supported_features & SUPPORT_STOP async def test_stop_with_no_stop(self, aioclient_mock): """Test stop capability is not available.""" entity = await self.updated(aioclient_mock, self.STATE_OPENING_NO_STOP) with pytest.raises( error.MisconfiguredDevice, match=r"second button not configured as 'stop'" ): await entity.async_stop_cover() async def test_set_position(self, aioclient_mock): """Test cover position setting.""" entity = await self.updated(aioclient_mock, self.STATE_CLOSED) with pytest.raises(NotImplementedError): await entity.async_set_cover_position(**{ATTR_POSITION: 1}) # almost closed async def test_unkown_position(self, aioclient_mock): """Test handling cover at unknown position.""" entity = await self.updated(aioclient_mock, self.STATE_UNKNOWN) self.assert_state(entity, None) class TestGateBoxB(CoverTest): """Tests for cover devices representing a BleBox gateBoxB subgroup.""" DEV_INFO_PATH = "state/extended" DEVICE_INFO = json.loads( """ { "device": { "deviceName":"My gateBox 1", "type":"gateBox", "product":"gateBox", "hv":"9.1d", "fv":"0.1010", "universe":0, "apiLevel":"20200831", "id":"1afe34d27e4f", "ip":"192.168.4.1", "availableFv":null } } """ ) DEVICE_INFO_FUTURE = jmerge(DEVICE_INFO, patch_version(future_date())) DEVICE_INFO_LATEST = jmerge( DEVICE_INFO, patch_version(get_latest_api_level("gateBox")) ) DEVICE_INFO_UNSUPPORTED = DEVICE_INFO DEVICE_INFO_UNSPECIFIED_API = None # already handled as default case STATE_DEFAULT = json.loads( """ { "gate": { "currentPos": 0, "openCloseMode": 0, "gateType": 1, "gatePulseTimeMs": 1500, "gateOutputState": 0, "extraButtonType": 1, "extraButtonPulseTimeMs": 1500, "extraButtonOutputState": 0, "inputsType": 0 } } """ ) STATE_CLOSED = STATE_DEFAULT STATE_STOPPED = jmerge(STATE_DEFAULT, '{"gate": {"currentPos": 50 }}') STATE_FULLY_OPENED = jmerge(STATE_DEFAULT, '{"gate": {"currentPos": 100 }}') STATE_UNKNOWN = json.loads( """ { "gate": { "currentPos": -1, "openCloseMode": 0, "gateType": 1, "gatePulseTimeMs": 1500, "gateOutputState": 0, "extraButtonType": 1, "extraButtonPulseTimeMs": 1500, "extraButtonOutputState": 0, "inputsType": 0 } } """ ) async def test_init(self, aioclient_mock): """Test cover default state.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[0] assert entity.name == "My gateBox 1 (gateBox#position)" assert entity.unique_id == "BleBox-gateBox-1afe34d27e4f-position" assert entity.device_class == DEVICE_CLASS_DOOR assert entity.supported_features & SUPPORT_OPEN assert entity.supported_features & SUPPORT_CLOSE assert entity.current_cover_position is None self.assert_state(entity, None) async def test_device_info(self, aioclient_mock): """Test device info state.""" await self.allow_get_info(aioclient_mock, self.DEVICE_INFO) entity = (await self.async_entities(aioclient_mock))[0] # import pdb;pdb.set_trace() assert entity.device_info["name"] == "My gateBox 1" assert entity.device_info["mac"] == "1afe34d27e4f" assert entity.device_info["manufacturer"] == "BleBox" assert entity.device_info["model"] == "gateBox" assert entity.device_info["sw_version"] == "0.1010" async def test_fully_opened(self, aioclient_mock): """Test cover fully opened.""" entity = await self.updated(aioclient_mock, self.STATE_FULLY_OPENED) assert entity.state == STATE_OPEN async def test_stop(self, aioclient_mock): """Test cover stopped.""" entity = await self.updated(aioclient_mock, self.STATE_STOPPED) self.assert_state(entity, STATE_OPEN) async def test_closed(self, aioclient_mock): """Test cover closed.""" entity = await self.updated(aioclient_mock, self.STATE_CLOSED) self.assert_state(entity, STATE_CLOSED) async def test_unkown_position(self, aioclient_mock): """Test handling cover at unknown position.""" entity = await self.updated(aioclient_mock, self.STATE_UNKNOWN) self.assert_state(entity, None) class TestGateController(CoverTest): """Tests for cover devices representing a BleBox gateController.""" DEV_INFO_PATH = "api/gatecontroller/state" DEVICE_INFO = json.loads( """ { "device": { "deviceName": "My gate controller 1", "type": "gateController", "apiLevel": "20180604", "fv": "1.390", "hv": "custom.2.6", "id": "0ff2ffaafe30db9437", "ip": "192.168.1.11" } } """ ) DEVICE_INFO_FUTURE = jmerge(DEVICE_INFO, patch_version(future_date())) DEVICE_INFO_LATEST = jmerge( DEVICE_INFO, patch_version(get_latest_api_level("gateController")) ) DEVICE_INFO_UNSUPPORTED = jmerge(DEVICE_INFO, patch_version(20180603)) # NOTE: can't happen with a real device DEVICE_INFO_UNSPECIFIED_API = json.loads( """ { "device": { "deviceName": "My gate controller 1", "type": "gateController", "fv": "0.981", "hv": "custom.2.6", "id": "0ff2ffaafe30db9437", "ip": "192.168.1.11" } } """ ) STATE_DEFAULT = json.loads( """ { "gateController": { "state": 2, "safety": { "eventReason": 0, "triggered": [ 0 ] }, "currentPos": { "positions": [ 31 ] }, "desiredPos": { "positions": [ 29 ] } } } """ ) def patch_state(state, current, desired): """Generate a patch for a JSON state fixture.""" return f""" {{ "gateController": {{ "state": {state}, "currentPos": {{ "positions": [ {current} ] }}, "desiredPos": {{ "positions": [ {desired} ] }} }} }} """ STATE_CLOSED = jmerge(STATE_DEFAULT, patch_state(3, 100, 100)) STATE_OPENING = jmerge(STATE_DEFAULT, patch_state(1, 34, 0)) STATE_CLOSING = jmerge(STATE_DEFAULT, patch_state(0, 78, 100)) STATE_MINIMALLY_OPENING = jmerge(STATE_DEFAULT, patch_state(1, 97, 100)) STATE_STOPPED = jmerge(STATE_DEFAULT, patch_state(2, 34, 100)) async def test_init(self, aioclient_mock): """Test cover default state.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[0] assert entity.name == "My gate controller 1 (gateController#position)" assert entity.unique_id == "BleBox-gateController-0ff2ffaafe30db9437-position" assert entity.device_class == DEVICE_CLASS_DOOR assert entity.supported_features & SUPPORT_OPEN assert entity.supported_features & SUPPORT_CLOSE assert entity.supported_features & SUPPORT_STOP assert entity.supported_features & SUPPORT_SET_POSITION assert entity.current_cover_position is None self.assert_state(entity, None) async def test_device_info(self, aioclient_mock): await self.allow_get_info(aioclient_mock, self.DEVICE_INFO) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My gate controller 1" assert entity.device_info["mac"] == "0ff2ffaafe30db9437" assert entity.device_info["manufacturer"] == "BleBox" assert entity.device_info["model"] == "gateController" assert entity.device_info["sw_version"] == "1.390" async def test_update(self, aioclient_mock): """Test cover updating.""" entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) assert entity.current_cover_position == 71 # 100 - 29 self.assert_state(entity, STATE_OPEN) async def test_open(self, aioclient_mock): """Test cover opening.""" entity = await self.updated(aioclient_mock, self.STATE_CLOSED) self.assert_state(entity, STATE_CLOSED) self.allow_get(aioclient_mock, "/s/o", self.STATE_OPENING) await entity.async_open_cover() self.assert_state(entity, STATE_OPENING) async def test_close(self, aioclient_mock): """Test cover closing.""" entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) self.assert_state(entity, STATE_OPEN) self.allow_get(aioclient_mock, "/s/c", self.STATE_CLOSING) await entity.async_close_cover() self.assert_state(entity, STATE_CLOSING) async def test_set_position(self, aioclient_mock): """Test cover position setting.""" entity = await self.updated(aioclient_mock, self.STATE_CLOSED) self.assert_state(entity, STATE_CLOSED) self.allow_get(aioclient_mock, "/s/p/99", self.STATE_MINIMALLY_OPENING) await entity.async_set_cover_position(**{ATTR_POSITION: 1}) # almost closed self.assert_state(entity, STATE_OPENING) async def test_stop(self, aioclient_mock): """Test cover stopping.""" entity = await self.updated(aioclient_mock, self.STATE_OPENING) self.assert_state(entity, STATE_OPENING) self.allow_get(aioclient_mock, "/s/s", self.STATE_STOPPED) await entity.async_stop_cover() self.assert_state(entity, STATE_OPEN) blebox-blebox_uniapi-16587f1/tests/test_light.py000066400000000000000000001131511466110602600217540ustar00rootroot00000000000000"""BleBox light entities tests.""" import json from .conftest import CommonEntity, DefaultBoxTest, future_date, jmerge from blebox_uniapi.box_types import get_latest_api_level from blebox_uniapi.light import Light # TODO: remove import colorsys import pytest # TODO: remove ATTR_BRIGHTNESS = "brightness" ATTR_HS_COLOR = "ATTR_HS_COLOR" ATTR_WHITE_VALUE = "ATTR_WHITE_VALUE" ATTR_RGB_COLOR = "rgb_color" ATTR_RGBW_COLOR = "rgbw_color" ATTR_EFFECT = "effect" ATTR_COLOR_TEMP = "color_temp" ATTR_RGBWW_COLOR = "rgbww_color" SUPPORT_BRIGHTNESS = 1 SUPPORT_COLOR = 2 SUPPORT_WHITE_VALUE = 4 COLOR_MODE_BRIGHTNESS = "brightness" COLOR_MODE_COLOR_TEMP = "color_temp" COLOR_MODE_ONOFF = "onoff" COLOR_MODE_RGB = "rgb" COLOR_MODE_RGBW = "rgbw" COLOR_MODE_RGBWW = "rgbww" COLOR_MODE_MAP = { 1: COLOR_MODE_RGBW, 2: COLOR_MODE_RGB, 3: COLOR_MODE_BRIGHTNESS, 4: COLOR_MODE_RGBW, # RGB and Brightness 2 and 3 implementation difference, if W hex is not null only this or RGB + Brightness separated with mask 5: COLOR_MODE_COLOR_TEMP, 6: COLOR_MODE_COLOR_TEMP, # two instances 7: COLOR_MODE_RGBWW, } # NOTE: copied from Home Assistant color util module def rgb_hex_to_rgb_list(hex_string: str): """Return an RGB color value list from a hex color string.""" return [ int(hex_string[i : i + len(hex_string) // 3], 16) for i in range(0, len(hex_string), len(hex_string) // 3) ] def color_hsv_to_RGB(iH: float, iS: float, iV: float): """Convert an hsv color into its rgb representation. Hue is scaled 0-360 Sat is scaled 0-100 Val is scaled 0-100 """ fRGB = colorsys.hsv_to_rgb(iH / 360, iS / 100, iV / 100) return (int(fRGB[0] * 255), int(fRGB[1] * 255), int(fRGB[2] * 255)) def color_hs_to_RGB(iH: float, iS: float): """Convert an hsv color into its rgb representation.""" return color_hsv_to_RGB(iH, iS, 100) def color_RGB_to_hs(iR: float, iG: float, iB: float): """Convert an rgb color to its hs representation.""" return color_RGB_to_hsv(iR, iG, iB)[:2] def color_RGB_to_hsv(iR: float, iG: float, iB: float): """Convert an rgb color to its hsv representation. Hue is scaled 0-360 Sat is scaled 0-100 Val is scaled 0-100 """ fHSV = colorsys.rgb_to_hsv(iR / 255.0, iG / 255.0, iB / 255.0) return round(fHSV[0] * 360, 3), round(fHSV[1] * 100, 3), round(fHSV[2] * 100, 3) def color_rgb_to_hex(r: int, g: int, b: int) -> str: """Return a RGB color from a hex color string.""" return "{0:02x}{1:02x}{2:02x}".format(round(r), round(g), round(b)) class BleBoxLightEntity(CommonEntity): """Representation of BleBox lights.""" @property def is_on(self) -> bool: """Return if light is on.""" return self._feature.is_on @property def brightness(self): """Return the brightness.""" return self._feature.brightness @property def color_temp(self): """Return color temperature.""" return self._feature.color_temp @property def color_mode(self): """Return the color mode. Set values to _attr_ibutes if needed.""" color_mode_tmp = COLOR_MODE_MAP.get(self._feature.color_mode, COLOR_MODE_ONOFF) if color_mode_tmp == COLOR_MODE_COLOR_TEMP: self._attr_min_mireds = 1 self._attr_max_mireds = 255 return color_mode_tmp @property def effect_list(self): """Return the list of supported effects.""" return self._feature.effect_list @property def effect(self): """Return the current effect.""" return self._feature.effect @property def rgb_color(self): """Return value for rgb.""" if (rgb_hex := self._feature.rgb_hex) is None: return None return tuple( self._feature.normalise_elements_of_rgb( self._feature.rgb_hex_to_rgb_list(rgb_hex)[0:3] ) ) @property def rgbw_color(self): """Return the hue and saturation.""" if (rgbw_hex := self._feature.rgbw_hex) is None: return None return tuple(self._feature.rgb_hex_to_rgb_list(rgbw_hex)[0:4]) @property def rgbww_color(self): """Return value for rgbww.""" if (rgbww_hex := self._feature.rgbww_hex) is None: return None return tuple(self._feature.rgb_hex_to_rgb_list(rgbww_hex)) async def async_turn_on(self, **kwargs): """Turn the light on.""" rgbw = kwargs.get(ATTR_RGBW_COLOR) brightness = kwargs.get(ATTR_BRIGHTNESS) effect = kwargs.get(ATTR_EFFECT) color_temp = kwargs.get(ATTR_COLOR_TEMP) rgbww = kwargs.get(ATTR_RGBWW_COLOR) feature = self._feature value = feature.sensible_on_value rgb = kwargs.get(ATTR_RGB_COLOR) if rgbw is not None: value = list(rgbw) if color_temp is not None: value = feature.return_color_temp_with_brightness( int(color_temp), self.brightness ) if rgbww is not None: value = list(rgbww) if rgb is not None: if self.color_mode == COLOR_MODE_RGB and brightness is None: brightness = self.brightness value = list(rgb) if brightness is not None: if self.color_mode == ATTR_COLOR_TEMP: value = feature.return_color_temp_with_brightness( self.color_temp, brightness ) else: value = feature.apply_brightness(value, brightness) if effect is not None: effect_value = self.effect_list.index(effect) await self._feature.async_api_command("effect", effect_value) else: await self._feature.async_on(value) async def async_turn_off(self, **kwargs): """Turn the light off.""" await self._feature.async_off() class TestDimmer(DefaultBoxTest): """Tests for BleBox dimmerBox.""" DEVCLASS = "lights" ENTITY_CLASS = BleBoxLightEntity DEV_INFO_PATH = "api/dimmer/state" DEVICE_INFO = json.loads( """ { "device": { "deviceName": "My dimmer", "type": "dimmerBox", "fv": "0.247", "hv": "0.2", "id": "1afe34e750b8", "apiLevel": "20170829" }, "network": { "ip": "192.168.1.239", "ssid": "myWiFiNetwork", "station_status": 5, "apSSID": "dimmerBox-ap", "apPasswd": "" }, "dimmer": { "loadType": 7, "currentBrightness": 65, "desiredBrightness": 65, "temperature": 39, "overloaded": false, "overheated": false } } """ ) def patch_version(apiLevel): """Generate a patch for a JSON state fixture.""" return f""" {{ "device": {{ "apiLevel": {apiLevel} }} }} """ DEVICE_INFO_FUTURE = jmerge(DEVICE_INFO, patch_version(future_date())) DEVICE_INFO_LATEST = jmerge( DEVICE_INFO, patch_version(get_latest_api_level("dimmerBox")) ) DEVICE_INFO_UNSUPPORTED = jmerge( DEVICE_INFO, patch_version(20140828) ) # fake version, default_api_level implemented DEVICE_INFO_UNSPECIFIED_API = json.loads( """ { "device": { "deviceName": "My dimmer", "type": "dimmerBox", "fv": "0.247", "hv": "0.2", "id": "1afe34e750b8" }, "network": { "ip": "192.168.1.239", "ssid": "myWiFiNetwork", "station_status": 5, "apSSID": "dimmerBox-ap", "apPasswd": "" }, "dimmer": { "loadType": 7, "currentBrightness": 65, "desiredBrightness": 65, "temperature": 39, "overloaded": false, "overheated": false } } """ ) STATE_DEFAULT = json.loads( """ { "dimmer": { "loadType": 7, "currentBrightness": 11, "desiredBrightness": 53, "temperature": 29, "overloaded": false, "overheated": false } } """ ) def patch_state(current, desired): """Generate a patch for a JSON state fixture.""" return f""" {{ "dimmer": {{ "currentBrightness": {current}, "desiredBrightness": {desired} }} }} """ STATE_OFF = jmerge(STATE_DEFAULT, patch_state(0, 0)) STATE_ON_DEFAULT = jmerge(STATE_DEFAULT, patch_state(238, 255)) STATE_ON_BRIGHT = jmerge(STATE_DEFAULT, patch_state(201, 202)) async def test_init(self, aioclient_mock): """Test cover default state.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[0] assert entity.name == "My dimmer (dimmerBox#brightness)" assert entity.unique_id == "BleBox-dimmerBox-1afe34e750b8-brightness" # assert entity.supported_features & SUPPORT_BRIGHTNESS assert entity.brightness is None assert entity.is_on is None async def test_device_info(self, aioclient_mock): await self.allow_get_info(aioclient_mock, self.DEVICE_INFO) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My dimmer" assert entity.device_info["mac"] == "1afe34e750b8" assert entity.device_info["manufacturer"] == "BleBox" assert entity.device_info["model"] == "dimmerBox" assert entity.device_info["sw_version"] == "0.247" async def test_update(self, aioclient_mock): """Test light updating.""" entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) assert entity.brightness == 53 assert entity.is_on is True async def allow_set_brightness(self, code, aioclient_mock, value, response): """Set up mock for HTTP POST simulating brightness change.""" await self.allow_post( code, aioclient_mock, "/api/dimmer/set", '{"dimmer":{"desiredBrightness": ' + str(value) + "}}", response, ) async def test_on(self, aioclient_mock): """Test light on.""" entity = await self.updated(aioclient_mock, self.STATE_OFF) assert entity.is_on is False async def turn_on(): await entity.async_turn_on() await self.allow_set_brightness( turn_on, aioclient_mock, 255, self.STATE_ON_DEFAULT ) assert entity.is_on is True # TODO: is max brightness a good default? assert entity.brightness == 255 async def test_on_with_brightness(self, aioclient_mock): """Test light on with a brightness value.""" entity = await self.updated(aioclient_mock, self.STATE_OFF) assert entity.is_on is False async def turn_on(): await entity.async_turn_on(brightness=202) await self.allow_set_brightness( turn_on, aioclient_mock, 202, self.STATE_ON_BRIGHT ) assert entity.is_on is True assert entity.brightness == 202 # as if desired brightness not reached yet async def test_off(self, aioclient_mock): """Test light off.""" entity = await self.updated(aioclient_mock, self.STATE_ON_DEFAULT) assert entity.is_on is True async def turn_off(): await entity.async_turn_off() await self.allow_set_brightness(turn_off, aioclient_mock, 0, self.STATE_OFF) assert entity.is_on is False assert entity.brightness == 0 async def test_unspecified_version(self, aioclient_mock): """ As default_api_level implemented for this class of devices, test method shall be omitted. """ pass class TestWLightBoxS(DefaultBoxTest): """Tests for BleBox wLightBoxS.""" DEVCLASS = "lights" ENTITY_CLASS = BleBoxLightEntity DEVICE_EXTENDED_INFO_PATH = "/api/rgbw/extended/state" DEV_INFO_PATH = "api/rgbw/state" DEVICE_INFO = json.loads( """ { "device": { "deviceName": "My wLightBoxS", "type": "wLightBox", "product":"wLightBoxS", "fv": "0.924", "hv": "0.1", "universe": 0, "id": "1afe34e750b8", "apiLevel": 20200229, "ip": "192.168.9.13", "availableFv": null } } """ ) DEVICE_INFO2 = { "device": { "deviceName": "My wLightBoxS", "type": "wLightBox", "product": "wLightBoxS", "hv": "s_0.1", "fv": "0.1022", "universe": 0, "apiLevel": "20200229", "id": "ce50e32d2707", "ip": "192.168.1.25", "availableFv": None, } } DEVICE_EXTENDED_INFO = { "rgbw": { "desiredColor": "f5", "currentColor": "f5", # 245 in decimal "lastOnColor": "f5", "durationsMs": {"colorFade": 1000, "effectFade": 1000, "effectStep": 1000}, "effectID": 0, "colorMode": 3, "favColors": {"0": "ff", "1": "00", "2": "c0", "3": "40", "4": "00"}, "effectsNames": {"0": "NONE", "1": "FADE", "2": "Stroboskop", "3": "BELL"}, } } def patch_version(apiLevel): """Generate a patch for a JSON state fixture.""" return f""" {{ "device": {{ "apiLevel": {apiLevel} }} }} """ DEVICE_INFO_FUTURE = jmerge(DEVICE_INFO, patch_version(future_date())) DEVICE_INFO_LATEST = jmerge( DEVICE_INFO, patch_version(get_latest_api_level("wLightBoxS")) ) DEVICE_INFO_UNSUPPORTED = jmerge(DEVICE_INFO, patch_version(20140717)) DEVICE_INFO_UNSPECIFIED_API = json.loads( """ { "device": { "deviceName": "My wLightBoxS", "type": "wLightBoxS", "fv": "0.247", "hv": "0.2", "id": "1afe34e750b8" }, "network": { "ip": "192.168.1.239", "ssid": "myWiFiNetwork", "station_status": 5, "apSSID": "wLightBoxS-ap", "apPasswd": "" }, "rgbw": { "desiredColor": "e3", "currentColor": "df", "fadeSpeed": 255, "effectID": 0 } } """ ) STATE_ON = json.loads( """ { "rgbw": { "desiredColor": "ab", "currentColor": "cd", "fadeSpeed": 255, "effectID": 0, "colorMode": 3 } } """ ) STATE_FULL_ON = json.loads( """ { "rgbw": { "desiredColor": "ff", "currentColor": "ce", "fadeSpeed": 255, "effectID": 0 } } """ ) STATE_OFF = json.loads( """ { "rgbw": { "desiredColor": "00", "currentColor": "00", "fadeSpeed": 255, "effectID": 0 } } """ ) STATE_DEFAULT = STATE_ON async def test_init(self, aioclient_mock): """Test cover default state.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[0] assert entity.name == "My wLightBoxS (wLightBoxS#brightness_mono1)" assert entity.unique_id == "BleBox-wLightBoxS-1afe34e750b8-brightness_mono1" assert entity.brightness == 0xF5 assert entity.is_on async def test_device_info(self, aioclient_mock): await self.allow_get_info(aioclient_mock, self.DEVICE_INFO) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My wLightBoxS" assert entity.device_info["mac"] == "1afe34e750b8" assert entity.device_info["manufacturer"] == "BleBox" assert entity.device_info["model"] == "wLightBoxS" assert entity.device_info["sw_version"] == "0.924" async def test_device_info2(self, aioclient_mock): await self.allow_get_info(aioclient_mock, self.DEVICE_INFO2) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My wLightBoxS" assert entity.device_info["mac"] == "ce50e32d2707" assert entity.device_info["manufacturer"] == "BleBox" assert entity.device_info["model"] == "wLightBoxS" assert entity.device_info["sw_version"] == "0.1022" async def test_update(self, aioclient_mock): """Test light updating.""" entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) assert entity.brightness == 0xAB assert entity.is_on is True async def allow_set_brightness(self, code, aioclient_mock, value, response): """Set up mock for HTTP POST simulating color change.""" raw = "{:02x}".format(value) await self.allow_post( code, aioclient_mock, "/api/rgbw/set", json.dumps( {"rgbw": {"desiredColor": raw + "------"}} ), # simulating mask for color mod 3 response, ) async def test_on(self, aioclient_mock): """Test light on.""" entity = await self.updated(aioclient_mock, self.STATE_OFF) assert entity.is_on is False async def turn_on(): await entity.async_turn_on() await self.allow_set_brightness( turn_on, aioclient_mock, 0xFF, self.STATE_FULL_ON ) assert entity.is_on is True assert entity.brightness == 0xFF async def test_on_with_bad_value_type(self, aioclient_mock): """Test light on with off value.""" entity = await self.updated(aioclient_mock, self.STATE_OFF) assert entity.is_on is False with pytest.raises( ValueError, match=r"adjust_brightness called with bad parameter \(00 is instead of int\)", ): await entity.async_turn_on(brightness="00") async def test_on_with_bad_value_exceeding_max(self, aioclient_mock): """Test light on with off value.""" entity = await self.updated(aioclient_mock, self.STATE_OFF) assert entity.is_on is False with pytest.raises( ValueError, match=r"adjust_brightness called with bad parameter \(1234 is greater than 255\)", ): await entity.async_turn_on(brightness=1234) async def test_off(self, aioclient_mock): """Test light off.""" entity = await self.updated(aioclient_mock, self.STATE_ON) assert entity.is_on is True async def turn_off(): await entity.async_turn_off() await self.allow_set_brightness(turn_off, aioclient_mock, 0, self.STATE_OFF) assert entity.is_on is False assert entity.brightness == 0 async def test_unspecified_version(self, aioclient_mock): """ As default_api_level implemented for this class of devices, test method shall be omitted. """ pass class TestWLightBox(DefaultBoxTest): """Tests for BleBox wLightBox.""" DEVCLASS = "lights" ENTITY_CLASS = BleBoxLightEntity DEVICE_EXTENDED_INFO_PATH = "/api/rgbw/extended/state" # TODO: rename everywhere (STATE_PATH) DEV_INFO_PATH = "api/rgbw/state" DEVICE_INFO = json.loads( """ { "device": { "deviceName": "My light 1", "type": "wLightBox", "fv": "0.993", "hv": "4.3", "id": "1afe34e750b8", "apiLevel": 20190808 } } """ ) DEVICE_EXTENDED_INFO = { "rgbw": { "colorMode": 4, "effectID": 0, "desiredColor": "fa00203A", "currentColor": "ff00302F", "lastOnColor": "f1e2d3e4", "durationsMs": {"colorFade": 1000, "effectFade": 1500, "effectStep": 2000}, "favColors": {"0": "ff", "1": "00", "2": "c0", "3": "40", "4": "00"}, "effectsNames": {"0": "NONE", "1": "FADE", "2": "Stroboskop", "3": "BELL"}, } } DEVICE_EXTENDED_INFO_COLORMODE_1 = { "rgbw": { "colorMode": 1, "effectID": 0, "desiredColor": "fa", "currentColor": "ff", "lastOnColor": "ff", "durationsMs": {"colorFade": 1000, "effectFade": 1500, "effectStep": 2000}, "favColors": {"0": "ff", "1": "00", "2": "c0", "3": "40", "4": "00"}, "effectsNames": {"0": "NONE", "1": "FADE", "2": "Stroboskop", "3": "BELL"}, } } DEVICE_EXTENDED_INFO_COLORMODE_2 = { "rgbw": { "colorMode": 2, "effectID": 0, "desiredColor": "fa00203A", "currentColor": "ff00302F", "lastOnColor": "f1e2d3e4", "durationsMs": {"colorFade": 1000, "effectFade": 1500, "effectStep": 2000}, "favColors": {"0": "ff", "1": "00", "2": "c0", "3": "40", "4": "00"}, "effectsNames": {"0": "NONE", "1": "FADE", "2": "Stroboskop", "3": "BELL"}, } } DEVICE_EXTENDED_INFO_COLORMODE_3 = { "rgbw": { "colorMode": 3, "effectID": 0, "desiredColor": "fa00203A", "currentColor": "ff00302F", "lastOnColor": "f1e2d3e4", "durationsMs": {"colorFade": 1000, "effectFade": 1500, "effectStep": 2000}, "favColors": {"0": "ff", "1": "00", "2": "c0", "3": "40", "4": "00"}, "effectsNames": {"0": "NONE", "1": "FADE", "2": "Stroboskop", "3": "BELL"}, } } DEVICE_EXTENDED_INFO_COLORMODE_4 = { "rgbw": { "colorMode": 4, "effectID": 0, "desiredColor": "fa00203A", "currentColor": "ff00302F", "lastOnColor": "f1e2d3e4", "durationsMs": {"colorFade": 1000, "effectFade": 1500, "effectStep": 2000}, "favColors": {"0": "ff", "1": "00", "2": "c0", "3": "40", "4": "00"}, "effectsNames": {"0": "NONE", "1": "FADE", "2": "Stroboskop", "3": "BELL"}, } } DEVICE_EXTENDED_INFO_COLORMODE_5 = { "rgbw": { "colorMode": 5, "effectID": 0, "desiredColor": "fa00203A", "currentColor": "ff00302F", "lastOnColor": "f1e2d3e4", "durationsMs": {"colorFade": 1000, "effectFade": 1500, "effectStep": 2000}, "favColors": {"0": "ff", "1": "00", "2": "c0", "3": "40", "4": "00"}, "effectsNames": {"0": "NONE", "1": "FADE", "2": "Stroboskop", "3": "BELL"}, } } DEVICE_EXTENDED_INFO_COLORMODE_6 = { "rgbw": { "colorMode": 6, "effectID": 0, "desiredColor": "fa00203A", "currentColor": "ff00302F", "lastOnColor": "f1e2d3e4", "durationsMs": {"colorFade": 1000, "effectFade": 1500, "effectStep": 2000}, "favColors": {"0": "ff", "1": "00", "2": "c0", "3": "40", "4": "00"}, "effectsNames": {"0": "NONE", "1": "FADE", "2": "Stroboskop", "3": "BELL"}, } } DEVICE_EXTENDED_INFO_COLORMODE_7 = { "rgbw": { "colorMode": 7, "effectID": 0, "desiredColor": "fcfffcff00", "currentColor": "fcfffcff00", "lastOnColor": "fcfffcff00", "durationsMs": {"colorFade": 1000, "effectFade": 1000, "effectStep": 1000}, "favColors": {"0": "ff", "1": "00", "2": "c0", "3": "40", "4": "00"}, "effectsNames": {"0": "NONE", "1": "FADE", "2": "Stroboskop", "3": "BELL"}, } } def patch_version(apiLevel): """Generate a patch for a JSON state fixture.""" return f""" {{ "device": {{ "apiLevel": {apiLevel} }} }} """ DEVICE_INFO_FUTURE = jmerge(DEVICE_INFO, patch_version(future_date())) DEVICE_INFO_LATEST = jmerge( DEVICE_INFO, patch_version(get_latest_api_level("wLightBox")) ) DEVICE_INFO_UNSUPPORTED = jmerge(DEVICE_INFO, patch_version(20140717)) DEVICE_INFO_UNSPECIFIED_API = json.loads( """ { "device": { "deviceName": "My light 1", "type": "wLightBox", "fv": "0.247", "hv": "0.2", "id": "1afe34e750b8" }, "network": { "ip": "192.168.1.237", "ssid": "myWiFiNetwork", "station_status": 5, "apSSID": "wLightBox-ap", "apPasswd": "" }, "rgbw": { "desiredColor": "abcdefd9", "currentColor": "abcdefd9", "fadeSpeed": 248, "effectSpeed": 2, "effectID": 3, "colorMode": 3 } } """ ) STATE_DEFAULT = json.loads( """ { "rgbw": { "colorMode": 4, "effectID": 0, "desiredColor": "fa00203A", "currentColor": "ff00302F", "lastOnColor": "f1e2d3e4", "durationsMs": { "colorFade": 1000, "effectFade": 1500, "effectStep": 2000 } } } """ ) def patch_state(current, desired=None, last=None): """Generate a patch for a JSON state fixture.""" if desired is None: desired = current return f""" {{ "rgbw": {{ "currentColor": "{current}", "desiredColor": "{desired}" { "" if last is None else f',"lastOnColor": "{last}"' } }} }} """ STATE_OFF = jmerge(STATE_DEFAULT, patch_state("00000000")) STATE_OFF_NOLAST_WHITE = jmerge( STATE_DEFAULT, patch_state("0a0b0c0d", "00000000", "dacefb00") ) STATE_ON = STATE_DEFAULT STATE_ON_AFTER_WHITE = jmerge( STATE_DEFAULT, patch_state("01020304", "f1e2d3c7", "f1e2d3c7") ) STATE_ON_AFTER_RESET_WHITE = jmerge( STATE_DEFAULT, patch_state("01020304", "f1e2d300", "f1e2d300") ) STATE_ON_ONLY_SOME_COLOR = jmerge(STATE_DEFAULT, patch_state("ffa1b200")) STATE_ON_LAST = jmerge(STATE_DEFAULT, patch_state("01020304", "f1e2d3e4")) STATE_AFTER_SOME_COLOR_SET = jmerge(STATE_DEFAULT, patch_state("ffa1b2e4")) async def test_init(self, aioclient_mock): """Test cover default state.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[0] assert entity.name == "My light 1 (wLightBox#color_RGBorW)" assert entity.unique_id == "BleBox-wLightBox-1afe34e750b8-color_RGBorW" # In current state of master branch white_value is not property of BleBoxLightEntity, fake test... dissapointing assert entity._feature.supports_white assert entity._feature.white_value == 0x3A assert entity._feature.supports_color assert entity.brightness == 0xFA assert entity.is_on is True async def test_device_info(self, aioclient_mock): await self.allow_get_info(aioclient_mock, self.DEVICE_INFO) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My light 1" assert entity.device_info["mac"] == "1afe34e750b8" assert entity.device_info["manufacturer"] == "BleBox" assert entity.device_info["model"] == "wLightBox" assert entity.device_info["sw_version"] == "0.993" async def test_update(self, aioclient_mock): """Test light updating.""" entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) assert entity.brightness == 250 # assert entity.hs_color == (352.32, 100.0) # assert entity.white_value == 0x3A assert entity.is_on is True # state already available async def allow_set_color(self, code, aioclient_mock, value, response): """Set up mock for HTTP POST simulating color change.""" await self.allow_post( code, aioclient_mock, "/api/rgbw/set", '{"rgbw":{"desiredColor": "' + str(value) + '"}}', response, ) async def test_on_to_last_color(self, aioclient_mock): """Test light on.""" entity = await self.updated(aioclient_mock, self.STATE_OFF) assert entity.is_on is False async def action(): await entity.async_turn_on() await self.allow_set_color( action, aioclient_mock, "f1e2d3e4", self.STATE_ON_LAST ) assert entity.is_on is True # assert entity.white_value == 0xE4 assert entity.rgbw_color == tuple( [int(i, 16) for i in ["f1", "e2", "d3", "e4"]] ) async def test_off(self, aioclient_mock): """Test light off.""" entity = await self.updated(aioclient_mock, self.STATE_ON) assert entity.is_on is True async def action(): await entity.async_turn_off() await self.allow_set_color(action, aioclient_mock, "00000000", self.STATE_OFF) assert entity.is_on is False # assert entity.white_value == 0x00 async def test_colormode_5_brightness(self, aioclient_mock): self.DEVICE_EXTENDED_INFO = self.DEVICE_EXTENDED_INFO_COLORMODE_5 await self.allow_get_info(aioclient_mock) self.STATE_DEFAULT["colorMode"] = 5 entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) assert "_cct" in entity.name assert entity.brightness == 250 async def test_colormode_6_brightness(self, aioclient_mock): self.DEVICE_EXTENDED_INFO = self.DEVICE_EXTENDED_INFO_COLORMODE_6 await self.allow_get_info(aioclient_mock) self.STATE_DEFAULT["colorMode"] = 6 entity = await self.updated(aioclient_mock, self.STATE_DEFAULT, 1) assert "_cct2" in entity.name assert entity.brightness async def test_effect_list_return_list(self, aioclient_mock): self.DEVICE_EXTENDED_INFO = self.DEVICE_EXTENDED_INFO_COLORMODE_5 await self.allow_get_info(aioclient_mock) self.STATE_DEFAULT["colorMode"] = 5 entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) assert entity.effect_list async def test_color_temp_for_colomode_6(self, aioclient_mock): self.DEVICE_EXTENDED_INFO = self.DEVICE_EXTENDED_INFO_COLORMODE_6 await self.allow_get_info(aioclient_mock) self.STATE_DEFAULT["colorMode"] = 6 entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) assert "_cct1" in entity.name assert entity.color_temp async def test_color_temp_for_colomode_rgbww(self, aioclient_mock): self.DEVICE_EXTENDED_INFO = self.DEVICE_EXTENDED_INFO_COLORMODE_7 await self.allow_get_info(aioclient_mock) self.STATE_DEFAULT["colorMode"] = 7 entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) assert "_RGBCCT" in entity.name assert entity.color_temp assert entity.brightness async def test_normalise_element_colormode_rgb(self, aioclient_mock): # testing sensible on value which is used only while async_turn_on executed self.DEVICE_EXTENDED_INFO = self.DEVICE_EXTENDED_INFO_COLORMODE_2 self.DEVICE_EXTENDED_INFO = jmerge( self.DEVICE_EXTENDED_INFO, self.patch_state("fafafa", "fafafa") ) self.STATE_DEFAULT["rgbw"]["colorMode"] = 2 self.STATE_DEFAULT = jmerge( self.STATE_DEFAULT, self.patch_state("fafafa", "fafafa") ) await self.allow_get_info(aioclient_mock) entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) async def turn_on(): await entity.async_turn_on(rgb_color=(255, 0, 140)) self.STATE_ON = jmerge(self.STATE_ON, self.patch_state("fafafa", "fafafa")) await self.allow_set_color(turn_on, aioclient_mock, "fa0089", self.STATE_ON) assert max(entity.rgbw_color) == 250 async def test_normalise_when_max_is_zero_rgb(self, aioclient_mock): # testing sensible on value which is used only while async_turn_on executed self.DEVICE_EXTENDED_INFO = self.DEVICE_EXTENDED_INFO_COLORMODE_2 self.DEVICE_EXTENDED_INFO = jmerge( self.DEVICE_EXTENDED_INFO, self.patch_state("030303", "030303") ) self.STATE_DEFAULT["rgbw"]["colorMode"] = 2 self.STATE_DEFAULT = jmerge( self.STATE_DEFAULT, self.patch_state("030303", "030303") ) await self.allow_get_info(aioclient_mock) entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) async def turn_on(): await entity.async_turn_on(rgb_color=(255, 10, 255)) self.STATE_ON = jmerge(self.STATE_ON, self.patch_state("000000", "000000")) await self.allow_set_color(turn_on, aioclient_mock, "030003", self.STATE_ON) assert max(entity.rgb_color) == 255 async def test_sensible_on_value_for_color_mode_1(self, aioclient_mock): self.DEVICE_EXTENDED_INFO = self.DEVICE_EXTENDED_INFO_COLORMODE_1 self.DEVICE_EXTENDED_INFO = jmerge( self.DEVICE_EXTENDED_INFO, self.patch_state("00000000", "00000000") ) self.STATE_DEFAULT["rgbw"]["colorMode"] = 1 self.STATE_DEFAULT = jmerge( self.STATE_DEFAULT, self.patch_state("00000000", "00000000") ) await self.allow_get_info(aioclient_mock) entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) async def turn_on(): await entity.async_turn_on() self.STATE_ON = jmerge(self.STATE_ON, self.patch_state("ffffffff", "ffffffff")) await self.allow_set_color(turn_on, aioclient_mock, "ffffffff", self.STATE_ON) assert entity.rgbw_color == (255, 255, 255, 255) async def test_sensible_on_value_for_color_mode_5(self, aioclient_mock): self.DEVICE_EXTENDED_INFO = self.DEVICE_EXTENDED_INFO_COLORMODE_5 self.DEVICE_EXTENDED_INFO = jmerge( self.DEVICE_EXTENDED_INFO, self.patch_state("00000000", "00000000") ) self.STATE_DEFAULT["rgbw"]["colorMode"] = 5 self.STATE_DEFAULT = jmerge( self.STATE_DEFAULT, self.patch_state("00000000", "00000000") ) await self.allow_get_info(aioclient_mock) self.STATE_DEFAULT["colorMode"] = 5 entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) async def turn_on(): await entity.async_turn_on() self.STATE_ON = jmerge(self.STATE_ON, self.patch_state("ffffffff", "ffffffff")) await self.allow_set_color(turn_on, aioclient_mock, "ffff----", self.STATE_ON) assert entity.color_temp == 128 async def test_turn_on_color_temp_full_warm_for_color_mode_5(self, aioclient_mock): self.DEVICE_EXTENDED_INFO = self.DEVICE_EXTENDED_INFO_COLORMODE_5 self.STATE_DEFAULT["rgbw"]["colorMode"] = 5 await self.allow_get_info(aioclient_mock) self.STATE_DEFAULT["colorMode"] = 5 entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) async def turn_on(): await entity.async_turn_on(color_temp=1) self.STATE_ON = jmerge(self.STATE_ON, self.patch_state("02ffffff", "02ffffff")) await self.allow_set_color(turn_on, aioclient_mock, "02fa----", self.STATE_ON) assert entity.color_temp == 1 async def test_turn_on_color_temp_full_cold_for_color_mode_5(self, aioclient_mock): self.DEVICE_EXTENDED_INFO = self.DEVICE_EXTENDED_INFO_COLORMODE_5 self.STATE_DEFAULT["rgbw"]["colorMode"] = 5 await self.allow_get_info(aioclient_mock) self.STATE_DEFAULT["colorMode"] = 5 entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) async def turn_on(): await entity.async_turn_on(color_temp=255) self.STATE_ON = jmerge(self.STATE_ON, self.patch_state("fa00ffff", "fa02ffff")) await self.allow_set_color(turn_on, aioclient_mock, "fa00----", self.STATE_ON) assert entity.color_temp == 255 async def test_sensible_on_value_for_color_mode_6(self, aioclient_mock): self.DEVICE_EXTENDED_INFO = self.DEVICE_EXTENDED_INFO_COLORMODE_6 self.STATE_DEFAULT["rgbw"]["colorMode"] = 6 await self.allow_get_info(aioclient_mock) self.STATE_DEFAULT["colorMode"] = 6 entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) async def turn_on(): await entity.async_turn_on(color_temp=255) self.STATE_ON = jmerge(self.STATE_ON, self.patch_state("fa00ffff", "fa02ffff")) await self.allow_set_color(turn_on, aioclient_mock, "fa00----", self.STATE_ON) assert entity.color_temp == 255 async def test_sensible_on_value_for_color_mode_7(self, aioclient_mock): self.DEVICE_EXTENDED_INFO = self.DEVICE_EXTENDED_INFO_COLORMODE_7 self.STATE_DEFAULT["rgbw"]["colorMode"] = 7 self.STATE_DEFAULT = jmerge( self.STATE_DEFAULT, self.patch_state("fcfffcff00", "fcfffcff00") ) await self.allow_get_info(aioclient_mock) self.STATE_DEFAULT["colorMode"] = 7 entity = await self.updated(aioclient_mock, self.STATE_DEFAULT) async def turn_on(): await entity.async_turn_on(rgbww_color=(0, 0, 0, 120, 214)) self.STATE_ON = jmerge( self.STATE_ON, self.patch_state("000000d678", "000000d678") ) await self.allow_set_color(turn_on, aioclient_mock, "000000d678", self.STATE_ON) assert entity.rgbww_color == (0, 0, 0, 120, 214) async def test_unspecified_version(self, aioclient_mock): """ As default_api_level implemented for this class of devices, test method shall be omitted. """ pass def test_unit_light_evaluate_brightness_from_rgb(): tested_ob = Light.evaluate_brightness_from_rgb(iterable=(140, 230)) assert tested_ob == 230 blebox-blebox_uniapi-16587f1/tests/test_light_unit.py000066400000000000000000000105311466110602600230110ustar00rootroot00000000000000from unittest.mock import Mock from blebox_uniapi.box_types import BOX_TYPE_CONF import pytest from blebox_uniapi.light import Light from blebox_uniapi.box import Box @pytest.fixture def product(): return Mock(spec=Box) @pytest.fixture def dimmer_box(product): product.type = "dimmerBox" many = Light.many_from_config( product, BOX_TYPE_CONF["dimmerBox"][20170829]["lights"], extended_state={} ) assert len(many) == 1 return many[0] @pytest.fixture def w_light_box_rgbww(product): product.type = "wLightBox" extended_state = { "rgbw": { "colorMode": 7, "effectID": 0, "desiredColor": "fcfffcff00", "currentColor": "fcfffcff00", "lastOnColor": "fcfffcff00", "durationsMs": {"colorFade": 1000, "effectFade": 1000, "effectStep": 1000}, "favColors": {"0": "ff", "1": "00", "2": "c0", "3": "40", "4": "00"}, "effectsNames": {"0": "NONE", "1": "FADE", "2": "Stroboskop", "3": "BELL"}, } } many = Light.many_from_config( product, BOX_TYPE_CONF["wLightBox"][20200229]["lights"], extended_state=extended_state, ) return many[0] @pytest.mark.parametrize("input_on", range(1, 255)) async def test_dimmer_box_async_on_with_int(dimmer_box: Light, product: Mock, input_on): await dimmer_box.async_on(input_on) product.async_api_command.assert_called_with("set", input_on) async def test_dimmer_box_async_on_zero(dimmer_box: Light, product: Mock): with pytest.raises(ValueError): await dimmer_box.async_on(0) async def test_dimmer_box_async_on_with_hex(dimmer_box: Light, product: Mock): await dimmer_box.async_on("ff") product.async_api_command.assert_called_with("set", 255) @pytest.mark.parametrize("input_on", [255, "ff"]) async def test_dimmer_box_async_on(dimmer_box: Light, product: Mock, input_on): await dimmer_box.async_on(input_on) product.async_api_command.assert_called_with("set", 255) async def test_dimmer_box_async_off(dimmer_box: Light, product: Mock): await dimmer_box.async_off() product.async_api_command.assert_called_with("set", 0) @pytest.mark.parametrize( "io_params", [("ff", [255]), ("ffff", [255, 255]), ("ff14cdfe6a", [255, 20, 205, 254, 106])], ) def test_dimmer_box_rgb_hex_to_rgb_list(dimmer_box: Light, io_params): assert dimmer_box.rgb_hex_to_rgb_list(io_params[0]) == io_params[1] @pytest.mark.parametrize( "io_params", [ (["ff"], [255]), (["ff", "ff"], [255, 255]), (["ff", "14", "cd", "fe", "6a"], [255, 20, 205, 254, 106]), ], ) def test_dimmer_box_rgb_list_to_rgb_hex_list(dimmer_box: Light, io_params): assert dimmer_box.rgb_list_to_rgb_hex_list(io_params[1]) == io_params[0] @pytest.mark.parametrize( "io_params", [ ([255], [255]), ([255, 255], [255, 255]), ([120, 20, 205, 96, 106], [149, 25, 255, 119, 132]), ], ) def test_dimmer_box_normalise_elements_of_rgb(dimmer_box: Light, io_params): assert dimmer_box.normalise_elements_of_rgb(io_params[0]) == io_params[1] def test_dimmer_box_normalise_elements_of_rgb_invalid_values(dimmer_box: Light): with pytest.raises(ValueError): dimmer_box.normalise_elements_of_rgb([-10]) with pytest.raises(ValueError): dimmer_box.normalise_elements_of_rgb([256]) with pytest.raises(ValueError): dimmer_box.normalise_elements_of_rgb([-1, 0]) @pytest.mark.parametrize( "io_params", [([145, 135, 90], 145), ([145, 135, 240], 240), ([255], 255)] ) def test_dimmer_box_evaluate_brightness_from_rgb(dimmer_box: Light, io_params): assert dimmer_box.evaluate_brightness_from_rgb(io_params[0]) == io_params[1] def test_dimmer_box_apply_brightness_zero(dimmer_box: Light): assert dimmer_box.apply_brightness(10, 0) == [0] def test_dimmer_box_evaluate_brightness_from_rgb_out_of_range(dimmer_box: Light): with pytest.raises(ValueError): dimmer_box.evaluate_brightness_from_rgb([257, 135, 90]) with pytest.raises(ValueError): dimmer_box.evaluate_brightness_from_rgb([145, 135, -1]) with pytest.raises(ValueError): dimmer_box.evaluate_brightness_from_rgb([145, -10, 900]) def test_light_sensible_on_value_last_is_zero(w_light_box_rgbww: Light): assert len(w_light_box_rgbww.effect_list) == 4 assert w_light_box_rgbww.effect == "NONE" blebox-blebox_uniapi-16587f1/tests/test_sensor.py000066400000000000000000000273321466110602600221630ustar00rootroot00000000000000"""Blebox sensors tests.""" import json import pytest from blebox_uniapi.box_types import get_latest_api_level from .conftest import CommonEntity, DefaultBoxTest, future_date, jmerge TEMP_CELSIUS = "celsius" DEVICE_CLASS_TEMPERATURE = "temperature class" class BleBoxSensorEntity(CommonEntity): """Home Assistant representation style of a BleBox sensor feature.""" @property def state(self): """Return the temperature.""" return self._feature.current @property def unit_of_measurement(self): """Return the temperature unit.""" return {"celsius": TEMP_CELSIUS}[self._feature.unit] @property def device_class(self): """Return the device class.""" types = {"temperature": DEVICE_CLASS_TEMPERATURE} return types[self._feature.device_class] @property def native_value(self): """Return the state.""" return self._feature.native_value class TestTempSensor(DefaultBoxTest): """Tests for sensors representing BleBox tempSensor.""" DEVCLASS = "sensors" ENTITY_CLASS = BleBoxSensorEntity DEV_INFO_PATH = "api/tempsensor/state" DEVICE_INFO = json.loads( """ { "device": { "deviceName": "My tempSensor", "type": "tempSensor", "fv": "0.176", "hv": "0.6", "apiLevel": "20180604", "id": "1afe34db9437", "ip": "172.100.123.4" } } """ ) def patch_version(apiLevel): """Generate a patch for a JSON state fixture.""" return f""" {{ "device": {{ "apiLevel": {apiLevel} }} }} """ DEVICE_INFO_FUTURE = jmerge(DEVICE_INFO, patch_version(future_date())) DEVICE_INFO_LATEST = jmerge( DEVICE_INFO, patch_version(get_latest_api_level("tempSensor")) ) DEVICE_INFO_UNSUPPORTED = jmerge(DEVICE_INFO, patch_version(20180603)) DEVICE_INFO_UNSPECIFIED_API = json.loads( """ { "device": { "deviceName": "My tempSensor", "type": "tempSensor", "fv": "0.176", "hv": "0.6", "id": "1afe34db9437", "ip": "172.100.123.4" } } """ ) STATE_DEFAULT = json.loads( """ { "tempSensor": { "sensors": [ { "type": "temperature", "id": 0, "value": 2518, "trend": 3, "state": 2, "elapsedTimeS": 0 } ] } } """ ) DEVICE_EXTENDED_INFO_RAIN_AIR_TEMP = {} DEVICE_MULTISENSOR = json.loads( """ { "multiSensor": { "sensors": [ { "type": "temperature", "id": 0, "name": "Temperature outside", "iconSet": 2, "value": 3606, "trend": 2, "state": 2, "elapsedTimeS": -1 }, { "type": "temperature", "id": 1, "name": "Temperature inside fence", "iconSet": 3, "value": 4712, "trend": 3, "state": 2, "elapsedTimeS": -1 }, { "type": "temperature", "id": 2, "name": "Underground -10 cm", "iconSet": 2, "value": 2050, "trend": 3, "state": 2, "elapsedTimeS": -1 } ] } }""" ) DEVICE_MULTISENSOR_UPDATE = json.loads( """ { "multiSensor": { "sensors": [ { "type": "temperature", "id": 0, "name": "Temperature outside", "iconSet": 2, "value": 123, "trend": 2, "state": 2, "elapsedTimeS": -1 }, { "type": "temperature", "id": 1, "name": "Temperature inside fence", "iconSet": 3, "value": 213, "trend": 3, "state": 2, "elapsedTimeS": -1 }, { "type": "temperature", "id": 2, "name": "Underground -10 cm", "iconSet": 2, "value": 312, "trend": 3, "state": 2, "elapsedTimeS": -1 } ] } }""" ) DEVICE_EXTENDED_INFO = DEVICE_MULTISENSOR DEVICE_EXTENDED_INFO_PATH = "/state/extended" DEVICE_INFO_MULTISENSOR = json.loads( """ { "device": { "deviceName": "Backyard - tempSensor PRO", "type": "multiSensor", "product": "tempSensorPro", "hv": "tSP-1.0", "fv": "0.1044", "universe": 0, "apiLevel": "20210413", "categories": [ 4, 7 ], "id": "42f5200ca102", "ip": "172.0.0.1", "availableFv": null } } """ ) async def test_init(self, aioclient_mock): """Test sensor default state.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_class == DEVICE_CLASS_TEMPERATURE assert entity.unique_id == "BleBox-tempSensor-1afe34db9437-0.temperature" assert entity.unit_of_measurement == TEMP_CELSIUS assert entity.state is None # assert entity.outdated is False async def test_device_info(self, aioclient_mock): await self.allow_get_info(aioclient_mock, self.DEVICE_INFO) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My tempSensor" assert entity.device_info["mac"] == "1afe34db9437" assert entity.device_info["manufacturer"] == "BleBox" assert entity.device_info["model"] == "tempSensor" assert entity.device_info["sw_version"] == "0.176" async def test_update(self, aioclient_mock): """Test sensor update.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[0] self.allow_get_state(aioclient_mock, self.STATE_DEFAULT) await entity.async_update() # TODO: include product name? assert entity.name == "My tempSensor (tempSensor#0.temperature)" assert entity.state == 25.2 async def test_sensor_factory(self, aioclient_mock): """Test sensor factory method class.""" self.DEVICE_EXTENDED_INFO = self.DEVICE_MULTISENSOR self.STATE_DEFAULT = self.DEVICE_MULTISENSOR self.DEVICE_INFO = self.DEVICE_INFO_MULTISENSOR await self.allow_get_info(aioclient_mock) entity = await self.async_entities(aioclient_mock) assert len(entity) == 3 async def test_multisensor_update(self, aioclient_mock): self.DEV_INFO_PATH = "state" self.DEVICE_EXTENDED_INFO = self.DEVICE_MULTISENSOR self.STATE_DEFAULT = self.DEVICE_MULTISENSOR self.DEVICE_INFO = self.DEVICE_INFO_MULTISENSOR # await self.allow_get_state(aioclient_mock) entity = await self.updated(aioclient_mock, self.DEVICE_MULTISENSOR_UPDATE) assert entity.native_value == 1.2 class TestAirSensor(DefaultBoxTest): """Tests for sensors representing BleBox airSensor.""" DEV_INFO_PATH = "api/air/state" DEVCLASS = "sensors" ENTITY_CLASS = BleBoxSensorEntity DEVICE_INFO = json.loads( """ { "deviceName": "My air 1", "type": "airSensor", "fv": "0.973", "hv": "0.6", "apiLevel": "20180403", "id": "1afe34db9437", "ip": "192.168.1.11" } """ ) def patch_version(apiLevel): """Generate a patch for a JSON state fixture.""" return f""" {{ "apiLevel": {apiLevel} }} """ DEVICE_INFO_FUTURE = jmerge(DEVICE_INFO, patch_version(future_date())) DEVICE_INFO_LATEST = jmerge( DEVICE_INFO, patch_version(get_latest_api_level("airSensor")) ) DEVICE_INFO_UNSUPPORTED = jmerge(DEVICE_INFO, patch_version(20180402)) DEVICE_INFO_UNSPECIFIED_API = json.loads( """ { "deviceName": "My air 1", "type": "airSensor", "fv": "0.973", "hv": "0.6", "id": "1afe34db9437", "ip": "192.168.1.11" } """ ) STATE_DEFAULT = json.loads( """ { "air": { "sensors": [ { "type": "pm1", "value": 49, "trend": 3, "state": 0, "qualityLevel": 0, "elaspedTimeS": -1 }, { "type": "pm2.5", "value": 222, "trend": 1, "state": 0, "qualityLevel": 4, "elaspedTimeS": -1 }, { "type": "pm10", "value": 333, "trend": 0, "state": 0, "qualityLevel": 6, "elaspedTimeS": -1 } ] } } """ ) # DEVICE_EXTENDED_INFO # DEVICE_EXTENDED_INFO_PATH async def test_init(self, aioclient_mock): """Test air quality sensor default state.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[0] assert entity.name == "My air 1 (airSensor#pm1)" assert entity.unique_id == "BleBox-airSensor-1afe34db9437-pm1" assert entity.native_value is None async def test_device_info(self, aioclient_mock): await self.allow_get_info(aioclient_mock, self.DEVICE_INFO) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My air 1" assert entity.device_info["mac"] == "1afe34db9437" assert entity.device_info["manufacturer"] == "BleBox" assert entity.device_info["model"] == "airSensor" assert entity.device_info["sw_version"] == "0.973" @pytest.mark.parametrize("io_param", [(0, 49), (1, 222), (2, 333)]) async def test_update(self, aioclient_mock, io_param): """Test air quality sensor state after update.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[io_param[0]] self.allow_get_state(aioclient_mock, self.STATE_DEFAULT) await entity.async_update() assert entity.native_value == io_param[1] # parametrised async def test_list_quantity(self, aioclient_mock): """Test air sensor init from config.""" await self.allow_get_info(aioclient_mock) entity = await self.async_entities(aioclient_mock) assert len(entity) == 3 blebox-blebox_uniapi-16587f1/tests/test_session.py000066400000000000000000000116311466110602600223300ustar00rootroot00000000000000"""Tests for `blebox_uniapi` package.""" import pytest import logging import aiohttp from unittest.mock import patch, Mock, AsyncMock from blebox_uniapi.session import ApiHost as Session from blebox_uniapi import error @pytest.fixture def mocked_client(): with patch("aiohttp.ClientSession", spec_set=True, autospec=True) as mocked_session: yield mocked_session.return_value @pytest.fixture def logger(): return Mock(spec_set=logging.Logger).return_value @pytest.fixture def client(): return Mock(spec_set=aiohttp.ClientSession) def valid_response(): response = Mock(spec_set=aiohttp.ClientResponse) response.status = 200 response.text = AsyncMock(return_value="foobar") response.json = AsyncMock(return_value=123) return response def timeout_error(connection, timeout): raise aiohttp.ServerTimeoutError def client_error(connection, timeout): raise aiohttp.ClientError("client err") def os_error(connection, timeout): raise aiohttp.ClientOSError("os error") def bad_http_response(spec_set=aiohttp.ClientResponse): response = Mock(spec_set=aiohttp.ClientResponse) response.status = 400 return response async def test_session_api_get(logger, client): client.get = AsyncMock(return_value=valid_response()) api_session = Session("127.0.0.4", "88", 2, client, None, logger) result = await api_session.async_api_get("/api/foo") client.get.assert_called_once_with("http://127.0.0.4:88/api/foo", timeout=2) assert result == 123 async def test_session_default_client_created(mocked_client, logger): mocked_client.get = AsyncMock(return_value=valid_response()) api_session = Session("127.0.0.4", "88", 2, None, None, logger) result = await api_session.async_api_get("/api/foo") mocked_client.get.assert_called_once_with("http://127.0.0.4:88/api/foo", timeout=2) assert result == 123 async def test_session_default_timeout_used(mocked_client, logger): mocked_client.get = AsyncMock(return_value=valid_response()) api_session = Session("127.0.0.4", "88", None, None, None, logger) await api_session.async_api_get("/api/foo") expected_timeout = aiohttp.ClientTimeout( total=None, connect=None, sock_read=5, sock_connect=5 ) mocked_client.get.assert_called_once_with( "http://127.0.0.4:88/api/foo", timeout=expected_timeout ) async def test_session_api_get_timeout(logger, client): client.get = AsyncMock(side_effect=timeout_error) api_session = Session("127.0.0.4", "88", 2, client, None, logger) with pytest.raises(error.TimeoutError): await api_session.async_api_get("/api/foo") async def test_session_api_post_timeout(logger, client): def post_timeout_error(connection, **kwargs): timeout_error(connection, timeout=kwargs.get("timeout")) client.post = AsyncMock(side_effect=post_timeout_error) api_session = Session("127.0.0.4", "88", 2, client, None, logger) with pytest.raises(error.TimeoutError): await api_session.async_api_post("/api/foo", {}) async def test_session_api_get_client_error(logger, client): client.get = AsyncMock(side_effect=client_error) api_session = Session("127.0.0.4", "88", 2, client, None, logger) with pytest.raises( error.ClientError, match=r"API request http://127\.0\.0\.4:88/api/foo failed: client err", ): await api_session.async_api_get("/api/foo") async def test_session_always_show_address_details(logger, client): client.get = AsyncMock(side_effect=os_error) api_session = Session("127.0.0.4", "88", 2, client, None, logger) with pytest.raises( error.ConnectionError, match=r"Failed to connect to 127\.0\.0\.4:88: os error" ): await api_session.async_api_get("/api/foo") async def test_session_api_post_client_error(logger, client): def post_client_error(connection, **kwargs): client_error(connection, timeout=kwargs.get("timeout")) client.post = AsyncMock(side_effect=post_client_error) api_session = Session("127.0.0.4", "88", 2, client, None, logger) with pytest.raises(error.ClientError): await api_session.async_api_post("/api/foo", {}) async def test_session_api_get_http_error(logger, client): client.get = AsyncMock(return_value=bad_http_response()) api_session = Session("127.0.0.4", "88", 2, client, None, logger) with pytest.raises(error.HttpError): await api_session.async_api_get("/api/foo") async def test_session_api_post_http_error(logger, client): client.post = AsyncMock(return_value=bad_http_response()) api_session = Session("127.0.0.4", "88", 2, client, None, logger) with pytest.raises(error.HttpError): await api_session.async_api_post("/api/foo", {}) async def test_session_provides_a_logger(logger, client): api_session = Session("127.0.0.4", "88", 2, client, None, logger) api_session.logger.debug("foobar") logger.debug.assert_called_once_with("foobar") blebox-blebox_uniapi-16587f1/tests/test_switch.py000066400000000000000000000346461466110602600221610ustar00rootroot00000000000000"""Blebox switch tests.""" from blebox_uniapi.box_types import get_latest_api_level from .conftest import CommonEntity, DefaultBoxTest, future_date DEVICE_CLASS_SWITCH = "switch" class BleBoxSwitchEntity(CommonEntity): @property def device_class(self): types = {"relay": DEVICE_CLASS_SWITCH} return types[self._feature.device_class] @property def is_on(self): return self._feature.is_on async def async_turn_on(self, **kwargs): return await self._feature.async_turn_on() async def async_turn_off(self, **kwargs): return await self._feature.async_turn_off() class TestSwitchBox0(DefaultBoxTest): """Tests for BleBox switchBox.""" DEVCLASS = "switches" ENTITY_CLASS = BleBoxSwitchEntity DEV_INFO_PATH = "api/relay/state" DEVICE_INFO = { "device": { "deviceName": "My switchBox", "type": "switchBox", "fv": "0.247", "hv": "0.2", "id": "1afe34e750b8", "ip": "192.168.1.239", "apiLevel": "20180604", } } DEVICE_INFO_FUTURE = { "device": { "deviceName": "My switchBox", "type": "switchBox", "fv": "0.247", "hv": "0.2", "id": "1afe34e750b8", "ip": "192.168.1.239", "apiLevel": future_date(), } } DEVICE_INFO_LATEST = { "device": { "deviceName": "My switchBox", "type": "switchBox", "fv": "0.247", "hv": "0.2", "id": "1afe34e750b8", "ip": "192.168.1.239", "apiLevel": get_latest_api_level("switchBox"), } } DEVICE_INFO_UNSUPPORTED = { "device": { "deviceName": "My switchBox", "type": "switchBox", "fv": "0.247", "hv": "0.2", "id": "1afe34e750b8", "ip": "192.168.1.239", "apiLevel": 20180603, } } DEVICE_INFO_UNSPECIFIED_API = { "device": { "deviceName": "My switchBox", "type": "switchBox", "fv": "0.247", "hv": "0.2", "id": "1afe34e750b8", "ip": "192.168.1.239", } } STATE_OFF = [{"relay": 0, "state": 0, "stateAfterRestart": 0}] STATE_ON = [{"relay": 0, "state": 1, "stateAfterRestart": 0}] STATE_DEFAULT = STATE_OFF async def test_init(self, aioclient_mock): """Test switch default state.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[0] assert entity.name == "My switchBox (switchBox#0.relay)" assert entity.unique_id == "BleBox-switchBox-1afe34e750b8-0.relay" assert entity.device_class == DEVICE_CLASS_SWITCH assert entity.is_on is None async def test_device_info(self, aioclient_mock): await self.allow_get_info(aioclient_mock, self.DEVICE_INFO) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My switchBox" assert entity.device_info["mac"] == "1afe34e750b8" assert entity.device_info["manufacturer"] == "BleBox" assert entity.device_info["model"] == "switchBox" assert entity.device_info["sw_version"] == "0.247" async def test_update_when_off(self, aioclient_mock): """Test switch updating when off.""" entity = await self.updated(aioclient_mock, self.STATE_OFF) assert entity.is_on is False async def test_update_when_on(self, aioclient_mock): """Test switch updating when on.""" entity = await self.updated(aioclient_mock, self.STATE_ON) assert entity.is_on is True async def test_on(self, aioclient_mock): """Test turning switch on.""" entity = await self.updated(aioclient_mock, self.STATE_OFF) self.allow_get(aioclient_mock, "/s/1", self.STATE_ON) await entity.async_turn_on() assert entity.is_on is True async def test_off(self, aioclient_mock): """Test turning switch off.""" entity = await self.updated(aioclient_mock, self.STATE_ON) self.allow_get(aioclient_mock, "/s/0", self.STATE_OFF) await entity.async_turn_off() assert entity.is_on is False class TestSwitchBox(DefaultBoxTest): """Tests for BleBox switchBox.""" DEVCLASS = "switches" ENTITY_CLASS = BleBoxSwitchEntity DEV_INFO_PATH = "api/relay/extended/state" DEVICE_INFO = { "device": { "deviceName": "My switchBox", "type": "switchBox", "fv": "0.247", "hv": "0.2", "id": "1afe34e750b8", "ip": "192.168.1.239", "apiLevel": "20190808", } } def patch_version(apiLevel): """Generate a patch for a JSON state fixture.""" return f""" {{ "device": {{ "apiLevel": {apiLevel} }} }} """ DEVICE_INFO_FUTURE = { "device": { "deviceName": "My switchBox", "type": "switchBox", "fv": "0.247", "hv": "0.2", "id": "1afe34e750b8", "ip": "192.168.1.239", "apiLevel": future_date(), } } DEVICE_INFO_LATEST = { "device": { "deviceName": "My switchBox", "type": "switchBox", "fv": "0.247", "hv": "0.2", "id": "1afe34e750b8", "ip": "192.168.1.239", "apiLevel": get_latest_api_level("switchBox"), } } DEVICE_INFO_UNSUPPORTED = { "device": { "deviceName": "My switchBox", "type": "switchBox", "fv": "0.247", "hv": "0.2", "id": "1afe34e750b8", "ip": "192.168.1.239", # since below it 20180808 it switches to switchBox0 "apiLevel": 20180604 - 1, } } DEVICE_INFO_UNSPECIFIED_API = { "device": { "deviceName": "My switchBox", "type": "switchBox", "fv": "0.247", "hv": "0.2", "id": "1afe34e750b8", "ip": "192.168.1.239", } } STATE_OFF = {"relays": [{"relay": 0, "state": 0, "stateAfterRestart": 0}]} STATE_ON = {"relays": [{"relay": 0, "state": 1, "stateAfterRestart": 0}]} STATE_DEFAULT = STATE_OFF async def test_init(self, aioclient_mock): """Test switch default state.""" await self.allow_get_info(aioclient_mock) entity = (await self.async_entities(aioclient_mock))[0] assert entity.name == "My switchBox (switchBox#0.relay)" assert entity.unique_id == "BleBox-switchBox-1afe34e750b8-0.relay" assert entity.device_class == DEVICE_CLASS_SWITCH assert entity.is_on is None async def test_device_info(self, aioclient_mock): await self.allow_get_info(aioclient_mock, self.DEVICE_INFO) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My switchBox" assert entity.device_info["mac"] == "1afe34e750b8" assert entity.device_info["manufacturer"] == "BleBox" assert entity.device_info["model"] == "switchBox" assert entity.device_info["sw_version"] == "0.247" async def test_update_when_off(self, aioclient_mock): """Test switch updating when off.""" entity = await self.updated(aioclient_mock, self.STATE_OFF) assert entity.is_on is False async def test_update_when_on(self, aioclient_mock): """Test switch updating when on.""" entity = await self.updated(aioclient_mock, self.STATE_ON) assert entity.is_on is True async def test_on(self, aioclient_mock): """Test turning switch on.""" entity = await self.updated(aioclient_mock, self.STATE_OFF) self.allow_get(aioclient_mock, "/s/1", self.STATE_ON) await entity.async_turn_on() assert entity.is_on is True async def test_off(self, aioclient_mock): """Test turning switch off.""" entity = await self.updated(aioclient_mock, self.STATE_ON) self.allow_get(aioclient_mock, "/s/0", self.STATE_OFF) await entity.async_turn_off() assert entity.is_on is False class TestSwitchBoxD(DefaultBoxTest): """Tests for BleBox switchBoxD.""" DEVCLASS = "switches" ENTITY_CLASS = BleBoxSwitchEntity DEV_INFO_PATH = "state/extended" DEVICE_INFO = { "device": { "deviceName": "My switchBoxD", "type": "switchBoxD", "fv": "0.200", "hv": "0.7", "id": "1afe34e750b8", "apiLevel": "20200831", } } DEVICE_INFO_FUTURE = { "device": { "deviceName": "My switchBoxD", "type": "switchBoxD", "fv": "0.200", "hv": "0.7", "id": "1afe34e750b8", "apiLevel": future_date(), } } DEVICE_INFO_LATEST = { "device": { "deviceName": "My switchBoxD", "type": "switchBoxD", "fv": "0.200", "hv": "0.7", "id": "1afe34e750b8", "apiLevel": get_latest_api_level("switchBoxD"), } } DEVICE_INFO_UNSUPPORTED = { "device": { "deviceName": "My switchBoxD", "type": "switchBoxD", "fv": "0.200", "hv": "0.7", "id": "1afe34e750b8", "apiLevel": 20190807, } } DEVICE_INFO_UNSPECIFIED_API = { "device": { "deviceName": "My switchBoxD", "type": "switchBoxD", "fv": "0.200", "hv": "0.7", "id": "1afe34e750b8", } } STATE_BOTH_OFF = { "relays": [ {"relay": 0, "state": 0, "stateAfterRestart": 0, "name": "output 1"}, {"relay": 1, "state": 0, "stateAfterRestart": 0, "name": "output 2"}, ] } STATE_FIRST_ON = { "relays": [ {"relay": 0, "state": 1, "stateAfterRestart": 0, "name": "output 1"}, {"relay": 1, "state": 0, "stateAfterRestart": 0, "name": "output 2"}, ] } STATE_SECOND_ON = { "relays": [ {"relay": 0, "state": 0, "stateAfterRestart": 0, "name": "output 1"}, {"relay": 1, "state": 1, "stateAfterRestart": 0, "name": "output 2"}, ] } STATE_BOTH_ON = { "relays": [ {"relay": 0, "state": 1, "stateAfterRestart": 0, "name": "output 1"}, {"relay": 1, "state": 1, "stateAfterRestart": 0, "name": "output 2"}, ] } STATE_DEFAULT = STATE_BOTH_OFF async def test_init(self, aioclient_mock): """Test switch default state.""" await self.allow_get_info(aioclient_mock) entities = await self.async_entities(aioclient_mock) entity = entities[0] # TODO: include output names? assert entity.name == "My switchBoxD (switchBoxD#0.relay)" assert entity.unique_id == "BleBox-switchBoxD-1afe34e750b8-0.relay" assert entity.device_class == DEVICE_CLASS_SWITCH assert entity.is_on is None entity = entities[1] assert entity.name == "My switchBoxD (switchBoxD#1.relay)" assert entity.unique_id == "BleBox-switchBoxD-1afe34e750b8-1.relay" assert entity.device_class == DEVICE_CLASS_SWITCH assert entity.is_on is None async def test_device_info(self, aioclient_mock): await self.allow_get_info(aioclient_mock, self.DEVICE_INFO) entity = (await self.async_entities(aioclient_mock))[0] assert entity.device_info["name"] == "My switchBoxD" assert entity.device_info["mac"] == "1afe34e750b8" assert entity.device_info["manufacturer"] == "BleBox" assert entity.device_info["model"] == "switchBoxD" assert entity.device_info["sw_version"] == "0.200" async def test_update_when_off(self, aioclient_mock): """Test switch updating when off.""" await self.allow_get_info(aioclient_mock) entities = await self.async_entities(aioclient_mock) self.allow_get_state(aioclient_mock, self.STATE_BOTH_OFF) # updating any one is fine await entities[0].async_update() assert entities[0].is_on is False assert entities[1].is_on is False async def test_update_when_second_off(self, aioclient_mock): """Test switch updating when off.""" await self.allow_get_info(aioclient_mock) entities = await self.async_entities(aioclient_mock) self.allow_get_state(aioclient_mock, self.STATE_FIRST_ON) # updating any one is fine await entities[0].async_update() assert entities[0].is_on is True assert entities[1].is_on is False async def test_first_on(self, aioclient_mock): """Test turning switch on.""" await self.allow_get_info(aioclient_mock) entities = await self.async_entities(aioclient_mock) self.allow_get(aioclient_mock, "/s/0/1", self.STATE_FIRST_ON) await entities[0].async_turn_on() assert entities[0].is_on is True assert entities[1].is_on is False async def test_second_on(self, aioclient_mock): """Test turning switch on.""" await self.allow_get_info(aioclient_mock) entities = await self.async_entities(aioclient_mock) self.allow_get(aioclient_mock, "/s/1/1", self.STATE_SECOND_ON) await entities[1].async_turn_on() assert entities[0].is_on is False assert entities[1].is_on is True async def test_first_off(self, aioclient_mock): """Test turning switch on.""" await self.allow_get_info(aioclient_mock) entities = await self.async_entities(aioclient_mock) self.allow_get_state(aioclient_mock, self.STATE_BOTH_ON) await entities[0].async_update() self.allow_get(aioclient_mock, "/s/0/0", self.STATE_SECOND_ON) await entities[0].async_turn_off() assert entities[0].is_on is False assert entities[1].is_on is True async def test_second_off(self, aioclient_mock): """Test turning switch on.""" await self.allow_get_info(aioclient_mock) entities = await self.async_entities(aioclient_mock) self.allow_get_state(aioclient_mock, self.STATE_BOTH_ON) await entities[0].async_update() self.allow_get(aioclient_mock, "/s/1/0", self.STATE_FIRST_ON) await entities[1].async_turn_off() assert entities[0].is_on is True assert entities[1].is_on is False blebox-blebox_uniapi-16587f1/tests/test_unknown.py000066400000000000000000000036151466110602600223470ustar00rootroot00000000000000import pytest from blebox_uniapi.session import ApiHost from blebox_uniapi.box import Box from blebox_uniapi import error from .conftest import json_get_expect @pytest.fixture def data(): return { "id": "abcd1234ef", "type": "unknownBox", "deviceName": "foobar", "fv": "1.23", "hv": "4.56", "apiLevel": "20180403", } class TestUnknownDevice: async def test_unknown_product(self, aioclient_mock, data): host = "172.1.2.3" with pytest.raises(error.UnsupportedBoxResponse, match=r"unknownBox"): full_data = {"device": data} json_get_expect( aioclient_mock, f"http://{host}:80/api/device/state", json=full_data ) port = 80 timeout = 2 api_host = ApiHost(host, port, timeout, aioclient_mock, None, None) await Box.async_from_host(api_host) async def test_unknown_product_without_device_section(self, aioclient_mock, data): host = "172.1.2.3" with pytest.raises(error.UnsupportedBoxResponse, match=r"unknownBox"): json_get_expect( aioclient_mock, f"http://{host}:80/api/device/state", json=data ) port = 80 timeout = 2 api_host = ApiHost(host, port, timeout, aioclient_mock, None, None) await Box.async_from_host(api_host) async def test_unknown_product_without_device_and_type(self, aioclient_mock, data): host = "172.1.2.3" with pytest.raises(error.UnsupportedBoxResponse, match=r"has no type"): del data["type"] json_get_expect( aioclient_mock, f"http://{host}:80/api/device/state", json=data ) port = 80 timeout = 2 api_host = ApiHost(host, port, timeout, aioclient_mock, None, None) await Box.async_from_host(api_host) blebox-blebox_uniapi-16587f1/tox.ini000066400000000000000000000010041466110602600173760ustar00rootroot00000000000000[tox] envlist = py39, py310, flake8 [testenv:flake8] basepython = python deps = flake8 commands = flake8 blebox_uniapi tests [flake8] ignore = E501,E203,W503,E731 [testenv] setenv = PYTHONPATH = {toxinidir} deps = -r{toxinidir}/requirements_dev.txt ; If you want to make tox run the tests with the same versions, create a ; requirements.txt with the pinned versions and uncomment the following line: ; -r{toxinidir}/requirements.txt commands = pip install -U pip pytest --basetemp={envtmpdir}