├── tests ├── __init__.py ├── conftest.py ├── test_init.py └── common.py ├── examples ├── __init__.py ├── list_devices.py ├── garage_door.py ├── delete_door_code.py ├── set_door_code.py ├── show_lock_info.py ├── lock_all_doors_with_status.py └── device_listener.py ├── .gitignore ├── .flake8 ├── scripts ├── clean.sh ├── build_and_publish.sh ├── update_deps.sh ├── common.sh └── build.sh ├── pylintrc ├── .github └── workflows │ ├── publish.yml │ └── build.yml ├── setup.cfg ├── pyproject.toml ├── README.md ├── LICENSE ├── poetry.lock └── pyvera └── __init__.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Init package.""" 2 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | """Init package.""" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | **/__pycache__ 3 | .tox 4 | .coverage 5 | *.egg-info 6 | .venv 7 | venv 8 | dist 9 | build 10 | .eggs 11 | .idea 12 | .mypy_cache 13 | .pytest_cache 14 | coverage.xml 15 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = venv,.venv,.git,.tox,.eggs,docs,venv,bin,lib,deps,build 3 | # To work with Black 4 | max-line-length = 88 5 | # E501: line too long 6 | # W503: Line break occurred before a binary operator 7 | # E203: Whitespace before ':' 8 | # D202 No blank lines allowed after function docstring 9 | # W504 line break after binary operator 10 | ignore = 11 | E501 12 | W503 13 | E203 14 | D202 15 | W504 -------------------------------------------------------------------------------- /scripts/clean.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euf -o pipefail 3 | 4 | SELF_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 5 | cd "$SELF_DIR/.." 6 | 7 | if [[ `env | grep VIRTUAL_ENV` ]]; then 8 | echo "Error: deactivate your venv first." 9 | exit 1 10 | fi 11 | 12 | find . -regex '^.*\(__pycache__\|\.py[co]\)$' -delete 13 | rm .coverage .eggs .tox build dist withings*.egg-info .venv venv -rf 14 | 15 | echo "Clean complete." 16 | -------------------------------------------------------------------------------- /scripts/build_and_publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euf -o pipefail 3 | 4 | SELF_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 5 | PUBLISH_PASSWORD="$1" 6 | 7 | source "$SELF_DIR/common.sh" 8 | 9 | assertPython 10 | 11 | "$SELF_DIR/build.sh" 12 | 13 | echo 14 | echo "===Settting up venv===" 15 | enterVenv 16 | 17 | 18 | echo 19 | echo "===Publishing package===" 20 | poetry publish --username __token__ --password "$PUBLISH_PASSWORD" 21 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | jobs=4 3 | 4 | [MESSAGES CONTROL] 5 | # Reasons disabled: 6 | # format - handled by black 7 | # too-many-* - are not enforced for the sake of readability 8 | # too-few-* - same as too-many-* 9 | disable= 10 | format, 11 | too-many-arguments, 12 | too-few-public-methods, 13 | unsubscriptable-object 14 | 15 | [REPORTS] 16 | reports=no 17 | 18 | [TYPECHECK] 19 | # For attrs 20 | ignored-classes=responses 21 | 22 | [FORMAT] 23 | expected-line-ending-format=LF 24 | 25 | [EXCEPTIONS] 26 | overgeneral-exceptions=Exception 27 | -------------------------------------------------------------------------------- /scripts/update_deps.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euf -o pipefail 3 | 4 | SELF_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 5 | cd "$SELF_DIR/.." 6 | 7 | source "$SELF_DIR/common.sh" 8 | 9 | assertPython 10 | 11 | 12 | echo 13 | echo "===Settting up venv===" 14 | enterVenv 15 | 16 | 17 | echo 18 | echo "===Installing poetry===" 19 | pip install poetry 20 | 21 | 22 | echo 23 | echo "===Installing dependencies===" 24 | poetry install 25 | 26 | 27 | echo 28 | echo "===Updating poetry lock file===" 29 | poetry update --lock 30 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Set up Python 3.8 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: 3.8 16 | - name: Build and publish 17 | run: | 18 | ./scripts/build_and_publish.sh ${{ secrets.PYPI_PASSWORD }} 19 | env: 20 | CI: 1 21 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 4 10 | matrix: 11 | python-version: [3.8, 3.11] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Build 20 | run: | 21 | ./scripts/build.sh 22 | env: 23 | CI: 1 24 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 25 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Global pytest fixtures.""" 2 | from typing import Generator 3 | 4 | from _pytest.fixtures import FixtureRequest 5 | import pytest 6 | import responses 7 | 8 | from .common import VeraControllerData, VeraControllerFactory, new_vera_api_data 9 | 10 | 11 | @pytest.fixture(name="vera_controller_factory") 12 | def fixture_vera_controller_factory( 13 | request: FixtureRequest, 14 | ) -> Generator[VeraControllerFactory, None, None]: 15 | """Get a controller factory.""" 16 | with responses.RequestsMock() as rsps: 17 | yield VeraControllerFactory(request, rsps) 18 | 19 | 20 | @pytest.fixture(name="vera_controller_data") 21 | def fixture_vera_controller_data( 22 | vera_controller_factory: VeraControllerFactory, 23 | ) -> VeraControllerData: 24 | """Get mocked controller data.""" 25 | return vera_controller_factory.new_instance(new_vera_api_data()) 26 | -------------------------------------------------------------------------------- /scripts/common.sh: -------------------------------------------------------------------------------- 1 | VENV_DIR=".venv" 2 | PYTHON_BIN="python3" 3 | LINT_PATHS="./pyvera ./tests/ ./examples/" 4 | 5 | function assertPython() { 6 | if ! [[ $(which "$PYTHON_BIN") ]]; then 7 | echo "Error: '$PYTHON_BIN' is not in your path." 8 | exit 1 9 | fi 10 | } 11 | 12 | function enterVenv() { 13 | # Not sure why I couldn't use "if ! [[ `"$PYTHON_BIN" -c 'import venv'` ]]" below. It just never worked when venv was 14 | # present. 15 | VENV_NOT_INSTALLED=$("$PYTHON_BIN" -c 'import venv' 2>&1 | grep -ic ' No module named' || true) 16 | if [[ "$VENV_NOT_INSTALLED" -gt "0" ]]; then 17 | echo "Error: The $PYTHON_BIN 'venv' module is not installed." 18 | exit 1 19 | fi 20 | 21 | if ! [[ -e "$VENV_DIR" ]]; then 22 | echo "Creating venv." 23 | "$PYTHON_BIN" -m venv "$VENV_DIR" 24 | else 25 | echo Using existing venv. 26 | fi 27 | 28 | if ! [[ $(env | grep VIRTUAL_ENV) ]]; then 29 | echo "Entering venv." 30 | set +uf 31 | source "$VENV_DIR/bin/activate" 32 | set -uf 33 | else 34 | echo Already in venv. 35 | fi 36 | 37 | } -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = venv,.venv,.git,.tox,.eggs,docs,venv,bin,lib,deps,build 3 | # To work with Black 4 | max-line-length = 88 5 | # E501: line too long 6 | # W503: Line break occurred before a binary operator 7 | # E203: Whitespace before ':' 8 | # D202 No blank lines allowed after function docstring 9 | # W504 line break after binary operator 10 | ignore = 11 | E501, 12 | W503, 13 | E203, 14 | D202, 15 | W504 16 | 17 | [mypy] 18 | ignore_missing_imports = True 19 | follow_imports = normal 20 | follow_imports_for_stubs = True 21 | 22 | disallow_subclassing_any = True 23 | 24 | disallow_untyped_calls = True 25 | disallow_untyped_defs = True 26 | disallow_incomplete_defs = True 27 | check_untyped_defs = True 28 | 29 | no_implicit_optional = True 30 | 31 | warn_unused_ignores = True 32 | warn_return_any = True 33 | warn_unreachable = True 34 | 35 | implicit_reexport = True 36 | strict_equality = True 37 | 38 | [tool:pytest] 39 | testpaths = tests 40 | addopts = --capture no --cov ./pyvera --cov-report html:build/coverage_report --cov-report term --cov-report xml:build/coverage.xml 41 | 42 | [coverage:run] 43 | branch = True 44 | 45 | [coverage:report] 46 | fail_under = 74 47 | -------------------------------------------------------------------------------- /examples/list_devices.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Example script.""" 3 | 4 | # Parse Arguments 5 | # Import project path 6 | import argparse 7 | import os 8 | import sys 9 | 10 | # Import pyvera 11 | from pyvera import VeraController 12 | 13 | 14 | def main() -> None: 15 | """Run main code entrypoint.""" 16 | sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")) 17 | 18 | parser = argparse.ArgumentParser(description="list-devices") 19 | parser.add_argument( 20 | "-u", "--url", help="Vera URL, e.g. http://192.168.1.161:3480", required=True 21 | ) 22 | args = parser.parse_args() 23 | 24 | # Start the controller 25 | controller = VeraController(args.url) 26 | controller.start() 27 | 28 | try: 29 | # Get a list of all the devices on the vera controller 30 | all_devices = controller.get_devices() 31 | 32 | # Print the devices out 33 | for device in all_devices: 34 | print( 35 | "{} {} ({})".format( 36 | type(device).__name__, device.name, device.device_id 37 | ) 38 | ) 39 | 40 | finally: 41 | # Stop the subscription listening thread so we can quit 42 | controller.stop() 43 | 44 | 45 | if __name__ == "__main__": 46 | main() 47 | -------------------------------------------------------------------------------- /examples/garage_door.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Example script.""" 3 | 4 | # Parse Arguments 5 | # Import project path 6 | import argparse 7 | import os 8 | import sys 9 | 10 | # Import pyvera 11 | from pyvera import VeraController, VeraGarageDoor 12 | 13 | 14 | def main() -> None: 15 | """Run main code entrypoint.""" 16 | sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")) 17 | 18 | parser = argparse.ArgumentParser(description="list-devices") 19 | parser.add_argument( 20 | "-u", "--url", help="Vera URL, e.g. http://192.168.1.161:3480", required=True 21 | ) 22 | parser.add_argument("--close", help="Close garage door(s)", action="store_true") 23 | args = parser.parse_args() 24 | 25 | # Start the controller 26 | controller = VeraController(args.url) 27 | controller.start() 28 | 29 | try: 30 | # Get a list of all the devices on the vera controller 31 | all_devices = controller.get_devices() 32 | 33 | # Open/close all garage doors. 34 | for device in all_devices: 35 | if isinstance(device, VeraGarageDoor): 36 | if args.close: 37 | device.switch_off() 38 | else: 39 | device.switch_on() 40 | 41 | finally: 42 | # Stop the subscription listening thread so we can quit 43 | controller.stop() 44 | 45 | 46 | if __name__ == "__main__": 47 | main() 48 | -------------------------------------------------------------------------------- /examples/delete_door_code.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Example script.""" 3 | 4 | # Parse Arguments 5 | # Import project path 6 | import argparse 7 | import os 8 | import sys 9 | 10 | # Import pyvera 11 | from pyvera import VeraController, VeraLock 12 | 13 | 14 | def main() -> None: 15 | """Run main code entrypoint.""" 16 | sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")) 17 | 18 | parser = argparse.ArgumentParser(description="set-and-delete-door-code") 19 | parser.add_argument( 20 | "-u", "--url", help="Vera URL, e.g. http://192.168.1.161:3480;", required=True 21 | ) 22 | parser.add_argument("-n", "--name", help='Name eg: "John Doe"', required=True) 23 | parser.add_argument("-i", "--id", help='Device ID: "123"', required=True) 24 | args = parser.parse_args() 25 | 26 | # Start the controller 27 | controller = VeraController(args.url) 28 | controller.start() 29 | 30 | try: 31 | device = controller.get_device_by_id(int(args.id)) 32 | 33 | if isinstance(device, VeraLock): 34 | pins = device.get_pin_codes() 35 | found_slot = None 36 | for slot, name, _ in pins: 37 | if name == args.name: 38 | found_slot = slot 39 | if found_slot is None: 40 | print("No matching slot found\n") 41 | return 42 | result = device.clear_slot_pin(slot=int(found_slot)) 43 | if result.status_code == 200: 44 | print( 45 | "\nCommand succesfully sent to Lock \ 46 | \nWait for the lock to process the request" 47 | ) 48 | else: 49 | print("\nLock command " + result.text) 50 | 51 | finally: 52 | # Stop the subscription listening thread so we can quit 53 | controller.stop() 54 | 55 | 56 | if __name__ == "__main__": 57 | main() 58 | -------------------------------------------------------------------------------- /examples/set_door_code.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Example script.""" 3 | 4 | # Parse Arguments 5 | # Import project path 6 | import argparse 7 | import os 8 | import sys 9 | 10 | # Import pyvera 11 | from pyvera import VeraController, VeraLock 12 | 13 | 14 | def main() -> None: 15 | """Run main code entrypoint.""" 16 | sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")) 17 | 18 | parser = argparse.ArgumentParser(description="set-and-delete-door-code") 19 | parser.add_argument( 20 | "-u", "--url", help="Vera URL, e.g. http://192.168.1.161:3480;", required=True 21 | ) 22 | parser.add_argument("-n", "--name", help='Name eg: "John Doe"', required=True) 23 | parser.add_argument("-p", "--pin", help='Pin eg: "5678"', required=True) 24 | parser.add_argument("-i", "--id", help='Device ID: "123"', required=True) 25 | args = parser.parse_args() 26 | 27 | # Start the controller 28 | controller = VeraController(args.url) 29 | controller.start() 30 | 31 | try: 32 | device = controller.get_device_by_id(int(args.id)) 33 | 34 | if isinstance(device, VeraLock): 35 | # show exisiting door codes 36 | print("Existing door codes:\n {}".format(device.get_pin_codes())) 37 | 38 | # set a new door code 39 | result = device.set_new_pin(name=args.name, pin=args.pin) 40 | 41 | # printing the status code and error if any for debug logs 42 | # print("status:"+str(result.status_code), result.text) 43 | 44 | if result.status_code == 200: 45 | print( 46 | "\nCommand succesfully sent to Lock \ 47 | \nWait for the lock to process the request" 48 | ) 49 | else: 50 | print("\nLock command " + result.text) 51 | finally: 52 | # Stop the subscription listening thread so we can quit 53 | controller.stop() 54 | 55 | 56 | if __name__ == "__main__": 57 | main() 58 | -------------------------------------------------------------------------------- /examples/show_lock_info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Example script.""" 3 | 4 | # Parse Arguments 5 | # Import project path 6 | import argparse 7 | import os 8 | import sys 9 | 10 | # Import pyvera 11 | from pyvera import VeraController, VeraLock 12 | 13 | 14 | def main() -> None: 15 | """Run main code entrypoint.""" 16 | sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")) 17 | 18 | parser = argparse.ArgumentParser(description="show-lock-info") 19 | parser.add_argument( 20 | "-u", "--url", help="Vera URL, e.g. http://192.168.1.161:3480", required=True 21 | ) 22 | args = parser.parse_args() 23 | 24 | # Start the controller 25 | controller = VeraController(args.url) 26 | controller.start() 27 | 28 | try: 29 | # Get a list of all the devices on the vera controller 30 | all_devices = controller.get_devices() 31 | 32 | # Look over the list and find the lock devices 33 | for device in all_devices: 34 | if isinstance(device, VeraLock): 35 | print( 36 | "{} {} ({})".format( 37 | type(device).__name__, device.name, device.device_id 38 | ) 39 | ) 40 | print(" comm_failure: {}".format(device.comm_failure)) 41 | print(" room_id: {}".format(device.room_id)) 42 | print(" is_locked(): {}".format(device.is_locked())) 43 | print(" get_pin_failed(): {}".format(device.get_pin_failed())) 44 | print(" get_unauth_user(): {}".format(device.get_unauth_user())) 45 | print(" get_lock_failed(): {}".format(device.get_lock_failed())) 46 | print(" get_last_user(): {}".format(device.get_last_user())) 47 | print(" get_pin_codes(): {}".format(device.get_pin_codes())) 48 | 49 | finally: 50 | # Stop the subscription listening thread so we can quit 51 | controller.stop() 52 | 53 | 54 | if __name__ == "__main__": 55 | main() 56 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euf -o pipefail 3 | 4 | SELF_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 5 | cd "$SELF_DIR/.." 6 | 7 | source "$SELF_DIR/common.sh" 8 | 9 | assertPython 10 | 11 | 12 | echo 13 | echo "===Settting up venv===" 14 | enterVenv 15 | 16 | 17 | echo 18 | echo "===Installing poetry===" 19 | pip install poetry 20 | 21 | 22 | echo 23 | echo "===Installing dependencies===" 24 | poetry install 25 | 26 | echo 27 | echo "===Sorting imports===" 28 | ISORT_ARGS="--apply" 29 | if [[ "${CI:-}" = "1" ]]; then 30 | ISORT_ARGS="--check-only" 31 | fi 32 | 33 | isort $ISORT_ARGS 34 | 35 | 36 | echo 37 | echo "===Formatting code===" 38 | if [[ `which black` ]]; then 39 | BLACK_ARGS="" 40 | if [[ "${CI:-}" = "1" ]]; then 41 | BLACK_ARGS="--check" 42 | fi 43 | 44 | black $BLACK_ARGS . 45 | else 46 | echo "Warning: Skipping code formatting. You should use python >= 3.6." 47 | fi 48 | 49 | 50 | echo 51 | echo "===Lint with flake8===" 52 | flake8 53 | 54 | # Needs work to run cleanly with python 3.9 55 | # echo 56 | # echo "===Lint with mypy===" 57 | # mypy . 58 | 59 | 60 | echo 61 | echo "===Lint with pylint===" 62 | set +e +o pipefail 63 | pylint $LINT_PATHS 64 | pylint_exitcode=$? 65 | set -e -o pipefail 66 | 67 | if (( (pylint_exitcode & 0x1) != 0 )); then 68 | echo "=> Fatal" 69 | fi 70 | if (( (pylint_exitcode & 0x2) != 0 )); then 71 | echo "=> Error" 72 | fi 73 | if (( (pylint_exitcode & 0x4) != 0 )); then 74 | echo "=> Warning" 75 | fi 76 | if (( (pylint_exitcode & 0x8) != 0 )); then 77 | echo "=> Refactor" 78 | fi 79 | if (( (pylint_exitcode & 0x10) != 0 )); then 80 | echo "=> Convention" 81 | fi 82 | if (( (pylint_exitcode & 0x20) != 0 )); then 83 | echo "=> Usage" 84 | fi 85 | if (( (pylint_exitcode & 0x23) != 0 )); then 86 | echo "=> Fatal, Errors or Usage" 87 | exit 1 88 | fi 89 | 90 | 91 | echo 92 | echo "===Test with pytest===" 93 | pytest 94 | 95 | 96 | echo 97 | echo "===Building package===" 98 | poetry build 99 | 100 | echo 101 | echo "===Uploading code coverage===" 102 | if [[ "${CI:-}" = "1" ]] && [[ -n "${CODECOV_TOKEN:-}" ]]; then 103 | curl -s https://codecov.io/bash | bash 104 | else 105 | echo "Skipping. Will only run during continuous integration build." 106 | fi 107 | 108 | 109 | echo 110 | echo "Build complete" 111 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pyvera" 3 | version = "0.3.16" 4 | description = "Python API for talking to Veracontrollers" 5 | 6 | license = "GPL2" 7 | 8 | authors = [ 9 | "James Cole", 10 | "Greg Dowling ", 11 | "Max Velitchko" 12 | ] 13 | classifiers = [ 14 | "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", 15 | ] 16 | 17 | readme = 'README.md' 18 | 19 | repository = "https://github.com/maximvelichko/pyvera" 20 | homepage = "https://github.com/maximvelichko/pyvera" 21 | 22 | keywords = ['vera', 'api'] 23 | 24 | [build-system] 25 | requires = ["poetry-core>=1.0.0"] 26 | build-backend = "poetry.core.masonry.api" 27 | 28 | [tool.poetry.dependencies] 29 | python = ">=3.8,<4.0" 30 | requests = ">=2.22.0" 31 | 32 | [tool.poetry.dev-dependencies] 33 | black = {version = "==23.10.1", python = "^3.7"} 34 | coverage = "==7.3.2" 35 | flake8 = "==3.7.8" 36 | isort = "==4.3.21" 37 | mypy = "==1.6.1" 38 | pydocstyle = "==4.0.1" 39 | pylint = "==3.0.2" 40 | pytest = "==7.4.2" 41 | pytest-cov = "==4.1.0" 42 | responses = "==0.10.6" 43 | toml = "==0.10.0" # Needed by isort and others. 44 | wheel = "==0.38.1" # Needed for successful compile of other modules. 45 | 46 | [tool.black] 47 | target-version = ["py35", "py36", "py37", "py38"] 48 | exclude = ''' 49 | ( 50 | /( 51 | \.eggs # exclude a few common directories in the 52 | | \.git # root of the project 53 | | \.hg 54 | | \.mypy_cache 55 | | \.tox 56 | | \.venv 57 | | venv 58 | | build 59 | | _build 60 | | buck-out 61 | | build 62 | | dist 63 | )/ 64 | | foo.py # also separately exclude a file named foo.py in 65 | # the root of the project 66 | ) 67 | ''' 68 | 69 | [tool.isort] 70 | # https://github.com/timothycrosley/isort 71 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 72 | # splits long import on multiple lines indented by 4 spaces 73 | multi_line_output = 3 74 | include_trailing_comma = true 75 | force_grid_wrap = 0 76 | use_parentheses = true 77 | line_length = 88 78 | indent = " " 79 | # by default isort don't check module indexes 80 | not_skip = "__init__.py" 81 | # will group `import x` and `from x import` of the same module. 82 | force_sort_within_sections = true 83 | sections = "FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER" 84 | default_section = "THIRDPARTY" 85 | known_first_party = "homeassistant,tests" 86 | forced_separate = "tests" 87 | combine_as_imports = true 88 | -------------------------------------------------------------------------------- /examples/lock_all_doors_with_status.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Example script.""" 3 | 4 | # Parse Arguments 5 | # Import project path 6 | import argparse 7 | import os 8 | import sys 9 | import time 10 | from typing import cast 11 | 12 | # Import pyvera 13 | from pyvera import VeraController, VeraDevice, VeraLock 14 | 15 | 16 | # Define a callback that runs each time a device changes state 17 | def device_info_callback(vera_device: VeraDevice) -> None: 18 | """Print device info.""" 19 | device = cast(VeraLock, vera_device) 20 | # Do what we want with the changed device information 21 | print( 22 | "{}_{}: locked={}".format( 23 | vera_device.name, vera_device.device_id, device.is_locked() 24 | ) 25 | ) 26 | 27 | 28 | def main() -> None: 29 | """Run main code entrypoint.""" 30 | sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")) 31 | 32 | parser = argparse.ArgumentParser(description="lock-all-doors-with-status") 33 | parser.add_argument( 34 | "-u", "--url", help="Vera URL, e.g. http://192.168.1.161:3480", required=True 35 | ) 36 | args = parser.parse_args() 37 | 38 | # Start the controller 39 | controller = VeraController(args.url) 40 | controller.start() 41 | 42 | try: 43 | # Get a list of all the devices on the vera controller 44 | all_devices = controller.get_devices() 45 | 46 | # Look over the list and find the lock devices 47 | lock_devices = [] 48 | for device in all_devices: 49 | if isinstance(device, VeraLock): 50 | # Register a callback that runs when the info for that device is updated 51 | controller.register(device, device_info_callback) 52 | print( 53 | "Initially, {}_{}: locked={}".format( 54 | device.name, device.device_id, device.is_locked() 55 | ) 56 | ) 57 | lock_devices.append(device) 58 | if not device.is_locked(): 59 | device.lock() 60 | 61 | # Loop until someone hits Ctrl-C to interrupt the listener 62 | try: 63 | all_locked = False 64 | while not all_locked: 65 | time.sleep(1) 66 | all_locked = True 67 | for device in lock_devices: 68 | if not device.is_locked(): 69 | all_locked = False 70 | print("All doors are now locked") 71 | 72 | except KeyboardInterrupt: 73 | print("Got interrupted by user") 74 | 75 | # Unregister our callback 76 | for device in lock_devices: 77 | controller.unregister(device, device_info_callback) 78 | 79 | finally: 80 | # Stop the subscription listening thread so we can quit 81 | controller.stop() 82 | 83 | 84 | if __name__ == "__main__": 85 | main() 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyVera ![Build status](https://github.com/maximvelichko/pyvera/workflows/Build/badge.svg) ![PyPi version](https://img.shields.io/pypi/v/pyvera) ![PyPi downloads](https://img.shields.io/pypi/dm/pyvera) 2 | 3 | A simple Python library to control devices via the Vera controller (http://getvera.com/). 4 | 5 | Based on https://github.com/jamespcole/home-assistant-vera-api 6 | 7 | Additions to support subscriptions and some additional devices 8 | 9 | How to use 10 | ---------- 11 | 12 | 13 | >>> import pyvera 14 | 15 | >>> controller = pyvera.VeraController("http://192.168.1.161:3480/") 16 | >>> devices = controller.get_devices('On/Off Switch') 17 | >>> devices 18 | [VeraSwitch (id=15 category=On/Off Switch name=Bookcase Uplighters), VeraSwitch (id=16 category=On/Off Switch name=Bookcase device)] 19 | 20 | >>> devices[1] 21 | VeraSwitch (id=15 category=On/Off Switch name=Bookcase Uplighters) 22 | 23 | >>> devices[1].is_switched_on() 24 | False 25 | 26 | >>> devices[1].switch_on() 27 | >>> devices[1].is_switched_on() 28 | True 29 | 30 | >>> devices[1].switch_off() 31 | 32 | 33 | Examples 34 | ------- 35 | 36 | There is some example code (that can also help with tracing and debugging) in the `examples` directory. 37 | 38 | This will list your vera devices 39 | ~~~~ 40 | $ ./examples/list_devices.py -u http://192.168.1.161:3480 41 | ~~~~ 42 | 43 | This will show you events on a particular device (get the id from the example above) 44 | ~~~~ 45 | $ ./examples/device_listener.py -u http://192.168.1.161:3480/ -i 26 46 | ~~~~ 47 | 48 | If you have locks - this will show you information about them. 49 | ~~~~ 50 | $ ./examples/show_lock_info.py -u http://192.168.1.161:3480/ 51 | ~~~~ 52 | 53 | View existing locks and PINs: 54 | ~~~~ 55 | $ ./examples/show_lock_info.py -u http://192.168.1.161:3480/ 56 | ~~~~ 57 | 58 | Set a new door lock code on device 335: 59 | ~~~~ 60 | $ ./examples/set_door_code.py -u http://192.168.1.161:3480/ -i 335 -n "John Doe" -p "5678" 61 | ~~~~ 62 | 63 | Clear a existing door lock code from device 335: 64 | ~~~~ 65 | $ ./examples/delete_door_code.py -u http://192.168.1.161:3480/ -i 335 -n "John Doe" 66 | ~~~~ 67 | 68 | Debugging 69 | ------- 70 | You may use the PYVERA_LOGLEVEL environment variable to output more verbose messages to the console. For instance, to show all debug level messages using the list-devices implementation in the example directory, run something similar to: 71 | ~~~~ 72 | $ PYVERA_LOGLEVEL=DEBUG ./examples/list-devices.py -u http://192.168.1.161:3480 73 | ~~~~ 74 | 75 | Debugging inside home assistant 76 | ------- 77 | If you're running pyvera inside home assistant and need the debugging log traces, add the following to your `configuration.yaml` 78 | 79 | 80 | ~~~~ 81 | logger: 82 | logs: 83 | pyvera: debug 84 | ~~~~ 85 | 86 | Developing 87 | ------- 88 | Setup and builds are fully automated. You can run build pipeline locally by running. 89 | ~~~~ 90 | # Setup, build, lint and test the code. 91 | ./scripts/build.sh 92 | ~~~~ 93 | 94 | License 95 | ------- 96 | The initial code was initially was written by James Cole and released under the BSD license. The rest is released under the MIT license. 97 | 98 | -------------------------------------------------------------------------------- /examples/device_listener.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Example script.""" 3 | 4 | # Parse Arguments 5 | # Import project path 6 | import argparse 7 | import os 8 | import sys 9 | import time 10 | 11 | # Import pyvera 12 | from pyvera import VeraController, VeraDevice 13 | 14 | 15 | # Define a callback that runs each time a device changes state 16 | def device_info_callback(vera_device: VeraDevice) -> None: 17 | """Print device info.""" 18 | # Do what we want with the changed device information 19 | print( 20 | "{}_{} values: {}".format( 21 | vera_device.name, vera_device.device_id, vera_device.get_all_values() 22 | ) 23 | ) 24 | print( 25 | "{}_{} alerts: {}".format( 26 | vera_device.name, vera_device.device_id, vera_device.get_alerts() 27 | ) 28 | ) 29 | 30 | 31 | def main() -> None: 32 | """Run main code entrypoint.""" 33 | sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")) 34 | 35 | parser = argparse.ArgumentParser(description="device-listener") 36 | parser.add_argument( 37 | "-u", "--url", help="Vera URL, e.g. http://192.168.1.161:3480", required=True 38 | ) 39 | group = parser.add_mutually_exclusive_group(required=True) 40 | # Pass in either the vera id of the device or the name 41 | group.add_argument( 42 | "-i", "--id", type=int, help="The Vera Device ID for subscription" 43 | ) 44 | group.add_argument( 45 | "-n", "--name", help="The Vera Device name string for subscription" 46 | ) 47 | args = parser.parse_args() 48 | 49 | # Start the controller 50 | controller = VeraController(args.url) 51 | controller.start() 52 | 53 | try: 54 | # Get the requested device on the vera controller 55 | found_device = None 56 | if args.name is not None: 57 | found_device = controller.get_device_by_name(args.name) 58 | elif args.id is not None: 59 | found_device = controller.get_device_by_id(args.id) 60 | 61 | if found_device is None: 62 | raise Exception( 63 | "Did not find device with {} or {}".format(args.name, args.id) 64 | ) 65 | 66 | print( 67 | "Listening for changes to {}: {}_{}".format( 68 | type(found_device).__name__, found_device.name, found_device.device_id 69 | ) 70 | ) 71 | 72 | # Register a callback that runs when the info for that device is updated 73 | controller.register(found_device, device_info_callback) 74 | print("Initial values: {}".format(found_device.get_all_values())) 75 | print("Initial alerts: {}".format(found_device.get_alerts())) 76 | 77 | # Loop until someone hits Ctrl-C to interrupt the listener 78 | try: 79 | while True: 80 | time.sleep(1) 81 | except KeyboardInterrupt: 82 | print("Got interrupted by user") 83 | 84 | # Unregister our callback 85 | controller.unregister(found_device, device_info_callback) 86 | 87 | finally: 88 | # Stop the subscription listening thread so we can quit 89 | controller.stop() 90 | 91 | 92 | if __name__ == "__main__": 93 | main() 94 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Test module.""" 2 | import logging 3 | import time 4 | from typing import Any, NamedTuple, cast 5 | from unittest.mock import MagicMock 6 | 7 | import pytest 8 | import pyvera 9 | from pyvera import ( 10 | CATEGORY_LOCK, 11 | STATE_JOB_IN_PROGRESS, 12 | STATE_NO_JOB, 13 | SubscriptionRegistry, 14 | VeraBinarySensor, 15 | VeraController, 16 | VeraCurtain, 17 | VeraDimmer, 18 | VeraLock, 19 | VeraSceneController, 20 | VeraSensor, 21 | VeraSwitch, 22 | VeraThermostat, 23 | ) 24 | 25 | from .common import ( 26 | DEVICE_ALARM_SENSOR_ID, 27 | DEVICE_CURTAIN_ID, 28 | DEVICE_DOOR_SENSOR_ID, 29 | DEVICE_GARAGE_DOOR_ID, 30 | DEVICE_HUMIDITY_SENSOR_ID, 31 | DEVICE_LIGHT_ID, 32 | DEVICE_LIGHT_SENSOR_ID, 33 | DEVICE_LOCK_ID, 34 | DEVICE_MOTION_SENSOR_ID, 35 | DEVICE_POWER_METER_SENSOR_ID, 36 | DEVICE_SCENE_CONTROLLER_ID, 37 | DEVICE_SWITCH2_ID, 38 | DEVICE_SWITCH_ID, 39 | DEVICE_TEMP_SENSOR_ID, 40 | DEVICE_THERMOSTAT2_ID, 41 | DEVICE_THERMOSTAT_ID, 42 | DEVICE_UV_SENSOR_ID, 43 | VeraControllerData, 44 | update_device, 45 | ) 46 | 47 | logging.basicConfig(level=logging.DEBUG) 48 | pyvera.LOG = logging.getLogger(__name__) 49 | 50 | 51 | def test_controller_refresh_data(vera_controller_data: VeraControllerData) -> None: 52 | """Test function.""" 53 | controller = vera_controller_data.controller 54 | 55 | assert not controller.model 56 | assert not controller.version 57 | assert not controller.serial_number 58 | controller.refresh_data() 59 | assert controller.model == "fake_model_number" 60 | assert controller.version == "fake_version_number" 61 | assert controller.serial_number == "fake_serial_number" 62 | 63 | 64 | # pylint: disable=protected-access 65 | def test__event_device_for_vera_lock_status() -> None: 66 | """Test function.""" 67 | registry = SubscriptionRegistry() 68 | registry.set_controller(MagicMock(spec=VeraController)) 69 | mock_lock = MagicMock(spec=VeraLock) 70 | mock_lock.name = MagicMock(return_value="MyTestDeadbolt") 71 | 72 | # Deadbolt changing but not done 73 | device_json: dict = {"state": STATE_JOB_IN_PROGRESS} 74 | registry._event_device(mock_lock, device_json, []) 75 | mock_lock.update.assert_not_called() 76 | 77 | # Deadbolt progress with reset state but not done 78 | device_json = { 79 | "state": STATE_NO_JOB, 80 | "comment": "MyTestDeadbolt: Sending the Z-Wave command after 0 retries", 81 | } 82 | registry._event_device(mock_lock, device_json, []) 83 | mock_lock.update.assert_not_called() 84 | 85 | # Deadbolt progress locked but not done 86 | device_json = { 87 | "state": STATE_JOB_IN_PROGRESS, 88 | "locked": "1", 89 | "comment": "MyTestDeadbolt", 90 | } 91 | registry._event_device(mock_lock, device_json, []) 92 | mock_lock.update.assert_not_called() 93 | 94 | # Deadbolt progress with status but not done 95 | device_json = { 96 | "state": STATE_JOB_IN_PROGRESS, 97 | "comment": "MyTestDeadbolt: Please wait! Polling node", 98 | } 99 | registry._event_device(mock_lock, device_json, []) 100 | mock_lock.update.assert_not_called() 101 | 102 | # Deadbolt progress complete 103 | device_json = { 104 | "state": STATE_JOB_IN_PROGRESS, 105 | "locked": "1", 106 | "comment": "MyTestDeadbolt: SUCCESS! Successfully polled node", 107 | "deviceInfo": {"category": CATEGORY_LOCK}, 108 | } 109 | registry._event_device(mock_lock, device_json, []) 110 | mock_lock.update.assert_called_once_with(device_json) 111 | 112 | 113 | def test_refresh_data(vera_controller_data: VeraControllerData) -> None: 114 | """Test function.""" 115 | controller = vera_controller_data.controller 116 | data = controller.refresh_data() 117 | assert len(data) == 20 118 | 119 | services = controller.map_services() 120 | assert services is None 121 | 122 | 123 | def test_polling(vera_controller_data: VeraControllerData) -> None: 124 | """Test function.""" 125 | controller = vera_controller_data.controller 126 | 127 | callback_mock = MagicMock() 128 | device = cast(VeraSensor, controller.get_device_by_id(DEVICE_TEMP_SENSOR_ID)) 129 | controller.register(device, callback_mock) 130 | 131 | # Data updated, poll didn't run yet. 132 | update_device( 133 | controller_data=vera_controller_data, 134 | device_id=DEVICE_TEMP_SENSOR_ID, 135 | key="temperature", 136 | value=66.00, 137 | push=False, 138 | ) 139 | assert device.temperature == 57.00 140 | callback_mock.assert_not_called() 141 | 142 | # Poll ran, new data, device updated. 143 | time.sleep(1.1) 144 | assert device.temperature == 66.00 145 | callback_mock.assert_called_with(device) 146 | callback_mock.reset_mock() 147 | 148 | # Poll ran, no new data. 149 | time.sleep(1) 150 | callback_mock.assert_not_called() 151 | 152 | # Poll ran, new date, device updated. 153 | update_device( 154 | controller_data=vera_controller_data, 155 | device_id=DEVICE_TEMP_SENSOR_ID, 156 | key="temperature", 157 | value=77.00, 158 | push=False, 159 | ) 160 | callback_mock.assert_not_called() 161 | time.sleep(1) 162 | callback_mock.assert_called_with(device) 163 | 164 | 165 | def test_controller_custom_subscription_registry() -> None: 166 | """Test function.""" 167 | 168 | class CustomSubscriptionRegistry(pyvera.AbstractSubscriptionRegistry): 169 | """Test registry.""" 170 | 171 | def start(self) -> None: 172 | """Start the polling.""" 173 | 174 | def stop(self) -> None: 175 | """Stop the polling.""" 176 | 177 | controller = VeraController("URL", CustomSubscriptionRegistry()) 178 | assert controller.subscription_registry.get_controller() == controller 179 | 180 | 181 | def test_controller_register_unregister( 182 | vera_controller_data: VeraControllerData, 183 | ) -> None: 184 | """Test function.""" 185 | controller = vera_controller_data.controller 186 | device = cast(VeraSensor, controller.get_device_by_id(DEVICE_TEMP_SENSOR_ID)) 187 | callback_mock = MagicMock() 188 | 189 | assert device.temperature == 57.00 190 | 191 | # Device not registered, device is not update. 192 | update_device( 193 | controller_data=vera_controller_data, 194 | device_id=DEVICE_TEMP_SENSOR_ID, 195 | key="temperature", 196 | value=66.00, 197 | ) 198 | assert device.temperature == 57.00 199 | callback_mock.assert_not_called() 200 | callback_mock.mock_reset() 201 | 202 | # Device registered, device is updated. 203 | controller.register(device, callback_mock) 204 | update_device( 205 | controller_data=vera_controller_data, 206 | device_id=DEVICE_TEMP_SENSOR_ID, 207 | key="temperature", 208 | value=66.00, 209 | ) 210 | assert device.temperature == 66.00 211 | callback_mock.assert_called_with(device) 212 | callback_mock.reset_mock() 213 | 214 | # Device unregistered, device is updated. 215 | controller.unregister(device, callback_mock) 216 | update_device( 217 | controller_data=vera_controller_data, 218 | device_id=DEVICE_TEMP_SENSOR_ID, 219 | key="temperature", 220 | value=111111.11, 221 | ) 222 | assert device.temperature == 66.00 223 | callback_mock.assert_not_called() 224 | 225 | 226 | @pytest.mark.parametrize("device_id", (DEVICE_DOOR_SENSOR_ID, DEVICE_MOTION_SENSOR_ID)) 227 | def test_binary_sensor( 228 | vera_controller_data: VeraControllerData, device_id: int 229 | ) -> None: 230 | """Test function.""" 231 | controller = vera_controller_data.controller 232 | device = cast(VeraBinarySensor, controller.get_device_by_id(device_id)) 233 | controller.register(device, lambda device: None) 234 | 235 | assert device.is_tripped is False 236 | assert device.is_switched_on(refresh=True) is False 237 | 238 | update_device( 239 | controller_data=vera_controller_data, 240 | device_id=device_id, 241 | key="tripped", 242 | value="1", 243 | ) 244 | assert device.is_tripped is True 245 | assert device.is_switched_on() is False 246 | 247 | update_device( 248 | controller_data=vera_controller_data, 249 | device_id=device_id, 250 | key="status", 251 | value="1", 252 | ) 253 | assert device.is_switched_on() is True 254 | 255 | 256 | def test_lock(vera_controller_data: VeraControllerData) -> None: 257 | """Test function.""" 258 | controller = vera_controller_data.controller 259 | device = cast(VeraLock, controller.get_device_by_id(DEVICE_LOCK_ID)) 260 | controller.register(device, lambda device: None) 261 | 262 | assert device.is_locked() is False 263 | device.lock() 264 | assert device.is_locked() is True 265 | device.unlock() 266 | assert device.is_locked() is False 267 | assert device.set_new_pin(name="John Doe", pin=12121212).status_code == 200 268 | assert device.clear_slot_pin(slot=1).status_code == 200 269 | 270 | 271 | # pylint: disable=protected-access 272 | def test_thermostat(vera_controller_data: VeraControllerData) -> None: 273 | """Test function.""" 274 | controller = vera_controller_data.controller 275 | device1 = cast(VeraThermostat, controller.get_device_by_id(DEVICE_THERMOSTAT_ID)) 276 | device2 = cast(VeraThermostat, controller.get_device_by_id(DEVICE_THERMOSTAT2_ID)) 277 | controller.register(device1, lambda device: None) 278 | controller.register(device2, lambda device: None) 279 | 280 | all_devices = (device1, device2) 281 | 282 | assert device1.get_current_goal_temperature(refresh=True) == 8.0 283 | assert device2.get_current_goal_temperature(refresh=True) == 7.0 284 | 285 | for device in all_devices: 286 | assert device.get_current_temperature(refresh=True) == 9.0 287 | assert device.get_hvac_mode(refresh=True) == "Off" 288 | assert device.get_fan_mode(refresh=True) == "Off" 289 | assert device.get_hvac_state(refresh=True) == "Off" 290 | 291 | assert device1._has_double_setpoints() is False 292 | assert device2._has_double_setpoints() is True 293 | 294 | update_device( 295 | controller_data=vera_controller_data, 296 | device_id=DEVICE_THERMOSTAT_ID, 297 | key="temperature", 298 | value=65, 299 | ) 300 | assert device1.get_current_temperature() == 65 301 | 302 | for device in all_devices: 303 | device.set_temperature(72) 304 | assert device.get_current_goal_temperature() == 72 305 | 306 | device.turn_auto_on() 307 | assert device.get_hvac_mode() == "AutoChangeOver" 308 | device.turn_heat_on() 309 | assert device.get_hvac_mode() == "HeatOn" 310 | device.turn_cool_on() 311 | assert device.get_hvac_mode() == "CoolOn" 312 | 313 | device.fan_on() 314 | assert device.get_fan_mode() == "ContinuousOn" 315 | device.fan_auto() 316 | assert device.get_fan_mode() == "Auto" 317 | device.fan_cycle() 318 | assert device.get_fan_mode() == "PeriodicOn" 319 | device.fan_off() 320 | assert device.get_fan_mode() == "Off" 321 | 322 | device.turn_off() 323 | assert device.get_hvac_mode() == "Off" 324 | 325 | device2.turn_heat_on() 326 | device2.set_temperature(75) 327 | assert device2.get_current_goal_temperature() == 75 328 | assert device2.get_value("heatsp") == "75" 329 | 330 | device2.turn_cool_on() 331 | device2.set_temperature(60) 332 | assert device2.get_current_goal_temperature() == 60 333 | assert device2.get_value("coolsp") == "60" 334 | 335 | device2.turn_heat_on() 336 | assert device2.get_current_goal_temperature() == 75 337 | assert device2.get_value("heatsp") == "75" 338 | 339 | device2.turn_cool_on() 340 | assert device2.get_current_goal_temperature() == 60 341 | assert device2.get_value("coolsp") == "60" 342 | 343 | 344 | def test_curtain(vera_controller_data: VeraControllerData) -> None: 345 | """Test function.""" 346 | controller = vera_controller_data.controller 347 | device = cast(VeraCurtain, controller.get_device_by_id(DEVICE_CURTAIN_ID)) 348 | controller.register(device, lambda device: None) 349 | 350 | assert device.is_open(refresh=True) is False 351 | assert device.get_level(refresh=True) == 0 352 | 353 | device.open() 354 | assert device.is_open() is True 355 | assert device.get_level() == 100 356 | 357 | device.close() 358 | assert device.is_open() is False 359 | assert device.get_level() == 0 360 | 361 | device.set_level(50) 362 | update_device(vera_controller_data, DEVICE_CURTAIN_ID, "level", 55) 363 | device.stop() 364 | assert device.get_level() == 55 365 | assert device.is_open() is True 366 | 367 | 368 | def test_dimmer(vera_controller_data: VeraControllerData) -> None: 369 | """Test function.""" 370 | controller = vera_controller_data.controller 371 | device = cast(VeraDimmer, controller.get_device_by_id(DEVICE_LIGHT_ID)) 372 | controller.register(device, lambda device: None) 373 | 374 | assert device.is_switched_on(refresh=True) is False 375 | assert device.get_brightness(refresh=True) == 0 376 | assert device.get_color(refresh=True) == [255, 100, 100] 377 | 378 | device.switch_on() 379 | assert device.is_switched_on() is True 380 | 381 | device.set_brightness(66) 382 | assert device.get_brightness() == 66 383 | 384 | device.switch_off() 385 | device.switch_on() 386 | assert device.get_brightness() == 66 387 | 388 | device.set_color([120, 130, 140]) 389 | assert device.get_color() == [120, 130, 140] 390 | 391 | device.switch_off() 392 | assert device.is_switched_on() is False 393 | 394 | 395 | def test_scene_controller(vera_controller_data: VeraControllerData) -> None: 396 | """Test function.""" 397 | controller = vera_controller_data.controller 398 | device = cast( 399 | VeraSceneController, controller.get_device_by_id(DEVICE_SCENE_CONTROLLER_ID) 400 | ) 401 | controller.register(device, lambda device: None) 402 | 403 | assert device.get_last_scene_id(refresh=True) == "1234" 404 | assert device.get_last_scene_time(refresh=True) == "10000012" 405 | assert device.should_poll is True 406 | 407 | update_device( 408 | vera_controller_data, DEVICE_SCENE_CONTROLLER_ID, "LastSceneID", "Id2" 409 | ) 410 | update_device( 411 | vera_controller_data, DEVICE_SCENE_CONTROLLER_ID, "LastSceneTime", "4444" 412 | ) 413 | assert device.get_last_scene_id(refresh=False) == "1234" 414 | assert device.get_last_scene_time(refresh=False) == "10000012" 415 | assert device.get_last_scene_id(refresh=True) == "Id2" 416 | assert device.get_last_scene_time(refresh=True) == "4444" 417 | 418 | 419 | SensorParam = NamedTuple( 420 | "SensorParam", 421 | ( 422 | ("device_id", int), 423 | ("device_property", str), 424 | ("initial_value", Any), 425 | ("new_value", Any), 426 | ("variable", str), 427 | ("variable_value", Any), 428 | ), 429 | ) 430 | 431 | 432 | @pytest.mark.parametrize( 433 | "param", 434 | ( 435 | SensorParam( 436 | device_id=DEVICE_TEMP_SENSOR_ID, 437 | device_property="temperature", 438 | initial_value=57.00, 439 | new_value=66.00, 440 | variable="temperature", 441 | variable_value=66.00, 442 | ), 443 | SensorParam( 444 | device_id=DEVICE_ALARM_SENSOR_ID, 445 | device_property="is_tripped", 446 | initial_value=False, 447 | new_value=True, 448 | variable="tripped", 449 | variable_value="1", 450 | ), 451 | SensorParam( 452 | device_id=DEVICE_LIGHT_SENSOR_ID, 453 | device_property="light", 454 | initial_value="0", 455 | new_value="22", 456 | variable="light", 457 | variable_value="22", 458 | ), 459 | SensorParam( 460 | device_id=DEVICE_UV_SENSOR_ID, 461 | device_property="light", 462 | initial_value="0", 463 | new_value="23", 464 | variable="light", 465 | variable_value="23", 466 | ), 467 | SensorParam( 468 | device_id=DEVICE_HUMIDITY_SENSOR_ID, 469 | device_property="humidity", 470 | initial_value="0", 471 | new_value="40", 472 | variable="humidity", 473 | variable_value="40", 474 | ), 475 | SensorParam( 476 | device_id=DEVICE_POWER_METER_SENSOR_ID, 477 | device_property="power", 478 | initial_value="0", 479 | new_value="50", 480 | variable="watts", 481 | variable_value="50", 482 | ), 483 | ), 484 | ) 485 | def test_sensor(vera_controller_data: VeraControllerData, param: SensorParam) -> None: 486 | """Test function.""" 487 | controller = vera_controller_data.controller 488 | device = cast(VeraSensor, controller.get_device_by_id(param.device_id)) 489 | controller.register(device, lambda device: None) 490 | assert getattr(device, param.device_property) == param.initial_value 491 | 492 | update_device( 493 | controller_data=vera_controller_data, 494 | device_id=param.device_id, 495 | key=param.variable, 496 | value=param.variable_value, 497 | ) 498 | assert getattr(device, param.device_property) == param.new_value 499 | 500 | 501 | @pytest.mark.parametrize( 502 | "device_id", (DEVICE_SWITCH_ID, DEVICE_SWITCH2_ID, DEVICE_GARAGE_DOOR_ID) 503 | ) 504 | def test_switch(vera_controller_data: VeraControllerData, device_id: int) -> None: 505 | """Test function.""" 506 | controller = vera_controller_data.controller 507 | device = cast(VeraSwitch, controller.get_device_by_id(device_id)) 508 | controller.register(device, lambda device: None) 509 | 510 | assert device.is_switched_on() is False 511 | device.switch_on() 512 | assert device.is_switched_on() is True 513 | device.switch_off() 514 | assert device.is_switched_on() is False 515 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | 341 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | """Common code for tests.""" 2 | 3 | from copy import deepcopy 4 | import json 5 | import re 6 | from typing import Any, List, NamedTuple, Optional, Tuple 7 | from urllib.parse import parse_qs, urlparse 8 | 9 | from _pytest.fixtures import FixtureRequest 10 | from pyvera import ( 11 | CATEGORY_ARMABLE, 12 | CATEGORY_CURTAIN, 13 | CATEGORY_DIMMER, 14 | CATEGORY_GARAGE_DOOR, 15 | CATEGORY_GENERIC, 16 | CATEGORY_HUMIDITY_SENSOR, 17 | CATEGORY_LIGHT_SENSOR, 18 | CATEGORY_LOCK, 19 | CATEGORY_POWER_METER, 20 | CATEGORY_SCENE_CONTROLLER, 21 | CATEGORY_SENSOR, 22 | CATEGORY_SWITCH, 23 | CATEGORY_TEMPERATURE_SENSOR, 24 | CATEGORY_THERMOSTAT, 25 | CATEGORY_UV_SENSOR, 26 | VeraController, 27 | ) 28 | import requests 29 | import responses 30 | 31 | VeraApiData = NamedTuple( 32 | "VeraApiData", [("sdata", dict), ("status", dict), ("lu_sdata", dict)] 33 | ) 34 | 35 | VeraControllerData = NamedTuple( 36 | "ControllerData", [("api_data", VeraApiData), ("controller", VeraController)] 37 | ) 38 | 39 | 40 | def new_vera_api_data() -> VeraApiData: 41 | """Create new api data object.""" 42 | return VeraApiData( 43 | sdata=deepcopy(RESPONSE_SDATA), 44 | status=deepcopy(RESPONSE_STATUS), 45 | lu_sdata=deepcopy(RESPONSE_LU_SDATA), 46 | ) 47 | 48 | 49 | def find_device_object(device_id: int, data_list: List[dict]) -> Optional[dict]: 50 | """Find a vera device object in a list of devices.""" 51 | for device in data_list: 52 | if device.get("id") == device_id: 53 | return device 54 | 55 | return None 56 | 57 | 58 | def get_device(device_id: int, api_data: VeraApiData) -> Optional[dict]: 59 | """Find a vera device.""" 60 | return find_device_object(device_id, api_data.sdata.get("devices", [])) 61 | 62 | 63 | def get_device_status(device_id: int, api_data: VeraApiData) -> Optional[dict]: 64 | """Find a vera device status.""" 65 | return find_device_object(device_id, api_data.status.get("devices", [])) 66 | 67 | 68 | def set_device_status( 69 | device_id: int, api_data: VeraApiData, key: str, value: Any 70 | ) -> None: 71 | """Set the status of a vera device.""" 72 | device = get_device(device_id, api_data) 73 | device_status = get_device_status(device_id, api_data) 74 | 75 | if not device or not device_status: 76 | return 77 | 78 | device_status[key] = value 79 | device[key] = value 80 | 81 | device_status["states"] = device_status["states"] or [] 82 | 83 | # Get the current state or create a new one. 84 | state: dict = next( 85 | iter([s for s in device_status["states"] if s["variable"] == key]), {} 86 | ) 87 | 88 | # Update current states to exclude the one we are changing. 89 | device_status["states"] = [ 90 | s for s in device_status["states"] if s["variable"] != key 91 | ] 92 | 93 | # Add the updated state to the list of states. 94 | state.update({"variable": key, "value": value}) 95 | device_states = device_status["state"] = device_status.get("state", []) 96 | device_states.append(state) 97 | 98 | 99 | def update_device( 100 | controller_data: VeraControllerData, 101 | device_id: int, 102 | key: str, 103 | value: Any, 104 | push: bool = True, 105 | ) -> None: 106 | """Update a vera device with a specific key/value.""" 107 | device = get_device(device_id, controller_data.api_data) 108 | assert device, "Failed to find device with device id %d" % device_id 109 | 110 | device_status = get_device_status(device_id, controller_data.api_data) 111 | assert device_status, "Failed to find device status with device id %d" % device_id 112 | 113 | lu_data = controller_data.api_data.lu_sdata 114 | lu_data_devices = lu_data["devices"] = lu_data.get("devices", []) 115 | lu_data_devices.append(device) 116 | 117 | controller_data.api_data.lu_sdata["loadtime"] = "now" 118 | controller_data.api_data.lu_sdata["dataversion"] += 1 119 | 120 | controller_data.api_data.status["LoadTime"] = "now" 121 | controller_data.api_data.status["DataVersion"] += 1 122 | 123 | device_status[key] = value 124 | device[key] = value 125 | 126 | device_status["states"] = device_status["states"] or [] 127 | 128 | # Get the current state or create a new one. 129 | state: dict = next( 130 | iter([s for s in device_status["states"] if s["variable"] == key]), {} 131 | ) 132 | 133 | # Update current states to exclude the one we are changing. 134 | device_status["states"] = [ 135 | s for s in device_status["states"] if s["variable"] != key 136 | ] 137 | 138 | # Add the updated state to the list of states. 139 | state.update({"variable": key, "value": value}) 140 | device_status["states"].append(state) 141 | 142 | if push: 143 | publish_device_status(controller_data.controller, device_status) 144 | 145 | 146 | def publish_device_status(controller: VeraController, device_status: dict) -> None: 147 | """Instruct pyvera to notify objects that data changed for a device.""" 148 | # pylint: disable=protected-access 149 | controller.subscription_registry._event([device_status], []) 150 | 151 | 152 | ResponsesResponse = Tuple[int, dict, str] 153 | 154 | 155 | def handle_lu_action(payload: dict, api_data: VeraApiData) -> ResponsesResponse: 156 | """Handle lu_action requests.""" 157 | params = payload.copy() 158 | params.pop("id") 159 | service_id = params.pop("serviceId") 160 | action = params.pop("action") 161 | device_id = int(params.pop("DeviceNum")) 162 | params.pop("output_format") 163 | set_state_variable_name = next( 164 | (key for key in params if key.lower().startswith("new")), 165 | "UserCodeName" 166 | if "UserCodeName" in params.keys() 167 | else "UserCode" 168 | if "UserCode" in params.keys() 169 | else None, 170 | ) 171 | state_variable_name = ( 172 | str(set_state_variable_name)[3:] 173 | if "UserCode" not in str(set_state_variable_name) 174 | else set_state_variable_name 175 | ) 176 | state_variable_value = params.pop(set_state_variable_name) 177 | status_variable_name = None 178 | 179 | if service_id == "urn:upnp-org:serviceId:SwitchPower1" and action == "SetTarget": 180 | status_variable_name = "status" 181 | elif ( 182 | service_id == "urn:upnp-org:serviceId:Dimming1" 183 | and action == "SetLoadLevelTarget" 184 | ): 185 | status_variable_name = "level" 186 | elif ( 187 | service_id == "urn:micasaverde-com:serviceId:SecuritySensor1" 188 | and action == "SetArmed" 189 | ): 190 | status_variable_name = "armed" 191 | elif ( 192 | service_id == "urn:upnp-org:serviceId:WindowCovering1" 193 | and action == "SetLoadLevelTarget" 194 | ): 195 | status_variable_name = "level" 196 | elif ( 197 | service_id == "urn:micasaverde-com:serviceId:DoorLock1" 198 | and action == "NewTarget" 199 | ): 200 | status_variable_name = "locked" 201 | elif ( 202 | service_id == "urn:upnp-org:serviceId:HVAC_UserOperatingMode1" 203 | and action == "SetModeTarget" 204 | ): 205 | status_variable_name = "mode" 206 | elif ( 207 | service_id == "urn:upnp-org:serviceId:HVAC_FanOperatingMode1" 208 | and action == "SetMode" 209 | ): 210 | status_variable_name = "fanmode" 211 | elif service_id == "urn:upnp-org:serviceId:TemperatureSetpoint1_Cool": 212 | pass 213 | elif service_id == "urn:upnp-org:serviceId:TemperatureSetpoint1_Heat": 214 | pass 215 | elif ( 216 | service_id == "urn:upnp-org:serviceId:TemperatureSetpoint1" 217 | and action == "SetCurrentSetpoint" 218 | ): 219 | status_variable_name = "setpoint" 220 | elif ( 221 | service_id == "urn:micasaverde-com:serviceId:Color1" and action == "SetColorRGB" 222 | ): 223 | status_variable_name = "CurrentColor" 224 | 225 | device = get_device(device_id, api_data) or {} 226 | status = get_device_status(device_id, api_data) or {} 227 | status["states"] = [] 228 | 229 | # Update the device and status objects. 230 | if status_variable_name is not None: 231 | device[status_variable_name] = state_variable_value 232 | status[status_variable_name] = state_variable_value 233 | 234 | # Update the state object. 235 | status["states"] = [ 236 | state 237 | for state in status.get("states", []) 238 | if state.get("service") != service_id 239 | or state.get("variable") != state_variable_name 240 | ] 241 | status["states"].append( 242 | { 243 | "service": service_id, 244 | "variable": state_variable_name, 245 | "value": state_variable_value, 246 | } 247 | ) 248 | 249 | return 200, {}, "" 250 | 251 | 252 | def handle_variable_get(payload: dict, api_data: VeraApiData) -> ResponsesResponse: 253 | """Handle variable_get requests.""" 254 | device_id = payload.get("DeviceNum") 255 | variable = payload.get("Variable") 256 | 257 | if device_id and variable: 258 | status = get_device_status(int(device_id), api_data) or {} 259 | for state in status.get("states", []): 260 | if state.get("variable") == variable: 261 | # return state.get("value") 262 | return 200, {}, state.get("value") 263 | 264 | return 200, {}, "" 265 | 266 | 267 | def handle_request( 268 | req: requests.PreparedRequest, api_data: VeraApiData 269 | ) -> ResponsesResponse: 270 | """Handle a request for data from the controller.""" 271 | url_parts = urlparse(req.url) 272 | qs_parts: dict = parse_qs(url_parts.query) 273 | payload = {} 274 | for key, value in qs_parts.items(): 275 | payload[key] = value[0] 276 | 277 | payload_id = payload.get("id") 278 | 279 | response: ResponsesResponse = (200, {}, "") 280 | if payload_id == "sdata": 281 | response = 200, {}, json.dumps(api_data.sdata) 282 | if payload_id == "status": 283 | response = 200, {}, json.dumps(api_data.status) 284 | if payload_id == "lu_sdata": 285 | response = 200, {}, json.dumps(api_data.lu_sdata) 286 | if payload_id == "action": 287 | response = 200, {}, json.dumps({}) 288 | if payload_id == "variableget": 289 | response = handle_variable_get(payload, api_data) 290 | if payload_id == "lu_action": 291 | response = handle_lu_action(payload, api_data) 292 | 293 | return response 294 | 295 | 296 | class VeraControllerFactory: 297 | """Manages the creation of mocked controllers.""" 298 | 299 | def __init__(self, pytest_req: FixtureRequest, rsps: responses.RequestsMock): 300 | """Init object.""" 301 | self.pytest_req = pytest_req 302 | self.rsps = rsps 303 | 304 | def new_instance(self, api_data: VeraApiData) -> VeraControllerData: 305 | """Create new instance of controller.""" 306 | base_url = "http://127.0.0.1:123" 307 | 308 | def callback(req: requests.PreparedRequest) -> ResponsesResponse: 309 | nonlocal api_data 310 | return handle_request(req, api_data) 311 | 312 | self.rsps.add_callback( 313 | method=responses.GET, 314 | url=re.compile(f"{base_url}/data_request?.*"), 315 | callback=callback, 316 | content_type="application/json", 317 | ) 318 | 319 | controller = VeraController("http://127.0.0.1:123") 320 | controller.data_request({"id": "sdata"}) 321 | controller.start() 322 | 323 | # Stop the controller after the test stops and fixture is torn down. 324 | self.pytest_req.addfinalizer(controller.stop) 325 | 326 | return VeraControllerData(api_data=api_data, controller=controller) 327 | 328 | 329 | SCENE1_ID = 101 330 | 331 | DEVICE_IGNORE = 55 332 | DEVICE_ALARM_SENSOR_ID = 62 333 | DEVICE_DOOR_SENSOR_ID = 45 334 | DEVICE_MOTION_SENSOR_ID = 51 335 | DEVICE_TEMP_SENSOR_ID = 52 336 | DEVICE_DIMMER_ID = 59 337 | DEVICE_LIGHT_ID = 69 338 | DEVICE_SWITCH_ID = 44 339 | DEVICE_SWITCH2_ID = 46 340 | DEVICE_GARAGE_DOOR_ID = 47 341 | DEVICE_LOCK_ID = 10 342 | DEVICE_THERMOSTAT_ID = 11 343 | DEVICE_THERMOSTAT2_ID = 18 344 | DEVICE_CURTAIN_ID = 12 345 | DEVICE_SCENE_CONTROLLER_ID = 13 346 | DEVICE_LIGHT_SENSOR_ID = 14 347 | DEVICE_UV_SENSOR_ID = 15 348 | DEVICE_HUMIDITY_SENSOR_ID = 16 349 | DEVICE_POWER_METER_SENSOR_ID = 17 350 | DEVICE_GENERIC_DEVICE_ID = 19 351 | 352 | CATEGORY_UNKNOWN = 1234 353 | 354 | RESPONSE_SDATA = { 355 | "scenes": [{"id": SCENE1_ID, "name": "scene1", "active": 0, "root": 0}], 356 | "temperature": 23, 357 | "model": "fake_model_number", 358 | "version": "fake_version_number", 359 | "serial_number": "fake_serial_number", 360 | "categories": [ 361 | {"name": "Dimmable Switch", "id": CATEGORY_DIMMER}, 362 | {"name": "On/Off Switch", "id": CATEGORY_SWITCH}, 363 | {"name": "Sensor", "id": CATEGORY_ARMABLE}, 364 | {"name": "Generic IO", "id": CATEGORY_GENERIC}, 365 | {"name": "Temperature Sensor", "id": CATEGORY_TEMPERATURE_SENSOR}, 366 | {"name": "Lock", "id": CATEGORY_LOCK}, 367 | {"name": "Thermostat", "id": CATEGORY_THERMOSTAT}, 368 | {"name": "Light sensor", "id": CATEGORY_LIGHT_SENSOR}, 369 | {"name": "UV sensor", "id": CATEGORY_UV_SENSOR}, 370 | {"name": "Humidity sensor", "id": CATEGORY_HUMIDITY_SENSOR}, 371 | {"name": "Power meter", "id": CATEGORY_POWER_METER}, 372 | ], 373 | "devices": [ 374 | { 375 | "name": "Ignore 1", 376 | "altid": "6", 377 | "id": DEVICE_IGNORE, 378 | "category": CATEGORY_SWITCH, 379 | "subcategory": 1, 380 | "room": 0, 381 | "parent": 1, 382 | "armed": "0", 383 | "armedtripped": "0", 384 | "configured": "1", 385 | "batterylevel": "100", 386 | "commFailure": "0", 387 | "lasttrip": "1571790666", 388 | "tripped": "0", 389 | "state": -1, 390 | "comment": "", 391 | }, 392 | { 393 | "name": "Door sensor 1", 394 | "altid": "6", 395 | "id": DEVICE_DOOR_SENSOR_ID, 396 | "category": CATEGORY_ARMABLE, 397 | "subcategory": 1, 398 | "room": 0, 399 | "parent": 1, 400 | "armed": "0", 401 | "armedtripped": "0", 402 | "configured": "1", 403 | "batterylevel": "100", 404 | "commFailure": "0", 405 | "lasttrip": "1571790666", 406 | "tripped": "0", 407 | "state": -1, 408 | "status": "0", 409 | "comment": "", 410 | }, 411 | { 412 | "name": "Motion sensor 1", 413 | "altid": "12", 414 | "id": DEVICE_MOTION_SENSOR_ID, 415 | "category": CATEGORY_ARMABLE, 416 | "subcategory": 3, 417 | "room": 0, 418 | "parent": 1, 419 | "armed": "0", 420 | "armedtripped": "0", 421 | "configured": "1", 422 | "batterylevel": "100", 423 | "commFailure": "0", 424 | "lasttrip": "1571975359", 425 | "tripped": "0", 426 | "state": -1, 427 | "status": "0", 428 | "comment": "", 429 | }, 430 | { 431 | "name": "Temp sensor 1", 432 | "altid": "m1", 433 | "id": DEVICE_TEMP_SENSOR_ID, 434 | "category": CATEGORY_TEMPERATURE_SENSOR, 435 | "subcategory": 0, 436 | "room": 0, 437 | "parent": 51, 438 | "configured": "0", 439 | "temperature": 57.00, 440 | }, 441 | { 442 | "name": "Dimmer 1", 443 | "altid": "16", 444 | "id": DEVICE_DIMMER_ID, 445 | "category": CATEGORY_DIMMER, 446 | "subcategory": 2, 447 | "room": 0, 448 | "parent": 1, 449 | "kwh": "0.0000", 450 | "watts": "0", 451 | "configured": "1", 452 | "level": "0", 453 | "status": "0", 454 | "state": -1, 455 | "comment": "", 456 | }, 457 | { 458 | "name": "Light 1", 459 | "altid": "16", 460 | "id": DEVICE_LIGHT_ID, 461 | "category": CATEGORY_DIMMER, 462 | "subcategory": 2, 463 | "room": 0, 464 | "parent": 1, 465 | "kwh": "0.0000", 466 | "watts": "0", 467 | "configured": "1", 468 | "level": "0", 469 | "status": "0", 470 | "state": -1, 471 | "comment": "", 472 | }, 473 | { 474 | "name": "Switch 1", 475 | "altid": "5", 476 | "id": DEVICE_SWITCH_ID, 477 | "category": CATEGORY_SWITCH, 478 | "subcategory": 0, 479 | "room": 0, 480 | "parent": 1, 481 | "configured": "1", 482 | "commFailure": "0", 483 | "armedtripped": "1", 484 | "lasttrip": "1561049427", 485 | "tripped": "1", 486 | "armed": "0", 487 | "status": "0", 488 | "state": -1, 489 | "comment": "", 490 | }, 491 | { 492 | "name": "Switch 2", 493 | "altid": "5", 494 | "id": DEVICE_SWITCH2_ID, 495 | "category": CATEGORY_SWITCH, 496 | "subcategory": 0, 497 | "room": 0, 498 | "parent": 1, 499 | "configured": "1", 500 | "commFailure": "0", 501 | "armedtripped": "1", 502 | "lasttrip": "1561049427", 503 | "tripped": "1", 504 | "armed": "0", 505 | "status": "0", 506 | "state": -1, 507 | "comment": "", 508 | }, 509 | { 510 | "name": "Garage door 1", 511 | "altid": "5", 512 | "id": DEVICE_GARAGE_DOOR_ID, 513 | "category": CATEGORY_GARAGE_DOOR, 514 | "subcategory": 0, 515 | "room": 0, 516 | "parent": 1, 517 | "configured": "1", 518 | "commFailure": "0", 519 | "armedtripped": "1", 520 | "lasttrip": "1561049427", 521 | "tripped": "1", 522 | "armed": "0", 523 | "status": "0", 524 | "state": -1, 525 | "comment": "", 526 | }, 527 | { 528 | "name": "Lock 1", 529 | "altid": "5", 530 | "id": DEVICE_LOCK_ID, 531 | "category": CATEGORY_LOCK, 532 | "subcategory": 0, 533 | "room": 0, 534 | "parent": 1, 535 | "configured": "1", 536 | "commFailure": "0", 537 | "armedtripped": "1", 538 | "lasttrip": "1561049427", 539 | "tripped": "1", 540 | "armed": "0", 541 | "status": "0", 542 | "state": -1, 543 | "comment": "", 544 | "locked": "0", 545 | }, 546 | { 547 | "name": "Thermostat 1", 548 | "altid": "5", 549 | "id": DEVICE_THERMOSTAT_ID, 550 | "category": CATEGORY_THERMOSTAT, 551 | "subcategory": 0, 552 | "room": 0, 553 | "parent": 1, 554 | "configured": "1", 555 | "commFailure": "0", 556 | "armedtripped": "1", 557 | "lasttrip": "1561049427", 558 | "tripped": "1", 559 | "armed": "0", 560 | "status": "0", 561 | "state": -1, 562 | "mode": "Off", 563 | "fanmode": "Off", 564 | "hvacstate": "Off", 565 | "setpoint": 8, 566 | "temperature": 9, 567 | "watts": 23, 568 | "comment": "", 569 | }, 570 | { 571 | "name": "Thermostat 2", 572 | "altid": "5", 573 | "id": DEVICE_THERMOSTAT2_ID, 574 | "category": CATEGORY_THERMOSTAT, 575 | "subcategory": 0, 576 | "room": 0, 577 | "parent": 1, 578 | "configured": "1", 579 | "commFailure": "0", 580 | "armedtripped": "1", 581 | "lasttrip": "1561049427", 582 | "tripped": "1", 583 | "armed": "0", 584 | "status": "0", 585 | "state": -1, 586 | "mode": "Off", 587 | "fanmode": "Off", 588 | "hvacstate": "Off", 589 | "heatsp": 7, 590 | "coolsp": 17, 591 | "temperature": 9, 592 | "watts": 23, 593 | "comment": "", 594 | }, 595 | { 596 | "name": "Curtain 1", 597 | "altid": "5", 598 | "id": DEVICE_CURTAIN_ID, 599 | "category": CATEGORY_CURTAIN, 600 | "subcategory": 0, 601 | "room": 0, 602 | "parent": 1, 603 | "configured": "1", 604 | "commFailure": "0", 605 | "armedtripped": "1", 606 | "lasttrip": "1561049427", 607 | "tripped": "1", 608 | "armed": "0", 609 | "status": "0", 610 | "state": -1, 611 | "level": 0, 612 | "comment": "", 613 | }, 614 | { 615 | "name": "Scene 1", 616 | "altid": "5", 617 | "id": DEVICE_SCENE_CONTROLLER_ID, 618 | "category": CATEGORY_SCENE_CONTROLLER, 619 | "subcategory": 0, 620 | "room": 0, 621 | "parent": 1, 622 | "configured": "1", 623 | "commFailure": "0", 624 | "armedtripped": "1", 625 | # "lasttrip": "1561049427", 626 | "tripped": "1", 627 | "armed": "0", 628 | "status": "0", 629 | "state": -1, 630 | "active": 0, 631 | "comment": "", 632 | }, 633 | { 634 | "name": "Alarm sensor 1", 635 | "altid": "18", 636 | "id": DEVICE_ALARM_SENSOR_ID, 637 | "category": CATEGORY_SENSOR, 638 | "subcategory": 0, 639 | "room": 0, 640 | "parent": 1, 641 | "configured": "1", 642 | "batterylevel": "100", 643 | "commFailure": "0", 644 | "armed": "0", 645 | "armedtripped": "0", 646 | "state": -1, 647 | "tripped": "0", 648 | "comment": "", 649 | }, 650 | { 651 | "name": "Light sensor 1", 652 | "altid": "5", 653 | "id": DEVICE_LIGHT_SENSOR_ID, 654 | "category": CATEGORY_LIGHT_SENSOR, 655 | "subcategory": 0, 656 | "room": 0, 657 | "parent": 1, 658 | "configured": "1", 659 | "commFailure": "0", 660 | "armedtripped": "1", 661 | "lasttrip": "1561049427", 662 | "tripped": "1", 663 | "armed": "0", 664 | "status": "0", 665 | "state": -1, 666 | "light": "0", 667 | "comment": "", 668 | }, 669 | { 670 | "name": "UV sensor 1", 671 | "altid": "5", 672 | "id": DEVICE_UV_SENSOR_ID, 673 | "category": CATEGORY_UV_SENSOR, 674 | "subcategory": 0, 675 | "room": 0, 676 | "parent": 1, 677 | "configured": "1", 678 | "commFailure": "0", 679 | "armedtripped": "1", 680 | "lasttrip": "1561049427", 681 | "tripped": "1", 682 | "armed": "0", 683 | "status": "0", 684 | "state": -1, 685 | "light": "0", 686 | "comment": "", 687 | }, 688 | { 689 | "name": "Humidity sensor 1", 690 | "altid": "5", 691 | "id": DEVICE_HUMIDITY_SENSOR_ID, 692 | "category": CATEGORY_HUMIDITY_SENSOR, 693 | "subcategory": 0, 694 | "room": 0, 695 | "parent": 1, 696 | "configured": "1", 697 | "commFailure": "0", 698 | "armedtripped": "1", 699 | "lasttrip": "1561049427", 700 | "tripped": "1", 701 | "armed": "0", 702 | "status": "0", 703 | "state": -1, 704 | "humidity": "0", 705 | "comment": "", 706 | }, 707 | { 708 | "name": "Power meter sensor 1", 709 | "altid": "5", 710 | "id": DEVICE_POWER_METER_SENSOR_ID, 711 | "category": CATEGORY_POWER_METER, 712 | "subcategory": 0, 713 | "room": 0, 714 | "parent": 1, 715 | "configured": "1", 716 | "commFailure": "0", 717 | "armedtripped": "1", 718 | "lasttrip": "1561049427", 719 | "tripped": "1", 720 | "armed": "0", 721 | "status": "0", 722 | "state": -1, 723 | "watts": "0", 724 | "comment": "", 725 | }, 726 | { 727 | "name": "Power meter sensor 1", 728 | "altid": "5", 729 | "id": DEVICE_GENERIC_DEVICE_ID, 730 | "category": CATEGORY_GENERIC, 731 | "subcategory": 0, 732 | "room": 0, 733 | "parent": 1, 734 | "configured": "1", 735 | "commFailure": "0", 736 | "armedtripped": "1", 737 | "lasttrip": "1561049427", 738 | "tripped": "1", 739 | "armed": "0", 740 | "status": "0", 741 | "state": -1, 742 | "watts": "0", 743 | "comment": "", 744 | }, 745 | ], 746 | } 747 | 748 | RESPONSE_STATUS: dict = { 749 | "LoadTime": None, 750 | "DataVersion": 1, 751 | "startup": {"tasks": []}, 752 | "devices": [ 753 | { 754 | "id": DEVICE_DOOR_SENSOR_ID, 755 | "states": [], 756 | "Jobs": [], 757 | "PendingJobs": 0, 758 | "tooltip": {"display": 0}, 759 | "armed": "0", 760 | }, 761 | { 762 | "id": DEVICE_MOTION_SENSOR_ID, 763 | "states": [], 764 | "Jobs": [], 765 | "PendingJobs": 0, 766 | "tooltip": {"display": 0}, 767 | "armed": "0", 768 | }, 769 | { 770 | "id": DEVICE_TEMP_SENSOR_ID, 771 | "states": [], 772 | "Jobs": [], 773 | "PendingJobs": 0, 774 | "tooltip": {"display": 0}, 775 | "status": -1, 776 | }, 777 | { 778 | "id": DEVICE_DIMMER_ID, 779 | "states": [], 780 | "Jobs": [], 781 | "PendingJobs": 0, 782 | "tooltip": {"display": 0}, 783 | "status": -1, 784 | }, 785 | { 786 | "id": DEVICE_LIGHT_ID, 787 | "states": [ 788 | { 789 | "service": "urn:micasaverde-com:serviceId:Color1", 790 | "variable": "CurrentColor", 791 | "value": "I=0,A=0,R=255,G=100,B=100", 792 | }, 793 | { 794 | "service": "urn:micasaverde-com:serviceId:Color1", 795 | "variable": "SupportedColors", 796 | "value": "I,A,R,G,B", 797 | }, 798 | ], 799 | "Jobs": [], 800 | "PendingJobs": 0, 801 | "tooltip": {"display": 0}, 802 | "status": -1, 803 | }, 804 | { 805 | "id": DEVICE_SWITCH_ID, 806 | "states": [], 807 | "Jobs": [], 808 | "PendingJobs": 0, 809 | "tooltip": {"display": 0}, 810 | "status": -1, 811 | }, 812 | { 813 | "id": DEVICE_SWITCH2_ID, 814 | "states": [], 815 | "Jobs": [], 816 | "PendingJobs": 0, 817 | "tooltip": {"display": 0}, 818 | "status": -1, 819 | }, 820 | { 821 | "id": DEVICE_GARAGE_DOOR_ID, 822 | "states": [], 823 | "Jobs": [], 824 | "PendingJobs": 0, 825 | "tooltip": {"display": 0}, 826 | "status": -1, 827 | }, 828 | { 829 | "id": DEVICE_LOCK_ID, 830 | "states": [], 831 | "Jobs": [], 832 | "PendingJobs": 0, 833 | "tooltip": {"display": 0}, 834 | "status": -1, 835 | "locked": "0", 836 | }, 837 | { 838 | "id": DEVICE_THERMOSTAT_ID, 839 | "states": [], 840 | "Jobs": [], 841 | "PendingJobs": 0, 842 | "tooltip": {"display": 0}, 843 | "status": -1, 844 | }, 845 | { 846 | "id": DEVICE_THERMOSTAT2_ID, 847 | "states": [], 848 | "Jobs": [], 849 | "PendingJobs": 0, 850 | "tooltip": {"display": 0}, 851 | "status": -1, 852 | }, 853 | { 854 | "id": DEVICE_CURTAIN_ID, 855 | "states": [], 856 | "Jobs": [], 857 | "PendingJobs": 0, 858 | "tooltip": {"display": 0}, 859 | "status": -1, 860 | }, 861 | { 862 | "id": DEVICE_SCENE_CONTROLLER_ID, 863 | "states": [ 864 | {"service": "", "variable": "LastSceneID", "value": "1234"}, 865 | {"service": "", "variable": "LastSceneTime", "value": "10000012"}, 866 | ], 867 | "Jobs": [], 868 | "PendingJobs": 0, 869 | "tooltip": {"display": 0}, 870 | "status": -1, 871 | }, 872 | { 873 | "id": DEVICE_ALARM_SENSOR_ID, 874 | "states": [], 875 | "Jobs": [], 876 | "PendingJobs": 0, 877 | "tooltip": {"display": 0}, 878 | "status": -1, 879 | }, 880 | { 881 | "id": DEVICE_LIGHT_SENSOR_ID, 882 | "states": [], 883 | "Jobs": [], 884 | "PendingJobs": 0, 885 | "tooltip": {"display": 0}, 886 | "status": -1, 887 | }, 888 | { 889 | "id": DEVICE_UV_SENSOR_ID, 890 | "states": [], 891 | "Jobs": [], 892 | "PendingJobs": 0, 893 | "tooltip": {"display": 0}, 894 | "status": -1, 895 | }, 896 | { 897 | "id": DEVICE_HUMIDITY_SENSOR_ID, 898 | "states": [], 899 | "Jobs": [], 900 | "PendingJobs": 0, 901 | "tooltip": {"display": 0}, 902 | "status": -1, 903 | }, 904 | { 905 | "id": DEVICE_POWER_METER_SENSOR_ID, 906 | "states": [], 907 | "Jobs": [], 908 | "PendingJobs": 0, 909 | "tooltip": {"display": 0}, 910 | "status": -1, 911 | }, 912 | { 913 | "id": DEVICE_GENERIC_DEVICE_ID, 914 | "states": [], 915 | "Jobs": [], 916 | "PendingJobs": 0, 917 | "tooltip": {"display": 0}, 918 | "status": -1, 919 | }, 920 | ], 921 | } 922 | 923 | RESPONSE_LU_SDATA: dict = {"loadtime": None, "dataversion": 1, "devices": []} 924 | 925 | RESPONSE_SCENES: dict = {} 926 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "astroid" 5 | version = "3.0.1" 6 | description = "An abstract syntax tree for Python with inference support." 7 | optional = false 8 | python-versions = ">=3.8.0" 9 | files = [ 10 | {file = "astroid-3.0.1-py3-none-any.whl", hash = "sha256:7d5895c9825e18079c5aeac0572bc2e4c83205c95d416e0b4fee8bc361d2d9ca"}, 11 | {file = "astroid-3.0.1.tar.gz", hash = "sha256:86b0bb7d7da0be1a7c4aedb7974e391b32d4ed89e33de6ed6902b4b15c97577e"}, 12 | ] 13 | 14 | [package.dependencies] 15 | typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} 16 | 17 | [[package]] 18 | name = "black" 19 | version = "23.10.1" 20 | description = "The uncompromising code formatter." 21 | optional = false 22 | python-versions = ">=3.8" 23 | files = [ 24 | {file = "black-23.10.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:ec3f8e6234c4e46ff9e16d9ae96f4ef69fa328bb4ad08198c8cee45bb1f08c69"}, 25 | {file = "black-23.10.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:1b917a2aa020ca600483a7b340c165970b26e9029067f019e3755b56e8dd5916"}, 26 | {file = "black-23.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c74de4c77b849e6359c6f01987e94873c707098322b91490d24296f66d067dc"}, 27 | {file = "black-23.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:7b4d10b0f016616a0d93d24a448100adf1699712fb7a4efd0e2c32bbb219b173"}, 28 | {file = "black-23.10.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b15b75fc53a2fbcac8a87d3e20f69874d161beef13954747e053bca7a1ce53a0"}, 29 | {file = "black-23.10.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:e293e4c2f4a992b980032bbd62df07c1bcff82d6964d6c9496f2cd726e246ace"}, 30 | {file = "black-23.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d56124b7a61d092cb52cce34182a5280e160e6aff3137172a68c2c2c4b76bcb"}, 31 | {file = "black-23.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:3f157a8945a7b2d424da3335f7ace89c14a3b0625e6593d21139c2d8214d55ce"}, 32 | {file = "black-23.10.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:cfcce6f0a384d0da692119f2d72d79ed07c7159879d0bb1bb32d2e443382bf3a"}, 33 | {file = "black-23.10.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:33d40f5b06be80c1bbce17b173cda17994fbad096ce60eb22054da021bf933d1"}, 34 | {file = "black-23.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:840015166dbdfbc47992871325799fd2dc0dcf9395e401ada6d88fe11498abad"}, 35 | {file = "black-23.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:037e9b4664cafda5f025a1728c50a9e9aedb99a759c89f760bd83730e76ba884"}, 36 | {file = "black-23.10.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:7cb5936e686e782fddb1c73f8aa6f459e1ad38a6a7b0e54b403f1f05a1507ee9"}, 37 | {file = "black-23.10.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:7670242e90dc129c539e9ca17665e39a146a761e681805c54fbd86015c7c84f7"}, 38 | {file = "black-23.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ed45ac9a613fb52dad3b61c8dea2ec9510bf3108d4db88422bacc7d1ba1243d"}, 39 | {file = "black-23.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:6d23d7822140e3fef190734216cefb262521789367fbdc0b3f22af6744058982"}, 40 | {file = "black-23.10.1-py3-none-any.whl", hash = "sha256:d431e6739f727bb2e0495df64a6c7a5310758e87505f5f8cde9ff6c0f2d7e4fe"}, 41 | {file = "black-23.10.1.tar.gz", hash = "sha256:1f8ce316753428ff68749c65a5f7844631aa18c8679dfd3ca9dc1a289979c258"}, 42 | ] 43 | 44 | [package.dependencies] 45 | click = ">=8.0.0" 46 | mypy-extensions = ">=0.4.3" 47 | packaging = ">=22.0" 48 | pathspec = ">=0.9.0" 49 | platformdirs = ">=2" 50 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 51 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 52 | 53 | [package.extras] 54 | colorama = ["colorama (>=0.4.3)"] 55 | d = ["aiohttp (>=3.7.4)"] 56 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 57 | uvloop = ["uvloop (>=0.15.2)"] 58 | 59 | [[package]] 60 | name = "certifi" 61 | version = "2023.7.22" 62 | description = "Python package for providing Mozilla's CA Bundle." 63 | optional = false 64 | python-versions = ">=3.6" 65 | files = [ 66 | {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, 67 | {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, 68 | ] 69 | 70 | [[package]] 71 | name = "charset-normalizer" 72 | version = "3.3.1" 73 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 74 | optional = false 75 | python-versions = ">=3.7.0" 76 | files = [ 77 | {file = "charset-normalizer-3.3.1.tar.gz", hash = "sha256:d9137a876020661972ca6eec0766d81aef8a5627df628b664b234b73396e727e"}, 78 | {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8aee051c89e13565c6bd366813c386939f8e928af93c29fda4af86d25b73d8f8"}, 79 | {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:352a88c3df0d1fa886562384b86f9a9e27563d4704ee0e9d56ec6fcd270ea690"}, 80 | {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:223b4d54561c01048f657fa6ce41461d5ad8ff128b9678cfe8b2ecd951e3f8a2"}, 81 | {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f861d94c2a450b974b86093c6c027888627b8082f1299dfd5a4bae8e2292821"}, 82 | {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1171ef1fc5ab4693c5d151ae0fdad7f7349920eabbaca6271f95969fa0756c2d"}, 83 | {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28f512b9a33235545fbbdac6a330a510b63be278a50071a336afc1b78781b147"}, 84 | {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0e842112fe3f1a4ffcf64b06dc4c61a88441c2f02f373367f7b4c1aa9be2ad5"}, 85 | {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f9bc2ce123637a60ebe819f9fccc614da1bcc05798bbbaf2dd4ec91f3e08846"}, 86 | {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f194cce575e59ffe442c10a360182a986535fd90b57f7debfaa5c845c409ecc3"}, 87 | {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9a74041ba0bfa9bc9b9bb2cd3238a6ab3b7618e759b41bd15b5f6ad958d17605"}, 88 | {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b578cbe580e3b41ad17b1c428f382c814b32a6ce90f2d8e39e2e635d49e498d1"}, 89 | {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6db3cfb9b4fcecb4390db154e75b49578c87a3b9979b40cdf90d7e4b945656e1"}, 90 | {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:debb633f3f7856f95ad957d9b9c781f8e2c6303ef21724ec94bea2ce2fcbd056"}, 91 | {file = "charset_normalizer-3.3.1-cp310-cp310-win32.whl", hash = "sha256:87071618d3d8ec8b186d53cb6e66955ef2a0e4fa63ccd3709c0c90ac5a43520f"}, 92 | {file = "charset_normalizer-3.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:e372d7dfd154009142631de2d316adad3cc1c36c32a38b16a4751ba78da2a397"}, 93 | {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae4070f741f8d809075ef697877fd350ecf0b7c5837ed68738607ee0a2c572cf"}, 94 | {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:58e875eb7016fd014c0eea46c6fa92b87b62c0cb31b9feae25cbbe62c919f54d"}, 95 | {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbd95e300367aa0827496fe75a1766d198d34385a58f97683fe6e07f89ca3e3c"}, 96 | {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de0b4caa1c8a21394e8ce971997614a17648f94e1cd0640fbd6b4d14cab13a72"}, 97 | {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:985c7965f62f6f32bf432e2681173db41336a9c2611693247069288bcb0c7f8b"}, 98 | {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a15c1fe6d26e83fd2e5972425a772cca158eae58b05d4a25a4e474c221053e2d"}, 99 | {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae55d592b02c4349525b6ed8f74c692509e5adffa842e582c0f861751701a673"}, 100 | {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4d9c2770044a59715eb57c1144dedea7c5d5ae80c68fb9959515037cde2008"}, 101 | {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:851cf693fb3aaef71031237cd68699dded198657ec1e76a76eb8be58c03a5d1f"}, 102 | {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:31bbaba7218904d2eabecf4feec0d07469284e952a27400f23b6628439439fa7"}, 103 | {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:871d045d6ccc181fd863a3cd66ee8e395523ebfbc57f85f91f035f50cee8e3d4"}, 104 | {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:501adc5eb6cd5f40a6f77fbd90e5ab915c8fd6e8c614af2db5561e16c600d6f3"}, 105 | {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f5fb672c396d826ca16a022ac04c9dce74e00a1c344f6ad1a0fdc1ba1f332213"}, 106 | {file = "charset_normalizer-3.3.1-cp311-cp311-win32.whl", hash = "sha256:bb06098d019766ca16fc915ecaa455c1f1cd594204e7f840cd6258237b5079a8"}, 107 | {file = "charset_normalizer-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:8af5a8917b8af42295e86b64903156b4f110a30dca5f3b5aedea123fbd638bff"}, 108 | {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7ae8e5142dcc7a49168f4055255dbcced01dc1714a90a21f87448dc8d90617d1"}, 109 | {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5b70bab78accbc672f50e878a5b73ca692f45f5b5e25c8066d748c09405e6a55"}, 110 | {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5ceca5876032362ae73b83347be8b5dbd2d1faf3358deb38c9c88776779b2e2f"}, 111 | {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d95638ff3613849f473afc33f65c401a89f3b9528d0d213c7037c398a51296"}, 112 | {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9edbe6a5bf8b56a4a84533ba2b2f489d0046e755c29616ef8830f9e7d9cf5728"}, 113 | {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6a02a3c7950cafaadcd46a226ad9e12fc9744652cc69f9e5534f98b47f3bbcf"}, 114 | {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10b8dd31e10f32410751b3430996f9807fc4d1587ca69772e2aa940a82ab571a"}, 115 | {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edc0202099ea1d82844316604e17d2b175044f9bcb6b398aab781eba957224bd"}, 116 | {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b891a2f68e09c5ef989007fac11476ed33c5c9994449a4e2c3386529d703dc8b"}, 117 | {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:71ef3b9be10070360f289aea4838c784f8b851be3ba58cf796262b57775c2f14"}, 118 | {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:55602981b2dbf8184c098bc10287e8c245e351cd4fdcad050bd7199d5a8bf514"}, 119 | {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:46fb9970aa5eeca547d7aa0de5d4b124a288b42eaefac677bde805013c95725c"}, 120 | {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:520b7a142d2524f999447b3a0cf95115df81c4f33003c51a6ab637cbda9d0bf4"}, 121 | {file = "charset_normalizer-3.3.1-cp312-cp312-win32.whl", hash = "sha256:8ec8ef42c6cd5856a7613dcd1eaf21e5573b2185263d87d27c8edcae33b62a61"}, 122 | {file = "charset_normalizer-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:baec8148d6b8bd5cee1ae138ba658c71f5b03e0d69d5907703e3e1df96db5e41"}, 123 | {file = "charset_normalizer-3.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63a6f59e2d01310f754c270e4a257426fe5a591dc487f1983b3bbe793cf6bac6"}, 124 | {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d6bfc32a68bc0933819cfdfe45f9abc3cae3877e1d90aac7259d57e6e0f85b1"}, 125 | {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f3100d86dcd03c03f7e9c3fdb23d92e32abbca07e7c13ebd7ddfbcb06f5991f"}, 126 | {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39b70a6f88eebe239fa775190796d55a33cfb6d36b9ffdd37843f7c4c1b5dc67"}, 127 | {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e12f8ee80aa35e746230a2af83e81bd6b52daa92a8afaef4fea4a2ce9b9f4fa"}, 128 | {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b6cefa579e1237ce198619b76eaa148b71894fb0d6bcf9024460f9bf30fd228"}, 129 | {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:61f1e3fb621f5420523abb71f5771a204b33c21d31e7d9d86881b2cffe92c47c"}, 130 | {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4f6e2a839f83a6a76854d12dbebde50e4b1afa63e27761549d006fa53e9aa80e"}, 131 | {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:1ec937546cad86d0dce5396748bf392bb7b62a9eeb8c66efac60e947697f0e58"}, 132 | {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:82ca51ff0fc5b641a2d4e1cc8c5ff108699b7a56d7f3ad6f6da9dbb6f0145b48"}, 133 | {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:633968254f8d421e70f91c6ebe71ed0ab140220469cf87a9857e21c16687c034"}, 134 | {file = "charset_normalizer-3.3.1-cp37-cp37m-win32.whl", hash = "sha256:c0c72d34e7de5604df0fde3644cc079feee5e55464967d10b24b1de268deceb9"}, 135 | {file = "charset_normalizer-3.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:63accd11149c0f9a99e3bc095bbdb5a464862d77a7e309ad5938fbc8721235ae"}, 136 | {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5a3580a4fdc4ac05f9e53c57f965e3594b2f99796231380adb2baaab96e22761"}, 137 | {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2465aa50c9299d615d757c1c888bc6fef384b7c4aec81c05a0172b4400f98557"}, 138 | {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb7cd68814308aade9d0c93c5bd2ade9f9441666f8ba5aa9c2d4b389cb5e2a45"}, 139 | {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91e43805ccafa0a91831f9cd5443aa34528c0c3f2cc48c4cb3d9a7721053874b"}, 140 | {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:854cc74367180beb327ab9d00f964f6d91da06450b0855cbbb09187bcdb02de5"}, 141 | {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c15070ebf11b8b7fd1bfff7217e9324963c82dbdf6182ff7050519e350e7ad9f"}, 142 | {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c4c99f98fc3a1835af8179dcc9013f93594d0670e2fa80c83aa36346ee763d2"}, 143 | {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fb765362688821404ad6cf86772fc54993ec11577cd5a92ac44b4c2ba52155b"}, 144 | {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dced27917823df984fe0c80a5c4ad75cf58df0fbfae890bc08004cd3888922a2"}, 145 | {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a66bcdf19c1a523e41b8e9d53d0cedbfbac2e93c649a2e9502cb26c014d0980c"}, 146 | {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ecd26be9f112c4f96718290c10f4caea6cc798459a3a76636b817a0ed7874e42"}, 147 | {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:3f70fd716855cd3b855316b226a1ac8bdb3caf4f7ea96edcccc6f484217c9597"}, 148 | {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:17a866d61259c7de1bdadef418a37755050ddb4b922df8b356503234fff7932c"}, 149 | {file = "charset_normalizer-3.3.1-cp38-cp38-win32.whl", hash = "sha256:548eefad783ed787b38cb6f9a574bd8664468cc76d1538215d510a3cd41406cb"}, 150 | {file = "charset_normalizer-3.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:45f053a0ece92c734d874861ffe6e3cc92150e32136dd59ab1fb070575189c97"}, 151 | {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bc791ec3fd0c4309a753f95bb6c749ef0d8ea3aea91f07ee1cf06b7b02118f2f"}, 152 | {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0c8c61fb505c7dad1d251c284e712d4e0372cef3b067f7ddf82a7fa82e1e9a93"}, 153 | {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2c092be3885a1b7899cd85ce24acedc1034199d6fca1483fa2c3a35c86e43041"}, 154 | {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2000c54c395d9e5e44c99dc7c20a64dc371f777faf8bae4919ad3e99ce5253e"}, 155 | {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4cb50a0335382aac15c31b61d8531bc9bb657cfd848b1d7158009472189f3d62"}, 156 | {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c30187840d36d0ba2893bc3271a36a517a717f9fd383a98e2697ee890a37c273"}, 157 | {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe81b35c33772e56f4b6cf62cf4aedc1762ef7162a31e6ac7fe5e40d0149eb67"}, 158 | {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0bf89afcbcf4d1bb2652f6580e5e55a840fdf87384f6063c4a4f0c95e378656"}, 159 | {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:06cf46bdff72f58645434d467bf5228080801298fbba19fe268a01b4534467f5"}, 160 | {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:3c66df3f41abee950d6638adc7eac4730a306b022570f71dd0bd6ba53503ab57"}, 161 | {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd805513198304026bd379d1d516afbf6c3c13f4382134a2c526b8b854da1c2e"}, 162 | {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:9505dc359edb6a330efcd2be825fdb73ee3e628d9010597aa1aee5aa63442e97"}, 163 | {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:31445f38053476a0c4e6d12b047b08ced81e2c7c712e5a1ad97bc913256f91b2"}, 164 | {file = "charset_normalizer-3.3.1-cp39-cp39-win32.whl", hash = "sha256:bd28b31730f0e982ace8663d108e01199098432a30a4c410d06fe08fdb9e93f4"}, 165 | {file = "charset_normalizer-3.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:555fe186da0068d3354cdf4bbcbc609b0ecae4d04c921cc13e209eece7720727"}, 166 | {file = "charset_normalizer-3.3.1-py3-none-any.whl", hash = "sha256:800561453acdecedaac137bf09cd719c7a440b6800ec182f077bb8e7025fb708"}, 167 | ] 168 | 169 | [[package]] 170 | name = "click" 171 | version = "8.1.7" 172 | description = "Composable command line interface toolkit" 173 | optional = false 174 | python-versions = ">=3.7" 175 | files = [ 176 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 177 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 178 | ] 179 | 180 | [package.dependencies] 181 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 182 | 183 | [[package]] 184 | name = "colorama" 185 | version = "0.4.6" 186 | description = "Cross-platform colored terminal text." 187 | optional = false 188 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 189 | files = [ 190 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 191 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 192 | ] 193 | 194 | [[package]] 195 | name = "coverage" 196 | version = "7.3.2" 197 | description = "Code coverage measurement for Python" 198 | optional = false 199 | python-versions = ">=3.8" 200 | files = [ 201 | {file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"}, 202 | {file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"}, 203 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a"}, 204 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c"}, 205 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f"}, 206 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6"}, 207 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148"}, 208 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9"}, 209 | {file = "coverage-7.3.2-cp310-cp310-win32.whl", hash = "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f"}, 210 | {file = "coverage-7.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611"}, 211 | {file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"}, 212 | {file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"}, 213 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"}, 214 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"}, 215 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"}, 216 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"}, 217 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"}, 218 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"}, 219 | {file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"}, 220 | {file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"}, 221 | {file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"}, 222 | {file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"}, 223 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"}, 224 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"}, 225 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"}, 226 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"}, 227 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"}, 228 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"}, 229 | {file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"}, 230 | {file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"}, 231 | {file = "coverage-7.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738"}, 232 | {file = "coverage-7.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2"}, 233 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2"}, 234 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c"}, 235 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9"}, 236 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82"}, 237 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901"}, 238 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76"}, 239 | {file = "coverage-7.3.2-cp38-cp38-win32.whl", hash = "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92"}, 240 | {file = "coverage-7.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a"}, 241 | {file = "coverage-7.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce"}, 242 | {file = "coverage-7.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9"}, 243 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f"}, 244 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25"}, 245 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9"}, 246 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6"}, 247 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc"}, 248 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083"}, 249 | {file = "coverage-7.3.2-cp39-cp39-win32.whl", hash = "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce"}, 250 | {file = "coverage-7.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f"}, 251 | {file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"}, 252 | {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"}, 253 | ] 254 | 255 | [package.dependencies] 256 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 257 | 258 | [package.extras] 259 | toml = ["tomli"] 260 | 261 | [[package]] 262 | name = "dill" 263 | version = "0.3.7" 264 | description = "serialize all of Python" 265 | optional = false 266 | python-versions = ">=3.7" 267 | files = [ 268 | {file = "dill-0.3.7-py3-none-any.whl", hash = "sha256:76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e"}, 269 | {file = "dill-0.3.7.tar.gz", hash = "sha256:cc1c8b182eb3013e24bd475ff2e9295af86c1a38eb1aff128dac8962a9ce3c03"}, 270 | ] 271 | 272 | [package.extras] 273 | graph = ["objgraph (>=1.7.2)"] 274 | 275 | [[package]] 276 | name = "entrypoints" 277 | version = "0.3" 278 | description = "Discover and load entry points from installed packages." 279 | optional = false 280 | python-versions = ">=2.7" 281 | files = [ 282 | {file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"}, 283 | {file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"}, 284 | ] 285 | 286 | [[package]] 287 | name = "exceptiongroup" 288 | version = "1.1.3" 289 | description = "Backport of PEP 654 (exception groups)" 290 | optional = false 291 | python-versions = ">=3.7" 292 | files = [ 293 | {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, 294 | {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, 295 | ] 296 | 297 | [package.extras] 298 | test = ["pytest (>=6)"] 299 | 300 | [[package]] 301 | name = "flake8" 302 | version = "3.7.8" 303 | description = "the modular source code checker: pep8, pyflakes and co" 304 | optional = false 305 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 306 | files = [ 307 | {file = "flake8-3.7.8-py2.py3-none-any.whl", hash = "sha256:8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"}, 308 | {file = "flake8-3.7.8.tar.gz", hash = "sha256:19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548"}, 309 | ] 310 | 311 | [package.dependencies] 312 | entrypoints = ">=0.3.0,<0.4.0" 313 | mccabe = ">=0.6.0,<0.7.0" 314 | pycodestyle = ">=2.5.0,<2.6.0" 315 | pyflakes = ">=2.1.0,<2.2.0" 316 | 317 | [[package]] 318 | name = "idna" 319 | version = "3.4" 320 | description = "Internationalized Domain Names in Applications (IDNA)" 321 | optional = false 322 | python-versions = ">=3.5" 323 | files = [ 324 | {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, 325 | {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, 326 | ] 327 | 328 | [[package]] 329 | name = "iniconfig" 330 | version = "2.0.0" 331 | description = "brain-dead simple config-ini parsing" 332 | optional = false 333 | python-versions = ">=3.7" 334 | files = [ 335 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 336 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 337 | ] 338 | 339 | [[package]] 340 | name = "isort" 341 | version = "4.3.21" 342 | description = "A Python utility / library to sort Python imports." 343 | optional = false 344 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 345 | files = [ 346 | {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, 347 | {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"}, 348 | ] 349 | 350 | [package.extras] 351 | pipfile = ["pipreqs", "requirementslib"] 352 | pyproject = ["toml"] 353 | requirements = ["pip-api", "pipreqs"] 354 | xdg-home = ["appdirs (>=1.4.0)"] 355 | 356 | [[package]] 357 | name = "mccabe" 358 | version = "0.6.1" 359 | description = "McCabe checker, plugin for flake8" 360 | optional = false 361 | python-versions = "*" 362 | files = [ 363 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 364 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 365 | ] 366 | 367 | [[package]] 368 | name = "mypy" 369 | version = "1.6.1" 370 | description = "Optional static typing for Python" 371 | optional = false 372 | python-versions = ">=3.8" 373 | files = [ 374 | {file = "mypy-1.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e5012e5cc2ac628177eaac0e83d622b2dd499e28253d4107a08ecc59ede3fc2c"}, 375 | {file = "mypy-1.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8fbb68711905f8912e5af474ca8b78d077447d8f3918997fecbf26943ff3cbb"}, 376 | {file = "mypy-1.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a1ad938fee7d2d96ca666c77b7c494c3c5bd88dff792220e1afbebb2925b5e"}, 377 | {file = "mypy-1.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b96ae2c1279d1065413965c607712006205a9ac541895004a1e0d4f281f2ff9f"}, 378 | {file = "mypy-1.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:40b1844d2e8b232ed92e50a4bd11c48d2daa351f9deee6c194b83bf03e418b0c"}, 379 | {file = "mypy-1.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:81af8adaa5e3099469e7623436881eff6b3b06db5ef75e6f5b6d4871263547e5"}, 380 | {file = "mypy-1.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8c223fa57cb154c7eab5156856c231c3f5eace1e0bed9b32a24696b7ba3c3245"}, 381 | {file = "mypy-1.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8032e00ce71c3ceb93eeba63963b864bf635a18f6c0c12da6c13c450eedb183"}, 382 | {file = "mypy-1.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4c46b51de523817a0045b150ed11b56f9fff55f12b9edd0f3ed35b15a2809de0"}, 383 | {file = "mypy-1.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:19f905bcfd9e167159b3d63ecd8cb5e696151c3e59a1742e79bc3bcb540c42c7"}, 384 | {file = "mypy-1.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:82e469518d3e9a321912955cc702d418773a2fd1e91c651280a1bda10622f02f"}, 385 | {file = "mypy-1.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d4473c22cc296425bbbce7e9429588e76e05bc7342da359d6520b6427bf76660"}, 386 | {file = "mypy-1.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59a0d7d24dfb26729e0a068639a6ce3500e31d6655df8557156c51c1cb874ce7"}, 387 | {file = "mypy-1.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cfd13d47b29ed3bbaafaff7d8b21e90d827631afda134836962011acb5904b71"}, 388 | {file = "mypy-1.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:eb4f18589d196a4cbe5290b435d135dee96567e07c2b2d43b5c4621b6501531a"}, 389 | {file = "mypy-1.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:41697773aa0bf53ff917aa077e2cde7aa50254f28750f9b88884acea38a16169"}, 390 | {file = "mypy-1.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7274b0c57737bd3476d2229c6389b2ec9eefeb090bbaf77777e9d6b1b5a9d143"}, 391 | {file = "mypy-1.6.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbaf4662e498c8c2e352da5f5bca5ab29d378895fa2d980630656178bd607c46"}, 392 | {file = "mypy-1.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bb8ccb4724f7d8601938571bf3f24da0da791fe2db7be3d9e79849cb64e0ae85"}, 393 | {file = "mypy-1.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:68351911e85145f582b5aa6cd9ad666c8958bcae897a1bfda8f4940472463c45"}, 394 | {file = "mypy-1.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:49ae115da099dcc0922a7a895c1eec82c1518109ea5c162ed50e3b3594c71208"}, 395 | {file = "mypy-1.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b27958f8c76bed8edaa63da0739d76e4e9ad4ed325c814f9b3851425582a3cd"}, 396 | {file = "mypy-1.6.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:925cd6a3b7b55dfba252b7c4561892311c5358c6b5a601847015a1ad4eb7d332"}, 397 | {file = "mypy-1.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8f57e6b6927a49550da3d122f0cb983d400f843a8a82e65b3b380d3d7259468f"}, 398 | {file = "mypy-1.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:a43ef1c8ddfdb9575691720b6352761f3f53d85f1b57d7745701041053deff30"}, 399 | {file = "mypy-1.6.1-py3-none-any.whl", hash = "sha256:4cbe68ef919c28ea561165206a2dcb68591c50f3bcf777932323bc208d949cf1"}, 400 | {file = "mypy-1.6.1.tar.gz", hash = "sha256:4d01c00d09a0be62a4ca3f933e315455bde83f37f892ba4b08ce92f3cf44bcc1"}, 401 | ] 402 | 403 | [package.dependencies] 404 | mypy-extensions = ">=1.0.0" 405 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 406 | typing-extensions = ">=4.1.0" 407 | 408 | [package.extras] 409 | dmypy = ["psutil (>=4.0)"] 410 | install-types = ["pip"] 411 | reports = ["lxml"] 412 | 413 | [[package]] 414 | name = "mypy-extensions" 415 | version = "1.0.0" 416 | description = "Type system extensions for programs checked with the mypy type checker." 417 | optional = false 418 | python-versions = ">=3.5" 419 | files = [ 420 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 421 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 422 | ] 423 | 424 | [[package]] 425 | name = "packaging" 426 | version = "23.2" 427 | description = "Core utilities for Python packages" 428 | optional = false 429 | python-versions = ">=3.7" 430 | files = [ 431 | {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, 432 | {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, 433 | ] 434 | 435 | [[package]] 436 | name = "pathspec" 437 | version = "0.11.2" 438 | description = "Utility library for gitignore style pattern matching of file paths." 439 | optional = false 440 | python-versions = ">=3.7" 441 | files = [ 442 | {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, 443 | {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, 444 | ] 445 | 446 | [[package]] 447 | name = "platformdirs" 448 | version = "3.11.0" 449 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 450 | optional = false 451 | python-versions = ">=3.7" 452 | files = [ 453 | {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, 454 | {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, 455 | ] 456 | 457 | [package.extras] 458 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] 459 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] 460 | 461 | [[package]] 462 | name = "pluggy" 463 | version = "1.3.0" 464 | description = "plugin and hook calling mechanisms for python" 465 | optional = false 466 | python-versions = ">=3.8" 467 | files = [ 468 | {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, 469 | {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, 470 | ] 471 | 472 | [package.extras] 473 | dev = ["pre-commit", "tox"] 474 | testing = ["pytest", "pytest-benchmark"] 475 | 476 | [[package]] 477 | name = "pycodestyle" 478 | version = "2.5.0" 479 | description = "Python style guide checker" 480 | optional = false 481 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 482 | files = [ 483 | {file = "pycodestyle-2.5.0-py2.py3-none-any.whl", hash = "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56"}, 484 | {file = "pycodestyle-2.5.0.tar.gz", hash = "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"}, 485 | ] 486 | 487 | [[package]] 488 | name = "pydocstyle" 489 | version = "4.0.1" 490 | description = "Python docstring style checker" 491 | optional = false 492 | python-versions = ">=3.4" 493 | files = [ 494 | {file = "pydocstyle-4.0.1-py3-none-any.whl", hash = "sha256:66aff87ffe34b1e49bff2dd03a88ce6843be2f3346b0c9814410d34987fbab59"}, 495 | {file = "pydocstyle-4.0.1.tar.gz", hash = "sha256:04c84e034ebb56eb6396c820442b8c4499ac5eb94a3bda88951ac3dc519b6058"}, 496 | ] 497 | 498 | [package.dependencies] 499 | snowballstemmer = "*" 500 | 501 | [[package]] 502 | name = "pyflakes" 503 | version = "2.1.1" 504 | description = "passive checker of Python programs" 505 | optional = false 506 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 507 | files = [ 508 | {file = "pyflakes-2.1.1-py2.py3-none-any.whl", hash = "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0"}, 509 | {file = "pyflakes-2.1.1.tar.gz", hash = "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"}, 510 | ] 511 | 512 | [[package]] 513 | name = "pylint" 514 | version = "3.0.2" 515 | description = "python code static checker" 516 | optional = false 517 | python-versions = ">=3.8.0" 518 | files = [ 519 | {file = "pylint-3.0.2-py3-none-any.whl", hash = "sha256:60ed5f3a9ff8b61839ff0348b3624ceeb9e6c2a92c514d81c9cc273da3b6bcda"}, 520 | {file = "pylint-3.0.2.tar.gz", hash = "sha256:0d4c286ef6d2f66c8bfb527a7f8a629009e42c99707dec821a03e1b51a4c1496"}, 521 | ] 522 | 523 | [package.dependencies] 524 | astroid = ">=3.0.1,<=3.1.0-dev0" 525 | colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} 526 | dill = [ 527 | {version = ">=0.2", markers = "python_version < \"3.11\""}, 528 | {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, 529 | {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, 530 | ] 531 | isort = ">=4.2.5,<6" 532 | mccabe = ">=0.6,<0.8" 533 | platformdirs = ">=2.2.0" 534 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 535 | tomlkit = ">=0.10.1" 536 | typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} 537 | 538 | [package.extras] 539 | spelling = ["pyenchant (>=3.2,<4.0)"] 540 | testutils = ["gitpython (>3)"] 541 | 542 | [[package]] 543 | name = "pytest" 544 | version = "7.4.2" 545 | description = "pytest: simple powerful testing with Python" 546 | optional = false 547 | python-versions = ">=3.7" 548 | files = [ 549 | {file = "pytest-7.4.2-py3-none-any.whl", hash = "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002"}, 550 | {file = "pytest-7.4.2.tar.gz", hash = "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069"}, 551 | ] 552 | 553 | [package.dependencies] 554 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 555 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 556 | iniconfig = "*" 557 | packaging = "*" 558 | pluggy = ">=0.12,<2.0" 559 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 560 | 561 | [package.extras] 562 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 563 | 564 | [[package]] 565 | name = "pytest-cov" 566 | version = "4.1.0" 567 | description = "Pytest plugin for measuring coverage." 568 | optional = false 569 | python-versions = ">=3.7" 570 | files = [ 571 | {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, 572 | {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, 573 | ] 574 | 575 | [package.dependencies] 576 | coverage = {version = ">=5.2.1", extras = ["toml"]} 577 | pytest = ">=4.6" 578 | 579 | [package.extras] 580 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 581 | 582 | [[package]] 583 | name = "requests" 584 | version = "2.31.0" 585 | description = "Python HTTP for Humans." 586 | optional = false 587 | python-versions = ">=3.7" 588 | files = [ 589 | {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, 590 | {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, 591 | ] 592 | 593 | [package.dependencies] 594 | certifi = ">=2017.4.17" 595 | charset-normalizer = ">=2,<4" 596 | idna = ">=2.5,<4" 597 | urllib3 = ">=1.21.1,<3" 598 | 599 | [package.extras] 600 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 601 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 602 | 603 | [[package]] 604 | name = "responses" 605 | version = "0.10.6" 606 | description = "A utility library for mocking out the `requests` Python library." 607 | optional = false 608 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 609 | files = [ 610 | {file = "responses-0.10.6-py2.py3-none-any.whl", hash = "sha256:97193c0183d63fba8cd3a041c75464e4b09ea0aff6328800d1546598567dde0b"}, 611 | {file = "responses-0.10.6.tar.gz", hash = "sha256:502d9c0c8008439cfcdef7e251f507fcfdd503b56e8c0c87c3c3e3393953f790"}, 612 | ] 613 | 614 | [package.dependencies] 615 | requests = ">=2.0" 616 | six = "*" 617 | 618 | [package.extras] 619 | tests = ["coverage (>=3.7.1,<5.0.0)", "flake8", "pytest", "pytest-cov", "pytest-localserver"] 620 | 621 | [[package]] 622 | name = "six" 623 | version = "1.16.0" 624 | description = "Python 2 and 3 compatibility utilities" 625 | optional = false 626 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 627 | files = [ 628 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 629 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 630 | ] 631 | 632 | [[package]] 633 | name = "snowballstemmer" 634 | version = "2.2.0" 635 | description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." 636 | optional = false 637 | python-versions = "*" 638 | files = [ 639 | {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, 640 | {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, 641 | ] 642 | 643 | [[package]] 644 | name = "toml" 645 | version = "0.10.0" 646 | description = "Python Library for Tom's Obvious, Minimal Language" 647 | optional = false 648 | python-versions = "*" 649 | files = [ 650 | {file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"}, 651 | {file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"}, 652 | ] 653 | 654 | [[package]] 655 | name = "tomli" 656 | version = "2.0.1" 657 | description = "A lil' TOML parser" 658 | optional = false 659 | python-versions = ">=3.7" 660 | files = [ 661 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 662 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 663 | ] 664 | 665 | [[package]] 666 | name = "tomlkit" 667 | version = "0.12.1" 668 | description = "Style preserving TOML library" 669 | optional = false 670 | python-versions = ">=3.7" 671 | files = [ 672 | {file = "tomlkit-0.12.1-py3-none-any.whl", hash = "sha256:712cbd236609acc6a3e2e97253dfc52d4c2082982a88f61b640ecf0817eab899"}, 673 | {file = "tomlkit-0.12.1.tar.gz", hash = "sha256:38e1ff8edb991273ec9f6181244a6a391ac30e9f5098e7535640ea6be97a7c86"}, 674 | ] 675 | 676 | [[package]] 677 | name = "typing-extensions" 678 | version = "4.8.0" 679 | description = "Backported and Experimental Type Hints for Python 3.8+" 680 | optional = false 681 | python-versions = ">=3.8" 682 | files = [ 683 | {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, 684 | {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, 685 | ] 686 | 687 | [[package]] 688 | name = "urllib3" 689 | version = "2.0.7" 690 | description = "HTTP library with thread-safe connection pooling, file post, and more." 691 | optional = false 692 | python-versions = ">=3.7" 693 | files = [ 694 | {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"}, 695 | {file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"}, 696 | ] 697 | 698 | [package.extras] 699 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 700 | secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] 701 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 702 | zstd = ["zstandard (>=0.18.0)"] 703 | 704 | [[package]] 705 | name = "wheel" 706 | version = "0.38.1" 707 | description = "A built-package format for Python" 708 | optional = false 709 | python-versions = ">=3.7" 710 | files = [ 711 | {file = "wheel-0.38.1-py3-none-any.whl", hash = "sha256:7a95f9a8dc0924ef318bd55b616112c70903192f524d120acc614f59547a9e1f"}, 712 | {file = "wheel-0.38.1.tar.gz", hash = "sha256:ea041edf63f4ccba53ad6e035427997b3bb10ee88a4cd014ae82aeb9eea77bb9"}, 713 | ] 714 | 715 | [package.extras] 716 | test = ["pytest (>=3.0.0)"] 717 | 718 | [metadata] 719 | lock-version = "2.0" 720 | python-versions = ">=3.8,<4.0" 721 | content-hash = "2996cb0bb359796ede568fe0a9477d0b4077095f1455414cb5a6bef188455003" 722 | -------------------------------------------------------------------------------- /pyvera/__init__.py: -------------------------------------------------------------------------------- 1 | """Vera Controller Python API. 2 | 3 | This lib is designed to simplify communication with Vera controllers 4 | """ 5 | from abc import ABC, abstractmethod 6 | import collections 7 | from datetime import datetime 8 | import json 9 | import logging 10 | import os 11 | import shlex 12 | import threading 13 | import time 14 | from typing import Any, Callable, DefaultDict, Dict, List, Optional, Tuple, Union, cast 15 | 16 | import requests 17 | 18 | TIMESTAMP_NONE = {"dataversion": 1, "loadtime": 0} 19 | 20 | # Time to block on Vera poll if there are no changes in seconds 21 | SUBSCRIPTION_WAIT = 30 22 | # Min time to wait for event in miliseconds 23 | SUBSCRIPTION_MIN_WAIT = 200 24 | # Timeout for requests calls, as vera sometimes just sits on sockets. 25 | TIMEOUT = SUBSCRIPTION_WAIT 26 | # VeraLock set target timeout in seconds 27 | LOCK_TARGET_TIMEOUT_SEC = 30 28 | 29 | CATEGORY_DIMMER = 2 30 | CATEGORY_SWITCH = 3 31 | CATEGORY_ARMABLE = 4 32 | CATEGORY_THERMOSTAT = 5 33 | CATEGORY_LOCK = 7 34 | CATEGORY_CURTAIN = 8 35 | CATEGORY_REMOTE = 9 36 | CATEGORY_GENERIC = 11 37 | CATEGORY_SENSOR = 12 38 | CATEGORY_SCENE_CONTROLLER = 14 39 | CATEGORY_HUMIDITY_SENSOR = 16 40 | CATEGORY_TEMPERATURE_SENSOR = 17 41 | CATEGORY_LIGHT_SENSOR = 18 42 | CATEGORY_POWER_METER = 21 43 | CATEGORY_VERA_SIREN = 24 44 | CATEGORY_UV_SENSOR = 28 45 | CATEGORY_GARAGE_DOOR = 32 46 | 47 | 48 | # How long to wait before retrying Vera 49 | SUBSCRIPTION_RETRY = 9 50 | 51 | # Vera state codes see http://wiki.micasaverde.com/index.php/Luup_Requests 52 | STATE_NO_JOB = -1 53 | STATE_JOB_WAITING_TO_START = 0 54 | STATE_JOB_IN_PROGRESS = 1 55 | STATE_JOB_ERROR = 2 56 | STATE_JOB_ABORTED = 3 57 | STATE_JOB_DONE = 4 58 | STATE_JOB_WAITING_FOR_CALLBACK = 5 59 | STATE_JOB_REQUEUE = 6 60 | STATE_JOB_PENDING_DATA = 7 61 | 62 | STATE_NOT_PRESENT = 999 63 | 64 | ChangedDevicesValue = Tuple[List[dict], dict] 65 | LockCode = Tuple[str, str, str] 66 | UserCode = Tuple[str, str] 67 | SubscriptionCallback = Callable[["VeraDevice"], None] 68 | 69 | 70 | def init_logging(logger: Any, logger_level: Optional[str]) -> None: 71 | """Initialize the logger.""" 72 | # Set logging level (such as INFO, DEBUG, etc) via an environment variable 73 | # Defaults to WARNING log level unless PYVERA_LOGLEVEL variable exists 74 | if logger_level: 75 | logger.setLevel(logger_level) 76 | log_handler = logging.StreamHandler() 77 | log_handler.setFormatter( 78 | logging.Formatter("%(levelname)s@{%(name)s:%(lineno)d} - %(message)s") 79 | ) 80 | logger.addHandler(log_handler) 81 | 82 | 83 | # Set up the console logger for debugging 84 | LOG = logging.getLogger(__name__) 85 | init_logging(LOG, os.environ.get("PYVERA_LOGLEVEL")) 86 | LOG.debug("DEBUG logging is ON") 87 | 88 | 89 | # pylint: disable=too-many-instance-attributes 90 | class VeraController: 91 | """Class to interact with the Vera device.""" 92 | 93 | temperature_units = "C" 94 | 95 | def __init__( 96 | self, 97 | base_url: str, 98 | subscription_registry: Optional["AbstractSubscriptionRegistry"] = None, 99 | ): 100 | """Init Vera controller at the given URL. 101 | 102 | base_url: Vera API URL, eg http://vera:3480. 103 | """ 104 | 105 | self.base_url = base_url 106 | self.devices: List[VeraDevice] = [] 107 | self.scenes: List[VeraScene] = [] 108 | self.temperature_units = "C" 109 | self.version = None 110 | self.model = None 111 | self.serial_number = None 112 | self.device_services_map: Dict[int, List[dict]] = {} 113 | self.subscription_registry = subscription_registry or SubscriptionRegistry() 114 | self.subscription_registry.set_controller(self) 115 | self.categories: Dict[int, str] = {} 116 | self.device_id_map: Dict[int, VeraDevice] = {} 117 | 118 | def data_request(self, payload: dict, timeout: int = TIMEOUT) -> requests.Response: 119 | """Perform a data_request and return the result.""" 120 | request_url = self.base_url + "/data_request" 121 | response = requests.get(request_url, timeout=timeout, params=payload) 122 | response.encoding = response.encoding if response.encoding else "utf-8" 123 | return response 124 | 125 | def get_simple_devices_info(self) -> None: 126 | """Get basic device info from Vera.""" 127 | j = self.data_request({"id": "sdata"}).json() 128 | 129 | self.scenes = [] 130 | items = j.get("scenes") 131 | 132 | for item in items: 133 | self.scenes.append(VeraScene(item, self)) 134 | 135 | if j.get("temperature"): 136 | self.temperature_units = j.get("temperature") 137 | 138 | self.categories = {} 139 | cats = j.get("categories") 140 | 141 | for cat in cats: 142 | self.categories[cat.get("id")] = cat.get("name") 143 | 144 | self.device_id_map = {} 145 | 146 | devs = j.get("devices") 147 | for dev in devs: 148 | dev["categoryName"] = self.categories.get(dev.get("category")) 149 | self.device_id_map[dev.get("id")] = dev 150 | 151 | def get_scenes(self) -> List["VeraScene"]: 152 | """Get list of scenes.""" 153 | 154 | self.get_simple_devices_info() 155 | 156 | return self.scenes 157 | 158 | def get_device_by_name(self, device_name: str) -> Optional["VeraDevice"]: 159 | """Search the list of connected devices by name. 160 | 161 | device_name param is the string name of the device 162 | """ 163 | 164 | # Find the device for the vera device name we are interested in 165 | found_device = None 166 | for device in self.get_devices(): 167 | if device.name == device_name: 168 | found_device = device 169 | # found the first (and should be only) one so we will finish 170 | break 171 | 172 | if found_device is None: 173 | LOG.debug("Did not find device with %s", device_name) 174 | 175 | return found_device 176 | 177 | def get_device_by_id(self, device_id: int) -> Optional["VeraDevice"]: 178 | """Search the list of connected devices by ID. 179 | 180 | device_id param is the integer ID of the device 181 | """ 182 | 183 | # Find the device for the vera device name we are interested in 184 | found_device = None 185 | for device in self.get_devices(): 186 | if device.device_id == device_id: 187 | found_device = device 188 | # found the first (and should be only) one so we will finish 189 | break 190 | 191 | if found_device is None: 192 | LOG.debug("Did not find device with %s", device_id) 193 | 194 | return found_device 195 | 196 | def get_devices(self, category_filter: str = "") -> List["VeraDevice"]: 197 | """Get list of connected devices. 198 | 199 | category_filter param is an array of strings. If specified, this 200 | function will only return devices with category names which match the 201 | strings in this filter. 202 | """ 203 | 204 | # the Vera rest API is a bit rough so we need to make 2 calls to get 205 | # all the info we need 206 | self.get_simple_devices_info() 207 | 208 | json_data = self.data_request({"id": "status", "output_format": "json"}).json() 209 | 210 | self.devices = [] 211 | items = json_data.get("devices") 212 | alerts = json_data.get("alerts", ()) 213 | 214 | for item in items: 215 | item["deviceInfo"] = self.device_id_map.get(item.get("id")) or {} 216 | item_alerts = [ 217 | alert for alert in alerts if alert.get("PK_Device") == item.get("id") 218 | ] 219 | device_category = item.get("deviceInfo", {}).get("category") 220 | 221 | device: VeraDevice 222 | if device_category == CATEGORY_DIMMER: 223 | device = VeraDimmer(item, item_alerts, self) 224 | elif device_category in (CATEGORY_SWITCH, CATEGORY_VERA_SIREN): 225 | device = VeraSwitch(item, item_alerts, self) 226 | elif device_category == CATEGORY_THERMOSTAT: 227 | device = VeraThermostat(item, item_alerts, self) 228 | elif device_category == CATEGORY_LOCK: 229 | device = VeraLock(item, item_alerts, self) 230 | elif device_category == CATEGORY_CURTAIN: 231 | device = VeraCurtain(item, item_alerts, self) 232 | elif device_category == CATEGORY_ARMABLE: 233 | device = VeraBinarySensor(item, item_alerts, self) 234 | elif device_category in ( 235 | CATEGORY_SENSOR, 236 | CATEGORY_HUMIDITY_SENSOR, 237 | CATEGORY_TEMPERATURE_SENSOR, 238 | CATEGORY_LIGHT_SENSOR, 239 | CATEGORY_POWER_METER, 240 | CATEGORY_UV_SENSOR, 241 | ): 242 | device = VeraSensor(item, item_alerts, self) 243 | elif device_category in (CATEGORY_SCENE_CONTROLLER, CATEGORY_REMOTE): 244 | device = VeraSceneController(item, item_alerts, self) 245 | elif device_category == CATEGORY_GARAGE_DOOR: 246 | device = VeraGarageDoor(item, item_alerts, self) 247 | else: 248 | device = VeraDevice(item, item_alerts, self) 249 | 250 | self.devices.append(device) 251 | 252 | if device.is_armable and device_category not in ( 253 | CATEGORY_SWITCH, 254 | CATEGORY_VERA_SIREN, 255 | CATEGORY_CURTAIN, 256 | CATEGORY_GARAGE_DOOR, 257 | ): 258 | self.devices.append(VeraArmableDevice(item, item_alerts, self)) 259 | 260 | return [ 261 | device 262 | for device in self.devices 263 | if not category_filter 264 | or ( 265 | device.category_name is not None 266 | and device.category_name != "" 267 | and device.category_name in category_filter 268 | ) 269 | ] 270 | 271 | def refresh_data(self) -> Dict[int, "VeraDevice"]: 272 | """Refresh mapping from device ids to devices.""" 273 | # Note: This function is side-effect free and appears to be unused. 274 | # Safe to erase? 275 | 276 | # the Vera rest API is a bit rough so we need to make 2 calls 277 | # to get all the info e need 278 | j = self.data_request({"id": "sdata"}).json() 279 | 280 | self.temperature_units = j.get("temperature", "C") 281 | self.model = j.get("model") 282 | self.version = j.get("version") 283 | self.serial_number = j.get("serial_number") 284 | 285 | categories = {} 286 | cats = j.get("categories") 287 | 288 | for cat in cats: 289 | categories[cat.get("id")] = cat.get("name") 290 | 291 | device_id_map = {} 292 | 293 | devs = j.get("devices") 294 | for dev in devs: 295 | dev["categoryName"] = categories.get(dev.get("category")) 296 | device_id_map[dev.get("id")] = dev 297 | 298 | return device_id_map 299 | 300 | def map_services(self) -> None: 301 | """Get full Vera device service info.""" 302 | # Note: This function updates the device_services_map, but that map does 303 | # not appear to be used. Safe to erase? 304 | self.get_simple_devices_info() 305 | 306 | j = self.data_request({"id": "status", "output_format": "json"}).json() 307 | 308 | service_map = {} 309 | 310 | items = j.get("devices") 311 | 312 | for item in items: 313 | service_map[item.get("id")] = item.get("states") 314 | 315 | self.device_services_map = service_map 316 | 317 | def get_changed_devices(self, timestamp: dict) -> ChangedDevicesValue: 318 | """Get data since last timestamp. 319 | 320 | This function blocks until a change is returned by the Vera, or the 321 | request times out. 322 | 323 | timestamp param: the timestamp returned by the last invocation of this 324 | function. Use a timestamp of TIMESTAMP_NONE for the first invocation. 325 | """ 326 | payload = { 327 | "timeout": SUBSCRIPTION_WAIT, 328 | "minimumdelay": SUBSCRIPTION_MIN_WAIT, 329 | "id": "lu_sdata", 330 | } 331 | payload.update(timestamp) 332 | 333 | # double the timeout here so requests doesn't timeout before vera 334 | LOG.debug("get_changed_devices() requesting payload %s", str(payload)) 335 | response = self.data_request(payload, TIMEOUT * 2) 336 | response.raise_for_status() 337 | 338 | # If the Vera disconnects before writing a full response (as lu_sdata 339 | # will do when interrupted by a Luup reload), the requests module will 340 | # happily return 200 with an empty string. So, test for empty response, 341 | # so we don't rely on the JSON parser to throw an exception. 342 | if response.text == "": 343 | raise PyveraError("Empty response from Vera") 344 | 345 | # Catch a wide swath of what the JSON parser might throw, within 346 | # reason. Unfortunately, some parsers don't specifically return 347 | # json.decode.JSONDecodeError, but so far most seem to derive what 348 | # they do throw from ValueError, so that's helpful. 349 | try: 350 | result = response.json() 351 | except ValueError as ex: 352 | raise PyveraError("JSON decode error: " + str(ex)) 353 | 354 | if not ( 355 | isinstance(result, dict) 356 | and "loadtime" in result 357 | and "dataversion" in result 358 | ): 359 | raise PyveraError("Unexpected/garbled response from Vera") 360 | 361 | # At this point, all good. Update timestamp and return change data. 362 | device_data = result.get("devices", []) 363 | timestamp = { 364 | "loadtime": result.get("loadtime", 0), 365 | "dataversion": result.get("dataversion", 1), 366 | } 367 | return device_data, timestamp 368 | 369 | def get_alerts(self, timestamp: dict) -> List[dict]: 370 | """Get alerts that have triggered since last timestamp. 371 | 372 | Note that unlike get_changed_devices, this is non-blocking. 373 | 374 | timestamp param: the timestamp returned by the prior (not current) 375 | invocation of get_changed_devices. Use a timestamp of TIMESTAMP_NONE 376 | for the first invocation. 377 | """ 378 | 379 | payload = { 380 | "LoadTime": timestamp["loadtime"], 381 | "DataVersion": timestamp["dataversion"], 382 | "id": "status", 383 | } 384 | 385 | LOG.debug("get_alerts() requesting payload %s", str(payload)) 386 | response = self.data_request(payload) 387 | response.raise_for_status() 388 | 389 | if response.text == "": 390 | raise PyveraError("Empty response from Vera") 391 | 392 | try: 393 | result = response.json() 394 | except ValueError as ex: 395 | raise PyveraError("JSON decode error: " + str(ex)) 396 | 397 | if not ( 398 | isinstance(result, dict) 399 | and "LoadTime" in result 400 | and "DataVersion" in result 401 | ): 402 | raise PyveraError("Unexpected/garbled response from Vera") 403 | 404 | return result.get("alerts", []) 405 | 406 | # The subscription thread (if you use it) runs in the background and blocks 407 | # waiting for state changes (a.k.a. events) from the Vera controller. When 408 | # an event occurs, the subscription thread will invoke any callbacks for 409 | # affected devices. 410 | # 411 | # The subscription thread is (obviously) run on a separate thread. This 412 | # means there is a potential for race conditions. Pyvera contains no locks 413 | # or synchronization primitives. To avoid race conditions, clients should 414 | # do the following: 415 | # 416 | # (a) set up Pyvera, including registering any callbacks, before starting 417 | # the subscription thread. 418 | # 419 | # (b) Once the subscription thread has started, realize that callbacks will 420 | # be invoked in the context of the subscription thread. Only access Pyvera 421 | # from those callbacks from that point forwards. 422 | 423 | def start(self) -> None: 424 | """Start the subscription thread.""" 425 | self.subscription_registry.start() 426 | 427 | def stop(self) -> None: 428 | """Stop the subscription thread.""" 429 | self.subscription_registry.stop() 430 | 431 | def register(self, device: "VeraDevice", callback: SubscriptionCallback) -> None: 432 | """Register a device and callback with the subscription service. 433 | 434 | The callback will be called from the subscription thread when the device 435 | is updated. 436 | """ 437 | self.subscription_registry.register(device, callback) 438 | 439 | def unregister(self, device: "VeraDevice", callback: SubscriptionCallback) -> None: 440 | """Unregister a device and callback with the subscription service.""" 441 | self.subscription_registry.unregister(device, callback) 442 | 443 | 444 | # pylint: disable=too-many-public-methods 445 | class VeraDevice: 446 | """Class to represent each vera device.""" 447 | 448 | def __init__( 449 | self, json_obj: dict, json_alerts: List[dict], vera_controller: VeraController 450 | ): 451 | """Init object.""" 452 | self.json_state = json_obj 453 | self.device_id = self.json_state.get("id") 454 | self.vera_controller = vera_controller 455 | self.name = "" 456 | self.alerts: List[VeraAlert] = [] 457 | self.set_alerts(json_alerts) 458 | 459 | if self.json_state.get("deviceInfo"): 460 | device_info = self.json_state.get("deviceInfo", {}) 461 | self.category = device_info.get("category") 462 | self.category_name = device_info.get("categoryName") 463 | self.name = device_info.get("name") 464 | else: 465 | self.category_name = "" 466 | 467 | if not self.name: 468 | if self.category_name: 469 | self.name = "Vera " + self.category_name + " " + str(self.device_id) 470 | else: 471 | self.name = "Vera Device " + str(self.device_id) 472 | 473 | def __repr__(self) -> str: 474 | """Get a string representation.""" 475 | return f"{self.__class__.__name__} (id={self.device_id} category={self.category_name} name={self.name})" 476 | 477 | @property 478 | def switch_service(self) -> str: 479 | """Vera service string for switch.""" 480 | return "urn:upnp-org:serviceId:SwitchPower1" 481 | 482 | @property 483 | def dimmer_service(self) -> str: 484 | """Vera service string for dimmer.""" 485 | return "urn:upnp-org:serviceId:Dimming1" 486 | 487 | @property 488 | def security_sensor_service(self) -> str: 489 | """Vera service string for armable sensors.""" 490 | return "urn:micasaverde-com:serviceId:SecuritySensor1" 491 | 492 | @property 493 | def window_covering_service(self) -> str: 494 | """Vera service string for window covering service.""" 495 | return "urn:upnp-org:serviceId:WindowCovering1" 496 | 497 | @property 498 | def lock_service(self) -> str: 499 | """Vera service string for lock service.""" 500 | return "urn:micasaverde-com:serviceId:DoorLock1" 501 | 502 | @property 503 | def thermostat_operating_service(self) -> Tuple[str]: 504 | """Vera service string HVAC operating mode.""" 505 | return ("urn:upnp-org:serviceId:HVAC_UserOperatingMode1",) 506 | 507 | @property 508 | def thermostat_fan_service(self) -> str: 509 | """Vera service string HVAC fan operating mode.""" 510 | return "urn:upnp-org:serviceId:HVAC_FanOperatingMode1" 511 | 512 | @property 513 | def thermostat_cool_setpoint(self) -> str: 514 | """Vera service string Temperature Setpoint1 Cool.""" 515 | return "urn:upnp-org:serviceId:TemperatureSetpoint1_Cool" 516 | 517 | @property 518 | def thermostat_heat_setpoint(self) -> str: 519 | """Vera service string Temperature Setpoint Heat.""" 520 | return "urn:upnp-org:serviceId:TemperatureSetpoint1_Heat" 521 | 522 | @property 523 | def thermostat_setpoint(self) -> str: 524 | """Vera service string Temperature Setpoint.""" 525 | return "urn:upnp-org:serviceId:TemperatureSetpoint1" 526 | 527 | @property 528 | def color_service(self) -> str: 529 | """Vera service string for color.""" 530 | return "urn:micasaverde-com:serviceId:Color1" 531 | 532 | @property 533 | def poll_service(self) -> str: 534 | """Vera service string for poll.""" 535 | return "urn:micasaverde-com:serviceId:HaDevice1" 536 | 537 | def vera_request(self, **kwargs: Any) -> requests.Response: 538 | """Perfom a vera_request for this device.""" 539 | request_payload = {"output_format": "json", "DeviceNum": self.device_id} 540 | request_payload.update(kwargs) 541 | 542 | return self.vera_controller.data_request(request_payload) 543 | 544 | def set_service_value( 545 | self, 546 | service_id: Union[str, Tuple[str, ...]], 547 | set_name: str, 548 | parameter_name: str, 549 | value: Any, 550 | ) -> None: 551 | """Set a variable on the vera device. 552 | 553 | This will call the Vera api to change device state. 554 | """ 555 | payload = { 556 | "id": "lu_action", 557 | "action": "Set" + set_name, 558 | "serviceId": service_id, 559 | parameter_name: value, 560 | } 561 | result = self.vera_request(**payload) 562 | LOG.debug( 563 | "set_service_value: " "result of vera_request with payload %s: %s", 564 | payload, 565 | result.text, 566 | ) 567 | 568 | def set_door_code_values( 569 | self, service_id: Union[str, Tuple[str, ...]], operation: str, parameter: dict 570 | ) -> requests.Response: 571 | """Add or remove door code on the vera Lock. 572 | 573 | This will call the Vera api to change Lock code. 574 | """ 575 | payload = {"id": "lu_action", "action": operation, "serviceId": service_id} 576 | for param in parameter: 577 | payload[param] = parameter[param] 578 | result = self.vera_request(**payload) 579 | LOG.debug( 580 | "set_door_code_values: " "result of vera_request with payload %s: %s", 581 | payload, 582 | result.text, 583 | ) 584 | return result 585 | 586 | def call_service(self, service_id: str, action: str) -> requests.Response: 587 | """Call a Vera service. 588 | 589 | This will call the Vera api to change device state. 590 | """ 591 | result = self.vera_request(id="action", serviceId=service_id, action=action) 592 | LOG.debug( 593 | "call_service: " "result of vera_request for %s with id %s: %s", 594 | self.name, 595 | service_id, 596 | result.text, 597 | ) 598 | return result 599 | 600 | def poll_device(self) -> None: 601 | """Poll the device to try and connect.""" 602 | self.call_service(self.poll_service, "Poll") 603 | 604 | def set_cache_value(self, name: str, value: Any) -> None: 605 | """Set a variable in the local state dictionary. 606 | 607 | This does not change the physical device. Useful if you want the 608 | device state to refect a new value which has not yet updated from 609 | Vera. 610 | """ 611 | dev_info = self.json_state.get("deviceInfo", {}) 612 | if dev_info.get(name.lower()) is None: 613 | LOG.error("Could not set %s for %s (key does not exist).", name, self.name) 614 | LOG.error("- dictionary %s", dev_info) 615 | return 616 | dev_info[name.lower()] = str(value) 617 | 618 | def set_cache_complex_value(self, name: str, value: Any) -> None: 619 | """Set a variable in the local complex state dictionary. 620 | 621 | This does not change the physical device. Useful if you want the 622 | device state to refect a new value which has not yet updated from 623 | Vera. 624 | """ 625 | for item in self.json_state.get("states", []): 626 | if item.get("variable") == name: 627 | item["value"] = str(value) 628 | 629 | def get_complex_value(self, name: str) -> Any: 630 | """Get a value from the service dictionaries. 631 | 632 | It's best to use get_value if it has the data you require since 633 | the vera subscription only updates data in dev_info. 634 | """ 635 | for item in self.json_state.get("states", []): 636 | if item.get("variable") == name: 637 | return item.get("value") 638 | return None 639 | 640 | def get_all_values(self) -> dict: 641 | """Get all values from the deviceInfo area. 642 | 643 | The deviceInfo data is updated by the subscription service. 644 | """ 645 | return cast(dict, self.json_state.get("deviceInfo")) 646 | 647 | def get_value(self, name: str) -> Any: 648 | """Get a value from the dev_info area. 649 | 650 | This is the common Vera data and is the best place to get state from 651 | if it has the data you require. 652 | 653 | This data is updated by the subscription service. 654 | """ 655 | return self.get_strict_value(name.lower()) 656 | 657 | def get_strict_value(self, name: str) -> Any: 658 | """Get a case-sensitive keys value from the dev_info area.""" 659 | dev_info = self.json_state.get("deviceInfo", {}) 660 | return dev_info.get(name, None) 661 | 662 | def refresh_complex_value(self, name: str) -> Any: 663 | """Refresh a value from the service dictionaries. 664 | 665 | It's best to use get_value / refresh if it has the data you need. 666 | """ 667 | for item in self.json_state.get("states", []): 668 | if item.get("variable") == name: 669 | service_id = item.get("service") 670 | result = self.vera_request( 671 | **{ 672 | "id": "variableget", 673 | "output_format": "json", 674 | "DeviceNum": self.device_id, 675 | "serviceId": service_id, 676 | "Variable": name, 677 | } 678 | ) 679 | item["value"] = result.text 680 | return item.get("value") 681 | return None 682 | 683 | def set_alerts(self, json_alerts: List[dict]) -> None: 684 | """Convert JSON alert data to VeraAlerts.""" 685 | self.alerts = [VeraAlert(json_alert, self) for json_alert in json_alerts] 686 | 687 | def get_alerts(self) -> List["VeraAlert"]: 688 | """Get any alerts present during the most recent poll cycle.""" 689 | return self.alerts 690 | 691 | def refresh(self) -> None: 692 | """Refresh the dev_info data used by get_value. 693 | 694 | Only needed if you're not using subscriptions. 695 | """ 696 | j = self.vera_request(id="sdata", output_format="json").json() 697 | devices = j.get("devices") 698 | for device_data in devices: 699 | if device_data.get("id") == self.device_id: 700 | self.update(device_data) 701 | 702 | def update(self, params: dict) -> None: 703 | """Update the dev_info data from a dictionary. 704 | 705 | Only updates if it already exists in the device. 706 | """ 707 | dev_info = self.json_state.get("deviceInfo", {}) 708 | dev_info.update({k: params[k] for k in params if dev_info.get(k)}) 709 | 710 | @property 711 | def is_armable(self) -> bool: 712 | """Device is armable.""" 713 | return self.get_value("Armed") is not None 714 | 715 | @property 716 | def is_armed(self) -> bool: 717 | """Device is armed now.""" 718 | return cast(str, self.get_value("Armed")) == "1" 719 | 720 | @property 721 | def is_dimmable(self) -> bool: 722 | """Device is dimmable.""" 723 | return cast(int, self.category) == CATEGORY_DIMMER 724 | 725 | @property 726 | def is_trippable(self) -> bool: 727 | """Device is trippable.""" 728 | return self.get_value("Tripped") is not None 729 | 730 | @property 731 | def is_tripped(self) -> bool: 732 | """Device is tripped now.""" 733 | return cast(str, self.get_value("Tripped")) == "1" 734 | 735 | @property 736 | def has_battery(self) -> bool: 737 | """Device has a battery.""" 738 | return self.get_value("BatteryLevel") is not None 739 | 740 | @property 741 | def battery_level(self) -> int: 742 | """Battery level as a percentage.""" 743 | return cast(int, self.get_value("BatteryLevel")) 744 | 745 | @property 746 | def last_trip(self) -> str: 747 | """Time device last tripped.""" 748 | # Vera seems not to update this for my device! 749 | return cast(str, self.get_value("LastTrip")) 750 | 751 | @property 752 | def light(self) -> int: 753 | """Light level in lux.""" 754 | return cast(int, self.get_value("Light")) 755 | 756 | @property 757 | def level(self) -> int: 758 | """Get level from vera.""" 759 | # Used for dimmers, curtains 760 | # Have seen formats of 10, 0.0 and "0%"! 761 | level = self.get_value("level") 762 | try: 763 | return int(float(level)) 764 | except (TypeError, ValueError): 765 | pass 766 | try: 767 | return int(level.strip("%")) 768 | except (TypeError, AttributeError, ValueError): 769 | pass 770 | return 0 771 | 772 | @property 773 | def temperature(self) -> float: 774 | """Get the temperature. 775 | 776 | You can get units from the controller. 777 | """ 778 | return cast(float, self.get_value("Temperature")) 779 | 780 | @property 781 | def humidity(self) -> float: 782 | """Get the humidity level in percent.""" 783 | return cast(float, self.get_value("Humidity")) 784 | 785 | @property 786 | def power(self) -> int: 787 | """Get the current power useage in watts.""" 788 | return cast(int, self.get_value("Watts")) 789 | 790 | @property 791 | def energy(self) -> int: 792 | """Get the energy usage in kwh.""" 793 | return cast(int, self.get_value("kwh")) 794 | 795 | @property 796 | def room_id(self) -> int: 797 | """Get the Vera Room ID.""" 798 | return cast(int, self.get_value("room")) 799 | 800 | @property 801 | def comm_failure(self) -> bool: 802 | """Return the Communication Failure Flag.""" 803 | status = self.get_strict_value("commFailure") 804 | if status is None: 805 | return False 806 | return cast(str, status) != "0" 807 | 808 | @property 809 | def vera_device_id(self) -> int: 810 | """Get the ID Vera uses to refer to the device.""" 811 | return cast(int, self.device_id) 812 | 813 | @property 814 | def should_poll(self) -> bool: 815 | """Whether polling is needed if using subscriptions for this device.""" 816 | return self.comm_failure 817 | 818 | 819 | class VeraSwitch(VeraDevice): 820 | """Class to add switch functionality.""" 821 | 822 | def set_switch_state(self, state: int) -> None: 823 | """Set the switch state, also update local state.""" 824 | self.set_service_value(self.switch_service, "Target", "newTargetValue", state) 825 | self.set_cache_value("Status", state) 826 | 827 | def switch_on(self) -> None: 828 | """Turn the switch on, also update local state.""" 829 | self.set_switch_state(1) 830 | 831 | def switch_off(self) -> None: 832 | """Turn the switch off, also update local state.""" 833 | self.set_switch_state(0) 834 | 835 | def is_switched_on(self, refresh: bool = False) -> bool: 836 | """Get switch state. 837 | 838 | Refresh data from Vera if refresh is True, otherwise use local cache. 839 | Refresh is only needed if you're not using subscriptions. 840 | """ 841 | if refresh: 842 | self.refresh() 843 | val = self.get_value("Status") 844 | return cast(str, val) == "1" 845 | 846 | 847 | class VeraDimmer(VeraSwitch): 848 | """Class to add dimmer functionality.""" 849 | 850 | def get_brightness(self, refresh: bool = False) -> int: 851 | """Get dimmer brightness. 852 | 853 | Refresh data from Vera if refresh is True, otherwise use local cache. 854 | Refresh is only needed if you're not using subscriptions. 855 | Converts the Vera level property for dimmable lights from a percentage 856 | to the 0 - 255 scale used by HA. 857 | """ 858 | if refresh: 859 | self.refresh() 860 | brightness = 0 861 | percent = self.level 862 | if percent > 0: 863 | brightness = round(percent * 2.55) 864 | return int(brightness) 865 | 866 | def set_brightness(self, brightness: int) -> None: 867 | """Set dimmer brightness. 868 | 869 | Converts the Vera level property for dimmable lights from a percentage 870 | to the 0 - 255 scale used by HA. 871 | """ 872 | percent = 0 873 | if brightness > 0: 874 | percent = round(brightness / 2.55) 875 | 876 | self.set_service_value( 877 | self.dimmer_service, "LoadLevelTarget", "newLoadlevelTarget", percent 878 | ) 879 | self.set_cache_value("level", percent) 880 | 881 | def get_color_index( 882 | self, colors: List[str], refresh: bool = False 883 | ) -> Optional[List[int]]: 884 | """Get color index. 885 | 886 | Refresh data from Vera if refresh is True, otherwise use local cache. 887 | """ 888 | if refresh: 889 | self.refresh_complex_value("SupportedColors") 890 | 891 | sup = self.get_complex_value("SupportedColors") 892 | if sup is None: 893 | return None 894 | 895 | sup = sup.split(",") 896 | if not set(colors).issubset(sup): 897 | return None 898 | 899 | return [sup.index(c) for c in colors] 900 | 901 | def get_color(self, refresh: bool = False) -> Optional[List[int]]: 902 | """Get color. 903 | 904 | Refresh data from Vera if refresh is True, otherwise use local cache. 905 | """ 906 | if refresh: 907 | self.refresh_complex_value("CurrentColor") 908 | 909 | color_index = self.get_color_index(["R", "G", "B"], refresh) 910 | cur = self.get_complex_value("CurrentColor") 911 | if color_index is None or cur is None: 912 | return None 913 | 914 | try: 915 | val = [cur.split(",")[c] for c in color_index] 916 | return [int(v.split("=")[1]) for v in val] 917 | except IndexError: 918 | return None 919 | 920 | def set_color(self, rgb: List[int]) -> None: 921 | """Set dimmer color.""" 922 | 923 | target = ",".join([str(c) for c in rgb]) 924 | self.set_service_value( 925 | self.color_service, "ColorRGB", "newColorRGBTarget", target 926 | ) 927 | 928 | rgbi = self.get_color_index(["R", "G", "B"]) 929 | if rgbi is None: 930 | return 931 | 932 | target = ( 933 | "0=0,1=0," 934 | + str(rgbi[0]) 935 | + "=" 936 | + str(rgb[0]) 937 | + "," 938 | + str(rgbi[1]) 939 | + "=" 940 | + str(rgb[1]) 941 | + "," 942 | + str(rgbi[2]) 943 | + "=" 944 | + str(rgb[2]) 945 | ) 946 | self.set_cache_complex_value("CurrentColor", target) 947 | 948 | 949 | class VeraArmableDevice(VeraSwitch): 950 | """Class to represent a device that can be armed.""" 951 | 952 | def set_armed_state(self, state: int) -> None: 953 | """Set the armed state, also update local state.""" 954 | self.set_service_value( 955 | self.security_sensor_service, "Armed", "newArmedValue", state 956 | ) 957 | self.set_cache_value("Armed", state) 958 | 959 | def switch_on(self) -> None: 960 | """Arm the device.""" 961 | self.set_armed_state(1) 962 | 963 | def switch_off(self) -> None: 964 | """Disarm the device.""" 965 | self.set_armed_state(0) 966 | 967 | def is_switched_on(self, refresh: bool = False) -> bool: 968 | """Get armed state. 969 | 970 | Refresh data from Vera if refresh is True, otherwise use local cache. 971 | Refresh is only needed if you're not using subscriptions. 972 | """ 973 | if refresh: 974 | self.refresh() 975 | val = self.get_value("Armed") 976 | return cast(str, val) == "1" 977 | 978 | 979 | class VeraSensor(VeraDevice): 980 | """Class to represent a supported sensor.""" 981 | 982 | 983 | class VeraBinarySensor(VeraDevice): 984 | """Class to represent an on / off sensor.""" 985 | 986 | def is_switched_on(self, refresh: bool = False) -> bool: 987 | """Get sensor on off state. 988 | 989 | Refresh data from Vera if refresh is True, otherwise use local cache. 990 | Refresh is only needed if you're not using subscriptions. 991 | """ 992 | if refresh: 993 | self.refresh() 994 | val = self.get_value("Status") 995 | return cast(str, val) == "1" 996 | 997 | 998 | class VeraCurtain(VeraSwitch): 999 | """Class to add curtains functionality.""" 1000 | 1001 | def open(self) -> None: 1002 | """Open the curtains.""" 1003 | self.set_level(100) 1004 | 1005 | def close(self) -> None: 1006 | """Close the curtains.""" 1007 | self.set_level(0) 1008 | 1009 | def stop(self) -> int: 1010 | """Open the curtains.""" 1011 | self.call_service(self.window_covering_service, "Stop") 1012 | return cast(int, self.get_level(True)) 1013 | 1014 | def is_open(self, refresh: bool = False) -> bool: 1015 | """Get curtains state. 1016 | 1017 | Refresh data from Vera if refresh is True, otherwise use local cache. 1018 | Refresh is only needed if you're not using subscriptions. 1019 | """ 1020 | if refresh: 1021 | self.refresh() 1022 | return self.get_level(refresh) > 0 1023 | 1024 | def get_level(self, refresh: bool = False) -> int: 1025 | """Get open level of the curtains. 1026 | 1027 | Refresh data from Vera if refresh is True, otherwise use local cache. 1028 | Refresh is only needed if you're not using subscriptions. 1029 | Scale is 0-100 1030 | """ 1031 | if refresh: 1032 | self.refresh() 1033 | return self.level 1034 | 1035 | def set_level(self, level: int) -> None: 1036 | """Set open level of the curtains. 1037 | 1038 | Scale is 0-100 1039 | """ 1040 | self.set_service_value( 1041 | self.dimmer_service, "LoadLevelTarget", "newLoadlevelTarget", level 1042 | ) 1043 | 1044 | self.set_cache_value("level", level) 1045 | 1046 | 1047 | class VeraLock(VeraDevice): 1048 | """Class to represent a door lock.""" 1049 | 1050 | # target locked (state, time) 1051 | # this is used since sdata does not return proper job status for locks 1052 | lock_target = None 1053 | 1054 | def set_lock_state(self, state: int) -> None: 1055 | """Set the lock state, also update local state.""" 1056 | self.set_service_value(self.lock_service, "Target", "newTargetValue", state) 1057 | self.set_cache_value("locked", state) 1058 | self.lock_target = (str(state), time.time()) 1059 | 1060 | def set_new_pin(self, name: str, pin: int) -> requests.Response: 1061 | """Set the lock state, also update local state.""" 1062 | return self.set_door_code_values( 1063 | self.lock_service, "SetPin", {"UserCodeName": name, "newPin": pin} 1064 | ) 1065 | 1066 | def clear_slot_pin(self, slot: int) -> requests.Response: 1067 | """Set the lock state, also update local state.""" 1068 | return self.set_door_code_values( 1069 | self.lock_service, "ClearPin", {"UserCode": slot} 1070 | ) 1071 | 1072 | def lock(self) -> None: 1073 | """Lock the door.""" 1074 | self.set_lock_state(1) 1075 | 1076 | def unlock(self) -> None: 1077 | """Unlock the device.""" 1078 | self.set_lock_state(0) 1079 | 1080 | def is_locked(self, refresh: bool = False) -> bool: 1081 | """Get locked state. 1082 | 1083 | Refresh data from Vera if refresh is True, otherwise use local cache. 1084 | Refresh is only needed if you're not using subscriptions. 1085 | Lock state can also be found with self.get_complex_value('Status') 1086 | """ 1087 | if refresh: 1088 | self.refresh() 1089 | 1090 | # if the lock target matches now 1091 | # or the locking action took too long 1092 | # then reset the target and time 1093 | now = time.time() 1094 | if self.lock_target is not None and ( 1095 | self.lock_target[0] == self.get_value("locked") 1096 | or now - self.lock_target[1] >= LOCK_TARGET_TIMEOUT_SEC 1097 | ): 1098 | LOG.debug( 1099 | "Resetting lock target for %s (%s==%s, %s - %s >= %s)", 1100 | self.name, 1101 | self.lock_target[0], 1102 | self.get_value("locked"), 1103 | now, 1104 | self.lock_target[1], 1105 | LOCK_TARGET_TIMEOUT_SEC, 1106 | ) 1107 | self.lock_target = None 1108 | 1109 | locked = cast(str, self.get_value("locked")) == "1" 1110 | if self.lock_target is not None: 1111 | locked = cast(str, self.lock_target[0]) == "1" 1112 | LOG.debug("Lock still in progress for %s: target=%s", self.name, locked) 1113 | return locked 1114 | 1115 | @staticmethod 1116 | def _parse_usercode(user_code: str) -> Optional[UserCode]: 1117 | # Syntax string: UserID="" UserName="" 1118 | # See http://wiki.micasaverde.com/index.php/Luup_UPnP_Variables_and_Actions#DoorLock1 1119 | 1120 | try: 1121 | # Get the UserID="" and UserName="" fields separately 1122 | raw_userid, raw_username = shlex.split(user_code) 1123 | # Get the right hand value of UserID= 1124 | userid = raw_userid.split("=")[1] 1125 | # Get the right hand value of UserName= 1126 | username = raw_username.split("=")[1] 1127 | # pylint: disable=broad-except 1128 | except Exception as ex: 1129 | LOG.error("Got unsupported user string %s: %s", user_code, ex) 1130 | return None 1131 | return (userid, username) 1132 | 1133 | def get_last_user(self, refresh: bool = False) -> Optional[UserCode]: 1134 | """Get the last used PIN user id. 1135 | 1136 | This is sadly not as useful as it could be. It will tell you the last 1137 | PIN used -- but if the lock is unlocked, you have no idea if a PIN was 1138 | used or just someone used a key or the knob. So it is not possible to 1139 | use this API to determine *when* a PIN was used. 1140 | """ 1141 | if refresh: 1142 | self.refresh_complex_value("sl_UserCode") 1143 | val = str(self.get_complex_value("sl_UserCode")) 1144 | 1145 | user = self._parse_usercode(val) 1146 | return user 1147 | 1148 | def get_last_user_alert(self) -> Optional[UserCode]: 1149 | """Get the PIN used for the action in the last poll cycle. 1150 | 1151 | Unlike get_last_user(), this function only returns a result when the 1152 | last action taken (such as an unlock) used a PIN. So this is useful for 1153 | triggering events when a paritcular PIN is used. Since it relies on the 1154 | poll cycle, this function is a no-op if subscriptions are not used. 1155 | """ 1156 | for alert in self.alerts: 1157 | if alert.code == "DL_USERCODE": 1158 | user = self._parse_usercode(alert.value) 1159 | return user 1160 | return None 1161 | 1162 | def get_low_battery_alert(self) -> int: 1163 | """See if a low battery alert was issued in the last poll cycle.""" 1164 | for alert in self.alerts: 1165 | if alert.code == "DL_LOW_BATTERY": 1166 | return 1 1167 | return 0 1168 | 1169 | # The following three functions are less useful than you might think. Once 1170 | # a user enters a bad PIN code, get_pin_failed() appears to remain True 1171 | # forever (or at least, until you reboot the Vera?). Similarly, 1172 | # get_unauth_user(), and get_lock_failed() don't appear to reset. 1173 | # get_last_user() also has this property -- but get_last_user_alert() is 1174 | # more useful. 1175 | # 1176 | # We could implement this as a destructive read -- unset the variables on 1177 | # the Vera after we read them. But this assumes the Vera only has a single 1178 | # client using this API (otherwise the two clients would interfere with each 1179 | # other). Also, this technique has an unavoidable race condition -- what if 1180 | # the Vera updates the variable after we've read it but before we clear it? 1181 | # 1182 | # The fundamental problem is with the HTTP API to the Vera. On the Vera 1183 | # itself you can observe when a variable is written (or overwritten, even 1184 | # with an identical value) by using the Lua function luup.variable_watch(). 1185 | # No equivalent appears to exist in the HTTP API. 1186 | 1187 | def get_pin_failed(self, refresh: bool = False) -> bool: 1188 | """Get if pin failed. True when a bad PIN code was entered.""" 1189 | if refresh: 1190 | self.refresh_complex_value("sl_PinFailed") 1191 | return cast(str, self.get_complex_value("sl_PinFailed")) == "1" 1192 | 1193 | def get_unauth_user(self, refresh: bool = False) -> bool: 1194 | """Get unauth user state. True when a user code entered was outside of a valid date.""" 1195 | if refresh: 1196 | self.refresh_complex_value("sl_UnauthUser") 1197 | return cast(str, self.get_complex_value("sl_UnauthUser")) == "1" 1198 | 1199 | def get_lock_failed(self, refresh: bool = False) -> bool: 1200 | """Get lock failed state. True when the lock fails to operate.""" 1201 | if refresh: 1202 | self.refresh_complex_value("sl_LockFailure") 1203 | return cast(str, self.get_complex_value("sl_LockFailure")) == "1" 1204 | 1205 | def get_pin_codes(self, refresh: bool = False) -> List[LockCode]: 1206 | """Get the list of PIN codes. 1207 | 1208 | Codes can also be found with self.get_complex_value('PinCodes') 1209 | """ 1210 | if refresh: 1211 | self.refresh() 1212 | val = self.get_value("pincodes") 1213 | 1214 | # val syntax string: next_available_user_code_id\tuser_code_id,active,date_added,date_used,PIN_code,name;\t... 1215 | # See (outdated) http://wiki.micasaverde.com/index.php/Luup_UPnP_Variables_and_Actions#DoorLock1 1216 | 1217 | # Remove the trailing tab 1218 | # ignore the version and next available at the start 1219 | # and split out each set of code attributes 1220 | raw_code_list: List[str] = [] 1221 | try: 1222 | raw_code_list = val.rstrip().split("\t")[1:] 1223 | # pylint: disable=broad-except 1224 | except Exception as ex: 1225 | LOG.error("Got unsupported string %s: %s", val, ex) 1226 | 1227 | # Loop to create a list of codes 1228 | codes = [] 1229 | for code in raw_code_list: 1230 | try: 1231 | # Strip off trailing semicolon 1232 | # Create a list from csv 1233 | code_addrs = code.split(";")[0].split(",") 1234 | 1235 | # Get the code ID (slot) and see if it should have values 1236 | slot, active = code_addrs[:2] 1237 | if active != "0": 1238 | # Since it has additional attributes, get the remaining ones 1239 | _, _, pin, name = code_addrs[2:6] 1240 | # And add them as a tuple to the list 1241 | codes.append((slot, name, pin)) 1242 | # pylint: disable=broad-except 1243 | except Exception as ex: 1244 | LOG.error("Problem parsing pin code string %s: %s", code, ex) 1245 | 1246 | return codes 1247 | 1248 | @property 1249 | def should_poll(self) -> bool: 1250 | """Determine if we should poll for data.""" 1251 | return True 1252 | 1253 | 1254 | class VeraThermostat(VeraDevice): 1255 | """Class to represent a thermostat.""" 1256 | 1257 | def set_temperature(self, temp: float) -> None: 1258 | """Set current goal temperature / setpoint.""" 1259 | 1260 | self.set_service_value( 1261 | self._thermostat_setpoint, "CurrentSetpoint", "NewCurrentSetpoint", temp 1262 | ) 1263 | 1264 | self.set_cache_value(self._setpoint_cache_value_name, temp) 1265 | 1266 | def get_current_goal_temperature(self, refresh: bool = False) -> Optional[float]: 1267 | """Get current goal temperature / setpoint.""" 1268 | if refresh: 1269 | self.refresh() 1270 | try: 1271 | return float(self.get_value(self._setpoint_cache_value_name)) 1272 | except (TypeError, ValueError): 1273 | return None 1274 | 1275 | def get_current_temperature(self, refresh: bool = False) -> Optional[float]: 1276 | """Get current temperature.""" 1277 | if refresh: 1278 | self.refresh() 1279 | try: 1280 | return float(self.get_value("temperature")) 1281 | except (TypeError, ValueError): 1282 | return None 1283 | 1284 | def set_hvac_mode(self, mode: str) -> None: 1285 | """Set the hvac mode.""" 1286 | self.set_service_value( 1287 | self.thermostat_operating_service, "ModeTarget", "NewModeTarget", mode 1288 | ) 1289 | self.set_cache_value("mode", mode) 1290 | 1291 | def get_hvac_mode(self, refresh: bool = False) -> Optional[str]: 1292 | """Get the hvac mode.""" 1293 | if refresh: 1294 | self.refresh() 1295 | return cast(str, self.get_value("mode")) 1296 | 1297 | def turn_off(self) -> None: 1298 | """Set hvac mode to off.""" 1299 | self.set_hvac_mode("Off") 1300 | 1301 | def turn_cool_on(self) -> None: 1302 | """Set hvac mode to cool.""" 1303 | self.set_hvac_mode("CoolOn") 1304 | 1305 | def turn_heat_on(self) -> None: 1306 | """Set hvac mode to heat.""" 1307 | self.set_hvac_mode("HeatOn") 1308 | 1309 | def turn_auto_on(self) -> None: 1310 | """Set hvac mode to auto.""" 1311 | self.set_hvac_mode("AutoChangeOver") 1312 | 1313 | def set_fan_mode(self, mode: str) -> None: 1314 | """Set the fan mode.""" 1315 | self.set_service_value(self.thermostat_fan_service, "Mode", "NewMode", mode) 1316 | self.set_cache_value("fanmode", mode) 1317 | 1318 | def fan_on(self) -> None: 1319 | """Turn fan on.""" 1320 | self.set_fan_mode("ContinuousOn") 1321 | 1322 | def fan_off(self) -> None: 1323 | """Turn fan off.""" 1324 | self.set_fan_mode("Off") 1325 | 1326 | def get_fan_mode(self, refresh: bool = False) -> Optional[str]: 1327 | """Get fan mode.""" 1328 | if refresh: 1329 | self.refresh() 1330 | return cast(str, self.get_value("fanmode")) 1331 | 1332 | def get_hvac_state(self, refresh: bool = False) -> Optional[str]: 1333 | """Get current hvac state.""" 1334 | if refresh: 1335 | self.refresh() 1336 | return cast(str, self.get_value("hvacstate")) 1337 | 1338 | def fan_auto(self) -> None: 1339 | """Set fan to automatic.""" 1340 | self.set_fan_mode("Auto") 1341 | 1342 | def fan_cycle(self) -> None: 1343 | """Set fan to cycle.""" 1344 | self.set_fan_mode("PeriodicOn") 1345 | 1346 | def _has_double_setpoints(self) -> bool: 1347 | """Determines if a thermostate has two setpoints""" 1348 | if self.get_value("setpoint"): 1349 | return False 1350 | 1351 | if self.get_value("heatsp") and self.get_value("coolsp"): 1352 | return True 1353 | 1354 | return False 1355 | 1356 | def _is_heating_recommended(self) -> bool: 1357 | mode = self.get_value("mode") 1358 | state = self.get_value("hvacstate") 1359 | 1360 | if mode == "HeatOn": 1361 | return True 1362 | 1363 | if mode == "CoolOn": 1364 | return False 1365 | 1366 | if state == "Heating": 1367 | return True 1368 | 1369 | if state == "Cooling": 1370 | return False 1371 | 1372 | return True 1373 | 1374 | @property 1375 | def _setpoint_cache_value_name(self) -> str: 1376 | if self._has_double_setpoints(): 1377 | if self._is_heating_recommended(): 1378 | return "heatsp" 1379 | else: 1380 | return "coolsp" 1381 | else: 1382 | return "setpoint" 1383 | 1384 | @property 1385 | def _thermostat_setpoint(self) -> str: 1386 | if self._has_double_setpoints(): 1387 | if self._is_heating_recommended(): 1388 | return self.thermostat_heat_setpoint 1389 | else: 1390 | return self.thermostat_cool_setpoint 1391 | else: 1392 | return self.thermostat_setpoint 1393 | 1394 | 1395 | class VeraSceneController(VeraDevice): 1396 | """Class to represent a scene controller.""" 1397 | 1398 | def get_last_scene_id(self, refresh: bool = False) -> str: 1399 | """Get last scene id. 1400 | 1401 | Refresh data from Vera if refresh is True, otherwise use local cache. 1402 | Refresh is only needed if you're not using subscriptions. 1403 | """ 1404 | if refresh: 1405 | self.refresh_complex_value("LastSceneID") 1406 | self.refresh_complex_value("sl_CentralScene") 1407 | val = self.get_complex_value("LastSceneID") or self.get_complex_value( 1408 | "sl_CentralScene" 1409 | ) 1410 | return cast(str, val) 1411 | 1412 | def get_last_scene_time(self, refresh: bool = False) -> str: 1413 | """Get last scene time. 1414 | 1415 | Refresh data from Vera if refresh is True, otherwise use local cache. 1416 | Refresh is only needed if you're not using subscriptions. 1417 | """ 1418 | if refresh: 1419 | self.refresh_complex_value("LastSceneTime") 1420 | val = self.get_complex_value("LastSceneTime") 1421 | return cast(str, val) 1422 | 1423 | @property 1424 | def should_poll(self) -> bool: 1425 | """Determine if you should poll for data.""" 1426 | return True 1427 | 1428 | 1429 | class VeraScene: 1430 | """Class to represent a scene that can be activated. 1431 | 1432 | This does not inherit from a VeraDevice since scene ids 1433 | and device ids are separate sets. A scene is not a device 1434 | as far as Vera is concerned. 1435 | 1436 | TODO: The duplicated code between VeraScene & VeraDevice should 1437 | be refactored at some point to be reused. Perhaps a VeraObject? 1438 | """ 1439 | 1440 | def __init__(self, json_obj: dict, vera_controller: VeraController): 1441 | """Init object.""" 1442 | self.json_state = json_obj 1443 | self.scene_id = cast(int, self.json_state.get("id")) 1444 | self.vera_controller = vera_controller 1445 | self.name = self.json_state.get("name") 1446 | self._active = False 1447 | 1448 | if not self.name: 1449 | self.name = f"Vera Scene {self.name} {self.scene_id}" 1450 | 1451 | def __repr__(self) -> str: 1452 | """Get a string representation.""" 1453 | return f"{self.__class__.__name__} (id={self.scene_id} name={self.name})" 1454 | 1455 | @property 1456 | def scene_service(self) -> str: 1457 | """Vera service string for switch.""" 1458 | return "urn:micasaverde-com:serviceId:HomeAutomationGateway1" 1459 | 1460 | def vera_request(self, **kwargs: str) -> requests.Response: 1461 | """Perfom a vera_request for this scene.""" 1462 | request_payload = {"output_format": "json", "SceneNum": self.scene_id} 1463 | request_payload.update(kwargs) 1464 | 1465 | return self.vera_controller.data_request(request_payload) 1466 | 1467 | def activate(self) -> None: 1468 | """Activate a Vera scene. 1469 | 1470 | This will call the Vera api to activate a scene. 1471 | """ 1472 | payload = { 1473 | "id": "lu_action", 1474 | "action": "RunScene", 1475 | "serviceId": self.scene_service, 1476 | } 1477 | result = self.vera_request(**payload) 1478 | LOG.debug( 1479 | "activate: " "result of vera_request with payload %s: %s", 1480 | payload, 1481 | result.text, 1482 | ) 1483 | 1484 | self._active = True 1485 | 1486 | def update(self, params: dict) -> None: 1487 | """Update the local variables.""" 1488 | self._active = params["active"] == 1 1489 | 1490 | def refresh(self) -> None: 1491 | """Refresh the data used by get_value. 1492 | 1493 | Only needed if you're not using subscriptions. 1494 | """ 1495 | j = self.vera_request(id="sdata", output_format="json").json() 1496 | scenes = j.get("scenes") 1497 | for scene_data in scenes: 1498 | if scene_data.get("id") == self.scene_id: 1499 | self.update(scene_data) 1500 | 1501 | @property 1502 | def is_active(self) -> bool: 1503 | """Is Scene active.""" 1504 | return self._active 1505 | 1506 | @property 1507 | def vera_scene_id(self) -> int: 1508 | """Return the ID Vera uses to refer to the scene.""" 1509 | return self.scene_id 1510 | 1511 | @property 1512 | def should_poll(self) -> bool: 1513 | """Whether polling is needed if using subscriptions for this device.""" 1514 | return True 1515 | 1516 | 1517 | class VeraGarageDoor(VeraSwitch): 1518 | """Garage door device.""" 1519 | 1520 | 1521 | class VeraAlert: 1522 | """An alert triggered by variable state change.""" 1523 | 1524 | def __init__(self, json_alert: dict, device: VeraDevice): 1525 | """Init object.""" 1526 | self.device = device 1527 | self.code = json_alert.get("Code") 1528 | self.severity = json_alert.get("Severity") 1529 | self.value = cast(str, json_alert.get("NewValue")) 1530 | self.timestamp = datetime.fromtimestamp(json_alert.get("LocalTimestamp", 0)) 1531 | 1532 | def __repr__(self) -> str: 1533 | """Get a string representation.""" 1534 | return f"{self.__class__.__name__} (code={self.code} value={self.value} timestamp={self.timestamp})" 1535 | 1536 | 1537 | class PyveraError(Exception): 1538 | """Simple error.""" 1539 | 1540 | 1541 | class ControllerNotSetException(Exception): 1542 | """The controller was not set in the subscription registry.""" 1543 | 1544 | 1545 | class AbstractSubscriptionRegistry(ABC): 1546 | """Class for subscribing to wemo events.""" 1547 | 1548 | def __init__(self) -> None: 1549 | """Init subscription.""" 1550 | self._devices: DefaultDict[int, List[VeraDevice]] = collections.defaultdict( 1551 | list 1552 | ) 1553 | self._callbacks: DefaultDict[ 1554 | VeraDevice, List[SubscriptionCallback] 1555 | ] = collections.defaultdict(list) 1556 | self._last_updated = TIMESTAMP_NONE 1557 | self._controller: Optional[VeraController] = None 1558 | 1559 | def set_controller(self, controller: VeraController) -> None: 1560 | """Set the controller.""" 1561 | self._controller = controller 1562 | 1563 | def get_controller(self) -> Optional[VeraController]: 1564 | """Get the controller.""" 1565 | return self._controller 1566 | 1567 | def register(self, device: VeraDevice, callback: SubscriptionCallback) -> None: 1568 | """Register a callback. 1569 | 1570 | device: device to be updated by subscription 1571 | callback: callback for notification of changes 1572 | """ 1573 | if not device: 1574 | LOG.error("Received an invalid device: %r", device) 1575 | return 1576 | 1577 | LOG.debug("Subscribing to events for %s", device.name) 1578 | self._devices[device.vera_device_id].append(device) 1579 | self._callbacks[device].append(callback) 1580 | 1581 | def unregister(self, device: VeraDevice, callback: SubscriptionCallback) -> None: 1582 | """Remove a registered change callback. 1583 | 1584 | device: device that has the subscription 1585 | callback: callback used in original registration 1586 | """ 1587 | if not device: 1588 | LOG.error("Received an invalid device: %r", device) 1589 | return 1590 | 1591 | LOG.debug("Removing subscription for %s", device.name) 1592 | self._callbacks[device].remove(callback) 1593 | self._devices[device.vera_device_id].remove(device) 1594 | 1595 | def _event( 1596 | self, device_data_list: List[dict], device_alert_list: List[dict] 1597 | ) -> None: 1598 | # Guard against invalid data from Vera API 1599 | if not isinstance(device_data_list, list): 1600 | LOG.debug("Got invalid device_data_list: %s", device_data_list) 1601 | device_data_list = [] 1602 | 1603 | if not isinstance(device_alert_list, list): 1604 | LOG.debug("Got invalid device_alert_list: %s", device_alert_list) 1605 | device_alert_list = [] 1606 | 1607 | # Find unique device_ids that have data across both device_data and alert_data 1608 | device_ids = set() 1609 | 1610 | for device_data in device_data_list: 1611 | if "id" in device_data: 1612 | device_ids.add(device_data["id"]) 1613 | else: 1614 | LOG.debug("Got invalid device_data: %s", device_data) 1615 | 1616 | for alert_data in device_alert_list: 1617 | if "PK_Device" in alert_data: 1618 | device_ids.add(alert_data["PK_Device"]) 1619 | else: 1620 | LOG.debug("Got invalid alert_data: %s", alert_data) 1621 | 1622 | for device_id in device_ids: 1623 | try: 1624 | device_list = self._devices.get(int(device_id), ()) 1625 | device_datas = [ 1626 | data for data in device_data_list if data.get("id") == device_id 1627 | ] 1628 | device_alerts = [ 1629 | alert 1630 | for alert in device_alert_list 1631 | if alert.get("PK_Device") == device_id 1632 | ] 1633 | 1634 | device_data = device_datas[0] if device_datas else {} 1635 | 1636 | for device in device_list: 1637 | self._event_device(device, device_data, device_alerts) 1638 | # pylint: disable=broad-except 1639 | except Exception as exp: 1640 | LOG.exception( 1641 | "Error processing event for device_id %s: %s", device_id, exp 1642 | ) 1643 | 1644 | def _event_device( 1645 | self, device: Optional[VeraDevice], device_data: dict, device_alerts: List[dict] 1646 | ) -> None: 1647 | if device is None: 1648 | return 1649 | # Vera can send an update status STATE_NO_JOB but 1650 | # with a comment about sending a command 1651 | state = int(device_data.get("state", STATE_NOT_PRESENT)) 1652 | comment = device_data.get("comment", "") 1653 | sending = comment.find("Sending") >= 0 1654 | LOG.debug( 1655 | "Event: %s, state %s, alerts %s, %s", 1656 | device.name, 1657 | state, 1658 | len(device_alerts), 1659 | json.dumps(device_data), 1660 | ) 1661 | device.set_alerts(device_alerts) 1662 | if sending and state == STATE_NO_JOB: 1663 | state = STATE_JOB_WAITING_TO_START 1664 | if state == STATE_JOB_IN_PROGRESS and device.__class__.__name__ == "VeraLock": 1665 | # VeraLocks don't complete 1666 | # so we detect if we are done from the comment field. 1667 | # This is really just a workaround for a vera bug 1668 | # and it does mean that a device name 1669 | # cannot contain the SUCCESS! string (very unlikely) 1670 | # since the name is also returned in the comment for 1671 | # some status messages 1672 | success = comment.find("SUCCESS!") >= 0 1673 | if success: 1674 | LOG.debug("Lock success found, job is done") 1675 | state = STATE_JOB_DONE 1676 | 1677 | if state in ( 1678 | STATE_JOB_WAITING_TO_START, 1679 | STATE_JOB_IN_PROGRESS, 1680 | STATE_JOB_WAITING_FOR_CALLBACK, 1681 | STATE_JOB_REQUEUE, 1682 | STATE_JOB_PENDING_DATA, 1683 | ): 1684 | return 1685 | if not ( 1686 | state == STATE_JOB_DONE 1687 | or state == STATE_NOT_PRESENT 1688 | or state == STATE_NO_JOB 1689 | or (state == STATE_JOB_ERROR and comment.find("Setting user configuration")) 1690 | ): 1691 | LOG.error("Device %s, state %s, %s", device.name, state, comment) 1692 | return 1693 | device.update(device_data) 1694 | for callback in self._callbacks.get(device, ()): 1695 | try: 1696 | callback(device) 1697 | # pylint: disable=broad-except 1698 | except Exception: 1699 | # (Very) broad check to not let loosely-implemented callbacks 1700 | # kill our polling thread. They should be catching their own 1701 | # errors, so if it gets back to us, just log it and move on. 1702 | LOG.exception( 1703 | "Unhandled exception in callback for device #%s (%s)", 1704 | str(device.device_id), 1705 | device.name, 1706 | ) 1707 | 1708 | @abstractmethod 1709 | def start(self) -> None: 1710 | """Start a thread to handle Vera blocked polling.""" 1711 | raise NotImplementedError("start method is not implemented.") 1712 | 1713 | @abstractmethod 1714 | def stop(self) -> None: 1715 | """Tell the subscription thread to terminate.""" 1716 | raise NotImplementedError("stop method is not implemented.") 1717 | 1718 | def get_device_data(self, last_updated: dict) -> ChangedDevicesValue: 1719 | """Get device data.""" 1720 | if not self._controller: 1721 | raise ControllerNotSetException() 1722 | 1723 | return self._controller.get_changed_devices(last_updated) 1724 | 1725 | def get_alert_data(self, last_updated: dict) -> List[dict]: 1726 | """Get alert data.""" 1727 | if not self._controller: 1728 | raise ControllerNotSetException() 1729 | 1730 | return self._controller.get_alerts(last_updated) 1731 | 1732 | def always_update(self) -> bool: # pylint: disable=no-self-use 1733 | """Determine if we should treat every poll as a data change.""" 1734 | return False 1735 | 1736 | def poll_server_once(self) -> bool: 1737 | """Poll the vera server only once. 1738 | 1739 | Returns True if it could successfully check for data. False otherwise. 1740 | """ 1741 | device_data: List[dict] = [] 1742 | alert_data: List[dict] = [] 1743 | data_changed = False 1744 | try: 1745 | LOG.debug("Polling for Vera changes") 1746 | device_data, new_timestamp = self.get_device_data(self._last_updated) 1747 | if ( 1748 | new_timestamp["dataversion"] != self._last_updated["dataversion"] 1749 | or self.always_update() 1750 | ): 1751 | alert_data = self.get_alert_data(self._last_updated) 1752 | data_changed = True 1753 | else: 1754 | data_changed = False 1755 | self._last_updated = new_timestamp 1756 | except requests.RequestException as ex: 1757 | LOG.debug("Caught RequestException: %s", str(ex)) 1758 | except PyveraError as ex: 1759 | LOG.debug("Non-fatal error in poll: %s", str(ex)) 1760 | except Exception as ex: 1761 | LOG.exception("Vera poll thread general exception: %s", str(ex)) 1762 | raise 1763 | else: 1764 | LOG.debug("Poll returned") 1765 | if data_changed or self.always_update(): 1766 | self._event(device_data, alert_data) 1767 | else: 1768 | LOG.debug("No changes in poll interval") 1769 | 1770 | return True 1771 | 1772 | # After error, discard timestamp for fresh update. pyvera issue #89 1773 | self._last_updated = {"dataversion": 1, "loadtime": 0} 1774 | LOG.info("Could not poll Vera") 1775 | return False 1776 | 1777 | 1778 | class SubscriptionRegistry(AbstractSubscriptionRegistry): 1779 | """Class for subscribing to wemo events.""" 1780 | 1781 | def __init__(self) -> None: 1782 | """Init subscription.""" 1783 | super(SubscriptionRegistry, self).__init__() 1784 | self._exiting = threading.Event() 1785 | self._poll_thread: threading.Thread 1786 | 1787 | def join(self) -> None: 1788 | """Don't allow the main thread to terminate until we have.""" 1789 | self._poll_thread.join() 1790 | 1791 | def start(self) -> None: 1792 | """Start a thread to handle Vera blocked polling.""" 1793 | self._poll_thread = threading.Thread( 1794 | target=self._run_poll_server, name="Vera Poll Thread" 1795 | ) 1796 | self._exiting = threading.Event() 1797 | self._poll_thread.daemon = True 1798 | self._poll_thread.start() 1799 | 1800 | def stop(self) -> None: 1801 | """Tell the subscription thread to terminate.""" 1802 | if self._exiting: 1803 | self._exiting.set() 1804 | self.join() 1805 | LOG.info("Terminated thread") 1806 | 1807 | def _run_poll_server(self) -> None: 1808 | while not self._exiting.wait(timeout=1): 1809 | if not self.poll_server_once(): 1810 | self._exiting.wait(timeout=SUBSCRIPTION_RETRY) 1811 | 1812 | LOG.info("Shutdown Vera Poll Thread") 1813 | --------------------------------------------------------------------------------