pax_global_header00006660000000000000000000000064145530015050014510gustar00rootroot0000000000000052 comment=deec59e73bc7ce6d02a73360cf3d94d7fa341c01 simplisafe-python-2024.01.0/000077500000000000000000000000001455300150500154715ustar00rootroot00000000000000simplisafe-python-2024.01.0/.codeclimate.yml000066400000000000000000000003401455300150500205400ustar00rootroot00000000000000--- engines: duplication: enabled: true config: languages: - python fixme: enabled: true radon: enabled: true ratings: paths: - "**.py" exclude_paths: - dist/ - docs/ - tests/ simplisafe-python-2024.01.0/.flake8000066400000000000000000000002271455300150500166450ustar00rootroot00000000000000[flake8] ignore = E203,E266,E501,F811,W503 max-line-length = 80 max-complexity = 18 per-file-ignores = tests/*:DAR,S101 select = B,B9,LK,C,D,E,F,I,S,W simplisafe-python-2024.01.0/.github/000077500000000000000000000000001455300150500170315ustar00rootroot00000000000000simplisafe-python-2024.01.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001455300150500212145ustar00rootroot00000000000000simplisafe-python-2024.01.0/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000007641455300150500237150ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Additional context** Add any other context about the problem here. simplisafe-python-2024.01.0/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000006461455300150500247470ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Additional context** Add any other context or screenshots about the feature request here. simplisafe-python-2024.01.0/.github/dependabot.yml000066400000000000000000000005521455300150500216630ustar00rootroot00000000000000--- version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily time: "06:00" - package-ecosystem: pip directory: "/.github/workflows" schedule: interval: daily time: "06:00" - package-ecosystem: "github-actions" directory: "/" schedule: interval: daily time: "06:00" simplisafe-python-2024.01.0/.github/labels.yml000066400000000000000000000024241455300150500210200ustar00rootroot00000000000000--- - name: "breaking-change" color: ee0701 description: "A breaking change for existing users" - name: "bug" color: ee0701 description: "Bugs or issues which will cause a problem for users" - name: "documentation" color: 0052cc description: "Project documentation" - name: "enhancement" color: 1d76db description: "Enhancement of the code, not introducing new features." - name: "maintenance" color: 2af79e description: "Generic library tasks" - name: "dependencies" color: 1d76db description: "Upgrade or downgrade of project dependencies" - name: "in-progress" color: fbca04 description: "Issue is currently being resolved by a developer" - name: "stale" color: fef2c0 description: "There has not been activity on this issue or PR for some time" - name: "no-stale" color: fef2c0 description: "This issue or PR is exempted from the stale bot" - name: "security" color: ee0701 description: "Marks a security issue that needs to be resolved ASAP" - name: "incomplete" color: fef2c0 description: "Marks a PR or issue that is missing information" - name: "invalid" color: fef2c0 description: "Marks a PR or issue that is missing information" - name: "help-wanted" color: 0e8a16 description: "Needs a helping hang or expertise in order to resolve" simplisafe-python-2024.01.0/.github/pull_request_template.md000066400000000000000000000005471455300150500240000ustar00rootroot00000000000000**Describe what the PR does:** **Does this fix a specific issue?** Fixes https://github.com/bachya/simplisafe-python/issues/ **Checklist:** - [ ] Confirm that one or more new tests are written for the new functionality. - [ ] Run tests and ensure everything passes (with 100% test coverage). - [ ] Update `README.md` with any new documentation. simplisafe-python-2024.01.0/.github/release-drafter.yml000066400000000000000000000010051455300150500226150ustar00rootroot00000000000000--- categories: - title: "🚨 Breaking Changes" labels: - "breaking-change" - title: "🚀 Features" labels: - "enhancement" - title: "🐛 Bug Fixes" labels: - "bug" - title: "📕 Documentation" labels: - "documentation" - title: "🧰 Maintenance" labels: - "dependencies" - "maintenance" - "tooling" change-template: "- $TITLE (#$NUMBER)" name-template: "$NEXT_PATCH_VERSION" tag-template: "$NEXT_PATCH_VERSION" template: | $CHANGES simplisafe-python-2024.01.0/.github/workflows/000077500000000000000000000000001455300150500210665ustar00rootroot00000000000000simplisafe-python-2024.01.0/.github/workflows/codeql.yml000066400000000000000000000010021455300150500230510ustar00rootroot00000000000000--- name: CodeQL "on": push: branches: - dev - main pull_request: branches: - dev - main workflow_dispatch: schedule: - cron: "30 1 * * 0" jobs: codeql: name: Scanning runs-on: ubuntu-latest steps: - name: ⤵️ Check out code from GitHub uses: actions/checkout@v4 - name: 🏗 Initialize CodeQL uses: github/codeql-action/init@v3 - name: 🚀 Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 simplisafe-python-2024.01.0/.github/workflows/labels.yml000066400000000000000000000006721455300150500230600ustar00rootroot00000000000000--- name: Sync Labels "on": push: branches: - main paths: - .github/labels.yml workflow_dispatch: jobs: labels: name: ♻️ Sync labels runs-on: ubuntu-latest steps: - name: ⤵️ Check out code from GitHub uses: actions/checkout@v4 - name: 🚀 Run Label Syncer uses: micnncim/action-label-syncer@v1.3.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} simplisafe-python-2024.01.0/.github/workflows/lock.yml000066400000000000000000000006251455300150500225440ustar00rootroot00000000000000--- name: Lock Closed Issues and PRs "on": schedule: - cron: "0 9 * * *" workflow_dispatch: jobs: lock: name: 🔒 Lock! runs-on: ubuntu-latest steps: - uses: dessant/lock-threads@v5.0.1 with: github-token: ${{ github.token }} issue-inactive-days: "30" issue-lock-reason: "" pr-inactive-days: "1" pr-lock-reason: "" simplisafe-python-2024.01.0/.github/workflows/publish.yml000066400000000000000000000007771455300150500232720ustar00rootroot00000000000000--- name: Publish to PyPI "on": push: tags: - "*" jobs: publish_to_pypi: runs-on: ubuntu-latest steps: - name: ⤵️ Check out code from GitHub uses: actions/checkout@v4 - name: 🏗 Set up Python 3.12 id: python uses: actions/setup-python@v5 with: python-version: "3.12" - name: 🚀 Publish to PyPi run: | pip install poetry poetry publish --build -u __token__ -p ${{ secrets.PYPI_API_KEY }} simplisafe-python-2024.01.0/.github/workflows/release-drafter.yml000066400000000000000000000005341455300150500246600ustar00rootroot00000000000000--- name: Release Drafter "on": push: branches: - main workflow_dispatch: jobs: update_release_draft: name: ✏️ Draft Release runs-on: ubuntu-latest steps: - name: 🚀 Run Release Drafter uses: release-drafter/release-drafter@v5.25.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} simplisafe-python-2024.01.0/.github/workflows/requirements.txt000066400000000000000000000000161455300150500243470ustar00rootroot00000000000000poetry==1.7.1 simplisafe-python-2024.01.0/.github/workflows/stale.yml000066400000000000000000000023461455300150500227260ustar00rootroot00000000000000--- name: Stale "on": schedule: - cron: "0 8 * * *" workflow_dispatch: jobs: stale: name: 🧹 Clean up stale issues and PRs runs-on: ubuntu-latest steps: - name: 🚀 Run stale uses: actions/stale@v9 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 30 days-before-close: 7 remove-stale-when-updated: true stale-issue-label: "stale" exempt-issue-labels: "no-stale,help-wanted" stale-issue-message: > There hasn't been any activity on this issue recently, so it has been marked as stale. Please make sure to update to the latest version and check if that solves the issue. Let us know if that works for you by leaving a comment. This issue will be closed if no further activity occurs. Thanks! stale-pr-label: "stale" exempt-pr-labels: "no-stale" stale-pr-message: > There hasn't been any activity on this pull request recently, so it has automatically been marked as stale and will be closed if no further action occurs within 7 days. Thank you for your contributions. simplisafe-python-2024.01.0/.github/workflows/static-analysis.yml000066400000000000000000000032511455300150500247220ustar00rootroot00000000000000--- name: Linting and Static Analysis "on": pull_request: branches: - dev - main push: branches: - dev - main jobs: lint: name: "Linting & Static Analysis" runs-on: ubuntu-latest steps: - name: ⤵️ Check out code from GitHub uses: actions/checkout@v4 - name: 🏗 Set up Python 3.12 id: setup-python uses: actions/setup-python@v5 with: python-version: "3.12" - name: ⤵️ Get pip cache directory id: pip-cache run: | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: ⤵️ Establish pip cache uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: "${{ runner.os }}-pip-\ ${{ hashFiles('.github/workflows/requirements.txt') }}" restore-keys: | ${{ runner.os }}-pip- - name: 🏗 Install workflow dependencies run: | pip install -r .github/workflows/requirements.txt poetry config virtualenvs.create true poetry config virtualenvs.in-project true - name: ⤵️ Establish poetry cache uses: actions/cache@v4 with: path: .venv key: "venv-${{ steps.setup-python.outputs.python-version }}-\ ${{ hashFiles('poetry.lock') }}" restore-keys: | venv-${{ steps.setup-python.outputs.python-version }}- - name: 🏗 Install package dependencies run: | poetry install --no-interaction - name: 🚀 Run pre-commit hooks uses: pre-commit/action@v3.0.0 env: SKIP: no-commit-to-branch,pytest simplisafe-python-2024.01.0/.github/workflows/test.yml000066400000000000000000000072511455300150500225750ustar00rootroot00000000000000--- name: Tests and Coverage "on": pull_request: branches: - dev - main push: branches: - dev - main jobs: test: name: Tests runs-on: ubuntu-latest strategy: matrix: python-version: - "3.10" - "3.11" - "3.12" steps: - name: ⤵️ Check out code from GitHub uses: actions/checkout@v4 - name: 🏗 Set up Python id: setup-python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: ⤵️ Get pip cache directory id: pip-cache run: | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: ⤵️ Establish pip cache uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: "${{ runner.os }}-pip-\ ${{ hashFiles('.github/workflows/requirements.txt') }}" restore-keys: | ${{ runner.os }}-pip- - name: 🏗 Install workflow dependencies run: | pip install -r .github/workflows/requirements.txt poetry config virtualenvs.create true poetry config virtualenvs.in-project true - name: ⤵️ Establish poetry cache uses: actions/cache@v4 with: path: .venv key: "venv-${{ steps.setup-python.outputs.python-version }}-\ ${{ hashFiles('poetry.lock') }}" restore-keys: | venv-${{ steps.setup-python.outputs.python-version }}- - name: 🏗 Install package dependencies run: | poetry install --no-interaction - name: 🚀 Run pytest run: poetry run pytest --cov simplipy tests - name: ⬆️ Upload coverage artifact uses: actions/upload-artifact@v3 with: name: coverage-${{ matrix.python-version }} path: .coverage coverage: name: Code Coverage needs: test runs-on: ubuntu-latest steps: - name: ⤵️ Check out code from GitHub uses: actions/checkout@v4 - name: ⬇️ Download coverage data uses: actions/download-artifact@v3 - name: 🏗 Set up Python 3.12 id: setup-python uses: actions/setup-python@v5 with: python-version: "3.12" - name: ⤵️ Get pip cache directory id: pip-cache run: | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: ⤵️ Establish pip cache uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: "${{ runner.os }}-pip-\ ${{ hashFiles('.github/workflows/requirements.txt') }}" restore-keys: | ${{ runner.os }}-pip- - name: 🏗 Install workflow dependencies run: | pip install -r .github/workflows/requirements.txt poetry config virtualenvs.create true poetry config virtualenvs.in-project true - name: ⤵️ Establish poetry cache uses: actions/cache@v4 with: path: .venv key: "venv-${{ steps.setup-python.outputs.python-version }}-\ ${{ hashFiles('poetry.lock') }}" restore-keys: | venv-${{ steps.setup-python.outputs.python-version }}- - name: 🏗 Install package dependencies run: | poetry install --no-interaction - name: 🚀 Process coverage results run: | poetry run coverage combine coverage*/.coverage* poetry run coverage xml -i - name: 📊 Upload coverage report to codecov.io uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} simplisafe-python-2024.01.0/.gitignore000066400000000000000000000001331455300150500174560ustar00rootroot00000000000000*.egg-info .coverage .mypy_cache .nox .tox .venv __pycache__ coverage.xml docs/_build tags simplisafe-python-2024.01.0/.mise.toml000066400000000000000000000000701455300150500173760ustar00rootroot00000000000000[tools] python = { version="3.12", virtualenv=".venv" } simplisafe-python-2024.01.0/.pre-commit-config.yaml000066400000000000000000000116211455300150500217530ustar00rootroot00000000000000--- repos: - repo: local hooks: - id: blacken-docs name: ☕️ Format documentation using black language: system files: '\.(rst|md|markdown|py|tex)$' entry: poetry run blacken-docs require_serial: true - id: check-ast name: 🐍 Checking Python AST language: system types: [python] entry: poetry run check-ast - id: check-case-conflict name: 🔠 Checking for case conflicts language: system entry: poetry run check-case-conflict - id: check-docstring-first name: ℹ️ Checking docstrings are first language: system types: [python] entry: poetry run check-docstring-first - id: check-executables-have-shebangs name: 🧐 Checking that executables have shebangs language: system types: [text, executable] entry: poetry run check-executables-have-shebangs stages: [commit, push, manual] - id: check-json name: { Checking JSON files language: system types: [json] entry: poetry run check-json - id: check-merge-conflict name: 💥 Checking for merge conflicts language: system types: [text] entry: poetry run check-merge-conflict - id: check-symlinks name: 🔗 Checking for broken symlinks language: system types: [symlink] entry: poetry run check-symlinks - id: check-toml name: ✅ Checking TOML files language: system types: [toml] entry: poetry run check-toml - id: check-yaml name: ✅ Checking YAML files language: system types: [yaml] entry: poetry run check-yaml - id: codespell name: ✅ Checking code for misspellings language: system types: [text] exclude: ^poetry\.lock$ entry: poetry run codespell -L resset - id: debug-statements name: 🪵 Checking for debug statements and imports (Python) language: system types: [python] entry: poetry run debug-statement-hook - id: detect-private-key name: 🕵️ Detecting private keys language: system types: [text] entry: poetry run detect-private-key - id: end-of-file-fixer name: 🔚 Checking end of files language: system types: [text] entry: poetry run end-of-file-fixer stages: [commit, push, manual] - id: fix-byte-order-marker name: 🚏 Checking UTF-8 byte order marker language: system types: [text] entry: poetry run fix-byte-order-marker - id: format name: ☕️ Formatting code using ruff language: system types: [python] entry: poetry run ruff format - id: isort name: 🔀 Sorting all imports with isort language: system types: [python] entry: poetry run isort - id: mypy name: 🆎 Performing static type checking using mypy language: system types: [python] entry: poetry run mypy require_serial: true - id: no-commit-to-branch name: 🛑 Checking for commit to protected branch language: system entry: poetry run no-commit-to-branch pass_filenames: false always_run: true args: - --branch=dev - --branch=main - id: poetry name: 📜 Checking pyproject with Poetry language: system entry: poetry check pass_filenames: false always_run: true - id: pylint name: 🌟 Starring code with pylint language: system types: [python] entry: poetry run pylint exclude: ^docs\/conf.py$ - id: pyupgrade name: 🆙 Checking for upgradable syntax with pyupgrade language: system types: [python] entry: poetry run pyupgrade args: [--py39-plus, --keep-runtime-typing] - id: ruff name: 👔 Enforcing style guide with ruff language: system types: [python] entry: poetry run ruff --fix exclude: ^docs\/conf.py$ - id: trailing-whitespace name: ✄ Trimming trailing whitespace language: system types: [text] entry: poetry run trailing-whitespace-fixer stages: [commit, push, manual] - id: vulture name: 🔍 Finding unused Python code with Vulture language: system types: [python] entry: poetry run vulture pass_filenames: false require_serial: true - id: yamllint name: 🎗 Checking YAML files with yamllint language: system types: [yaml] entry: poetry run yamllint - repo: https://github.com/pre-commit/mirrors-prettier rev: "v3.0.0-alpha.4" hooks: - id: prettier name: 💄 Ensuring files are prettier simplisafe-python-2024.01.0/.readthedocs.yml000066400000000000000000000004221455300150500205550ustar00rootroot00000000000000--- version: 2 build: os: ubuntu-20.04 tools: python: "3.10" jobs: post_install: - pip install poetry==1.3.2 - poetry config virtualenvs.create false - poetry install --with doc sphinx: configuration: docs/conf.py fail_on_warning: true simplisafe-python-2024.01.0/CHANGELOG.md000066400000000000000000000021251455300150500173020ustar00rootroot00000000000000# Change Log _NOTE:_ this file is here for posterity; going forward, release info can be found here: https://github.com/bachya/simplisafe-python/releases ## 3.0.0 - Convert library to use `asyncio` - Added full test sweet (with 100% coverage) - Complete overhaul of documentation - Added Makefile with common tasks - Added Pipfile for dependency management - Added CodeClimate and CodeCov badging/tracking - Updated `setup.py` to common standard - Added contribution instructions - Added author recognition ## 2.0.2 - Raise exception when setup doesn't complete ## 2.0.1 - Allow users without monthly monitoring to get system status ## 2.0.0 - Moving to the new API ## 1.0.5 - Get status from "Dashboard" request instead of "Locations" request because simplisafe was returning 'error' ## 1.0.4 - Don't log error when user doesn't have a freeze sensor ## 1.0.3 - Added protection against incorrect data from SimpliSafe back-end ## 1.0.2 - Return status of login corrected spelling of credentials ## 1.0.1 - Fixed logging in system ## 1.0.0 - Complete re-write ## 0.0.1 - Initial support simplisafe-python-2024.01.0/LICENSE000066400000000000000000000021201455300150500164710ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2016 William Scanlon, 2018-2024 Aaron Bach Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. simplisafe-python-2024.01.0/README.md000066400000000000000000000053241455300150500167540ustar00rootroot00000000000000# 🚨 simplisafe-python: A Python3, async interface to the SimpliSafe™ API [![CI][ci-badge]][ci] [![PyPI][pypi-badge]][pypi] [![Version][version-badge]][version] [![License][license-badge]][license] [![Code Coverage][codecov-badge]][codecov] [![Maintainability][maintainability-badge]][maintainability] Buy Me A Coffee `simplisafe-python` (hereafter referred to as `simplipy`) is a Python3, `asyncio`-driven interface to the unofficial [SimpliSafe™][simplisafe] API. With it, users can get data on their system (including available sensors), set the system state, and more. # Documentation You can find complete documentation [here][docs]. # Contributing Thanks to all of [our contributors][contributors] so far! 1. [Check for open features/bugs][issues] or [initiate a discussion on one][new-issue]. 2. [Fork the repository][fork]. 3. (_optional, but highly recommended_) Create a virtual environment: `python3 -m venv .venv` 4. (_optional, but highly recommended_) Enter the virtual environment: `source ./.venv/bin/activate` 5. Install the dev environment: `script/setup` 6. Code your new feature or bug fix on a new branch. 7. Write tests that cover your new functionality. 8. Run tests and ensure 100% code coverage: `poetry run pytest --cov simplipy tests` 9. Update `README.md` with any new documentation. 10. Submit a pull request! [ci-badge]: https://img.shields.io/github/actions/workflow/status/bachya/simplisafe-python/test.yml [ci]: https://github.com/bachya/simplisafe-python/actions [codecov-badge]: https://codecov.io/gh/bachya/simplisafe-python/branch/dev/graph/badge.svg [codecov]: https://codecov.io/gh/bachya/simplisafe-python [contributors]: https://github.com/bachya/simplisafe-python/graphs/contributors [docs]: https://simplisafe-python.readthedocs.io [fork]: https://github.com/bachya/simplisafe-python/fork [issues]: https://github.com/bachya/simplisafe-python/issues [license-badge]: https://img.shields.io/pypi/l/simplisafe-python.svg [license]: https://github.com/bachya/simplisafe-python/blob/main/LICENSE [maintainability-badge]: https://api.codeclimate.com/v1/badges/f46d8b1dcfde6a2f683d/maintainability [maintainability]: https://codeclimate.com/github/bachya/simplisafe-python/maintainability [new-issue]: https://github.com/bachya/simplisafe-python/issues/new [pypi-badge]: https://img.shields.io/pypi/v/simplisafe-python.svg [pypi]: https://pypi.python.org/pypi/simplisafe-python [simplisafe]: https://simplisafe.com [version-badge]: https://img.shields.io/pypi/pyversions/simplisafe-python.svg [version]: https://pypi.python.org/pypi/simplisafe-python simplisafe-python-2024.01.0/docs/000077500000000000000000000000001455300150500164215ustar00rootroot00000000000000simplisafe-python-2024.01.0/docs/Makefile000066400000000000000000000011721455300150500200620ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) simplisafe-python-2024.01.0/docs/api.md000066400000000000000000000033721455300150500175210ustar00rootroot00000000000000# API Reference ```{toctree} :maxdepth: 3 ``` ```{eval-rst} .. module:: simplipy ``` ## API ```{eval-rst} .. automodule:: simplipy.api :members: ``` ## Websocket Communication ```{eval-rst} .. autoclass:: simplipy.websocket.WebsocketClient :members: ``` ```{eval-rst} .. autoclass:: simplipy.websocket.WebsocketEvent :members: :undoc-members: ``` ## Devices ```{eval-rst} .. autoclass:: simplipy.device.Device :members: ``` ```{eval-rst} .. autoclass:: simplipy.device.DeviceTypes :members: :undoc-members: ``` ```{eval-rst} .. autoclass:: simplipy.device.DeviceV3 :members: ``` ## Lock ```{eval-rst} .. autoclass:: simplipy.device.lock.Lock :members: ``` ```{eval-rst} .. autoclass:: simplipy.device.lock.LockStates :members: :undoc-members: ``` ## Sensors ```{eval-rst} .. autoclass:: simplipy.device.sensor.v2.SensorV2 :members: ``` ```{eval-rst} .. autoclass:: simplipy.device.sensor.v3.SensorV3 :members: ``` ## Systems ```{eval-rst} .. autoclass:: simplipy.system.System :members: ``` ```{eval-rst} .. autoclass:: simplipy.system.v2.SystemV2 :members: ``` ```{eval-rst} .. autoclass:: simplipy.system.v3.SystemV3 :members: ``` ```{eval-rst} .. autoclass:: simplipy.system.SystemNotification :members: :undoc-members: ``` ```{eval-rst} .. autoclass:: simplipy.system.SystemStates :members: :undoc-members: ``` ## Utilities ```{eval-rst} .. automodule:: simplipy.util :members: ``` ### `auth` ```{eval-rst} .. automodule:: simplipy.util.auth :members: ``` ### `dt` ```{eval-rst} .. automodule:: simplipy.util.dt :members: ``` ### `string` ```{eval-rst} .. automodule:: simplipy.util.string :members: ``` ## Errors ```{eval-rst} .. automodule:: simplipy.errors :members: ``` simplisafe-python-2024.01.0/docs/camera.md000066400000000000000000000030361455300150500201750ustar00rootroot00000000000000# Cameras {meth}`Camera ` objects correspond to SimpliSafe™ "SimpliCam" cameras and doorbells (only available for V3 systems) and allow users to retrieve information on them, including URLs to view short-lived streams of the camera. ## Core Properties All {meth}`Camera ` objects come with a standard set of properties: ```python for serial, camera in system.cameras.items(): # Return the cammera's UUID: serial # >>> 1234ABCD # ...or through the property: camera.serial # >>> 1234ABCD # Return all camera settings data: camera.camera_settings # >>> {"cameraName": "Camera", "pictureQuality": "720p", ...} # Return the type of camera this object represents: camera.camera_type # >>> doorbell # Return the camera name: camera.name # >>> My Doorbell # Return whether the privacy shutter is open when the # alarm is armed in away mode: camera.shutter_open_when_off # >>> False # Return whether the privacy shutter is open when the # alarm is armed in home mode: camera.shutter_open_when_home # >>> False # Return whether the privacy shutter is open when the # alarm is disarmed: camera.shutter_open_when_off # >>> False # Return the camera status: camera.status # >>> online # Return the camera subscription status: camera.subscription_enabled # >>> True ``` ## Getting the Camera Video URL ```python url = camera.video_url() # >>> https://media.simplisafe.com/v1/... ``` simplisafe-python-2024.01.0/docs/conf.py000066400000000000000000000050421455300150500177210ustar00rootroot00000000000000"""Define docs configuration.""" # Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # import os # import sys # sys.path.insert(0, os.path.abspath('.')) from datetime import datetime # -- Project information ----------------------------------------------------- project = "simplisafe-python" copyright = f"{datetime.today().year}, Aaron Bach" author = "Aaron Bach" # The full version, including alpha/beta/rc tags release = "2024.01.0" # -- General configuration --------------------------------------------------- master_doc = "index" # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "myst_parser", "sphinx.ext.autodoc", "sphinx.ext.autosectionlabel", "sphinx.ext.napoleon", "sphinx_rtd_theme", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {"display_version": True, "style_external_links": True} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # html_static_path = ["_static"] # -- Options for autodoc ------------------------------------------------- autodoc_default_options = {"member-order": "bysource"} autosectionlabel_prefix_document = True simplisafe-python-2024.01.0/docs/images/000077500000000000000000000000001455300150500176665ustar00rootroot00000000000000simplisafe-python-2024.01.0/docs/images/ss-auth-code-in-console.png000066400000000000000000001766551455300150500247600ustar00rootroot00000000000000PNG  IHDRjH*iTXtXML:com.adobe.xmp +Zq ?iCCPICC ProfileHWXS[H "ґB ]:IPb ;**TD(@be]ŮI躯|oo39s̽wPm٨9/jC E2!əƇ8b+h#XN4SG4( sYg:wɖ! VִPB'JXBb! Sأ\6`@Bq(;"lOMr +)B2An@̐Vɴ!_hCr%r2_Yq!ΐ>R1b|a|*fńٌ/`G HѲ X~$0zȾ8'wx !'bˈ Vkqù`"Vܰ 71lx.|bX@3^SQC ;Hƛ@씛34σ Rbqܐ(EJ4-<޹`ٰ=?~cX bÖ?1HpoW_Xq7}x O G.BQ.8s[@MgPgzw~X YPܲ0 {Cvd2J&~b<"QĚ:oHϏe-eA v;`ǚQY]Ok[<,#'+dCCgE_` ij$<& ~Lg?x}aȿ7>^I-߸0v KxRIe|K )q."A,HS`pK 0,EA`8A 8N 2 ^~|B:!-∸!HD#IH )2Y H A~A 'sH'ry"(RQM@Ǡn( Ech::-@+r݋6' u }`S1fal,K0 6+ʰjk*օap"NǙ\xçxoCJ HH' ; ^&% %$b&q6qqJ$>&H$]-ɋIHE+n{%e%#%G@d%RRcJW)}"H2~- -'xZZGÂad3V10n0>jh˵봯h#)֩׹QFQg7AofSz}4Gy*u`}TF?ZvAb'  эF댎=gj1Ylf9ool,5fa$ΤФ)4tiiYZ;ds7 gYXZ$X,hԱXXZ޳YXMfMvβd}qɰdں m7v&v-]=ՎeoWkОaf_hhr٘1kƜ!aݱcCmƑXxmm\ƽru8mvLww^ERjZzM-mYw|.y<3r`L^ۼ)[|}>>|M};}Y~~~V?ȿؿ#@# ."AI`z`m`s`BphSq J }f& kGC׆ߋ0E4FHNQQӣ~@5rscy*nU4-^5~R|M҄1s/$% I;&L\?{I7&[N9)SNUʝz0'37[HVؼ |_:~KP*xV֓67',OV_egn|+k0;!>G)'%HC%jf8mNH5cP\$wrnS&(.>ί?#~3E3/β|ֳgyY8\my_<{AЂ ) VPXZעE͋ /XxIВ""IͥK,× u,|%%e%WViO? L[ٱeբ7]^ZPxmچuuZ?u2-(ʛ6m\sEFJ*U67]컹n- ڢl;q{;w皝z;Kv~%յ;zw{kM=jZimI{/TgWQ_K/7h;v HìƌƮ#!Gښ=jʣZGW[|lxVqk߉ۦ=xZSΞ<} ^g[y;r| /. \/5]v9+'_=}s7nܺ9f-ٷ_ɿ{{=P].]G?(Ǽ/>ܽ)i3g5==-O|BS_TzyO?/'w|]965mOÙ }毡_ WMK.(?yAgV9 +Έ@ldVjj{/|/@Ǎg5RV[]?@~l{m.@IDATxEǟJsN""QT0 *g<3?;s& 3`" d%ޞI;;{J]힞W^璯 @(V-`HHHHHHHH *jx# @&M.A$@$@$@$@$@$@$@T        4!@EM\6HHHHHHH=@$@$@$@$@$@$@$@iB4l PQ{HHHHHHH҄5ir!         *jB$@$@$@$@$@$@$@$@E        HTԤɅ`3HHHHHHHH$@$@$@$@$@$@$@$&I f 5HHHHHHHH MPQ&        *jx @&M.A$@$@$@$@$@$@$@T        4!@EM\6HHHHHHH^L $@$@$@$@$@$@$@$@i@5ip       ,F: 믿Hz6m'R       (,0ĠAa`Y& $SB4֢&,TeGUaۆ9,oC1 @4Yj&cҭEMD]:+*(me; .        7TNeZ}M$@$@$@$@$@$@$@$O ѴKl5"c}-j"1HHHHHHHH @&Z4Ayh        H_,lCJ7(IHHHHHHHr(j:LaE;)ߖ/7s Vw7Ɏ;KzBtCRRTIw]T8\re& J9&;00^ Vo~#    (XbUR+~;NZʚ2rxr +EM3h %Kd؏e5SBŊ2`@ߠ'%5 ,25m(5    %K ʕ++VY/h/Anf,j֬VEQf"{'/28C43ak5$lh!Ҽhu2-6Pਛ7mQFns @. `)ipPJ!g"7ǐ 3cQ勯%AaaUPf-i{`XJpeMJ+ee$@$@$@$@$C7%=oflwZQݶ,,L0iDMޣ7$ɶb%'f      (J͝xK,Lc @5^\nB-IݻӱceΜقTIv}WTUzHHHHHH ۼ_n9W˒ҥ0W 6o&ZZ`4M@_|ȑ2WSClي\,U PDJH0ZI7Cn+;}O:/鯎v)$@$@$@$@$@$~{|`OJxieNL hiAe5/҆\,.SLv גF h ye{HHHHHH - +eJClJH EgUd/$QٮTPTɓ&Ƭvqҩsg&͛ddb?p`fdbFᇷV[O%Y_g9΍7 fShZdd9|(~/oڹsG9e9RVMiҤq3K={dd޼jJ^ԮS[>CFKٷoyaVZ`Y͞n^Z#++KW"u8[W9C aW*̧sSeGevMD_Ju(&U;gMѳdɒҸq (u?S~dٿcrOA֬Y#דe˦mTq_>PT7NZh) 6ŕ2p8ܣ F9Vx>}OI-m |pWY( AJeMkIcDMDa^KxA5zLBh/P|bJXT5￟dmFʗ/˜y%rםw+No,T^{UiQruinX¸3i=0(+jλ#e)yڵ:2K> iM6C1pC͛9ލS~DQ̭fÆ2kls=TZu|WR\BTujז֭[^/kԨ|ӋZydٓCV**R3(`loYSy}ŵR]-YT%)VqD ߩ[X%mURָ4(kS)!}whvy&'PDT=찘hth)Ǽo>k׭K&h0rСIӦ2`n/"5j4kU \h!䗙_|(Em{ŇdH4w˲*x9 WhX˾7rTLL #{J ~ʕTXN0M  cX&YaWoUxw?+:W.d4Fy0-%K_Fhg;nP P4N:s ?I.V#pe Q#ߓgyF4'~Ɲۅ;ԑZc>- o { t/phرcrY\Ġh\% 5(k nX\A~ e Z\J=-Wp_2eNG)"E[d-:73Eq֭&cEz|[=jv`gھq&۲~[:+#u1EF1Oĕ)SF) tJkԨ\'L0q}4<ӍqN>g;YLlAgAÆyrI'&ay V\ve V4M#drq4ckԹa:~8{(XWOI%!_9xc`yC?|MtIumRƆsq<3yƛP(ї~Z>eY~U_ޕ]{?gUkl]{OskK/|Mt[?cY&|>1k,A<zyq\|жǟ|JT mRF w?zrv+W (9<}8x'6 ]#s>D;qwi~2p4S_JV𜂒`~_} 0B!N_~U=-3>-U!m%gbQzvXF6l \yDPzXI PG{0ߦzTR9, l(cPᇷvʠg};LغUT~j>AfՍc?9K/}ȌٶJӑ|EerJ\T(f@BN0 (k Pޔߒ_}(o_x 15]z1;E$M4ϰ[n=]0,C!ܥ`zWwϖh~ZA^V;_3)kZWJ+ܢ8CfE!"7_ +V27y !q0^Ϛ8g6(Cst_t4}Nd])Q%3=pgmֵr|g0j %UРswi*Ud*x :U}ycyGo'0.U\fLPE F7}q󟷅vNi3#.VE@Q+'93];Ղ#yҥXJ}/>t8zWn6dZĊMa%k(ioF>X,YXS6P<42>󬳤| Ô)FӿSy/𼣨ٸq頾ÏkoT4}'ҧO_g9BY% |b]uҨqcS ?:ʔ.]Fꨗי3% gKJ73O=^s+ETVkLX UK2(a}i&F;xjX~]J8=?]uՕjɧlu@( `EbF Q A\k zGԯU4%gbQzv]+<'Oj,'al"6|*QBI0See}"н"ԼC/^V %@[k;NΝI8MP2ƂȫĜVRn*Z+pA L}wvUNUX% mj)ʚWݽG U4!߶ɓE.mH(7SO>acy5x禷tǠq.P0lJN7ceK% hQc-BocGW۸ 6h1`VT9E&P8` (BV;.L9z/t@S:߶3ti^e%\$A ɭ-[SU`T$%ө лIf0RZA}yWM>8C-y펵 ,^W&lA`^Mݻtd1F uysS+7_tJ{U-/ÎH#i :jV02c/&DB/*&"_#ڨ%> ңGwGIc"$fdUᜭtQo|ܝRiᆍL~=Is])^.P]u5Z! :Q4t'X@ꪅ(q`cVc:(f6]s[:DrxQ{t F Yl-UÒaCG /UjUډ܎n{XU6 L˽vTXD{ci72S=TK_]NPfXˁdڻreH_3Aӧ`mbeժjJ(ٸzM',[&c HmepDЫP߻IaIXa<;nZijo9مs{Xg\xy~ϟRccܻs̓^'h`*N{{> hCoʙ Ŕ5u(wGm}uf^"ftakE+ln5 EOMFud*doN^Yv4(aZq-QO5eVVa=i&ȂA;j90hu'{~AKwTG P$*LN!=r+ThLLj9sfKާ90.>etm_\7!Y%EN-\&"K֫+ڀNWJ?k/Wox=@g2fJ:ٖMt2$3>8҅t!.]z~YCifwufma< >A}u'|+K̨/ (jVd[[RSXE&E|1X'Nd|Anue 3leJPx㫫+|v$L,J7/6.y`؃& @t߽̾;s߯RN YfEOR[X*h~ =#VBAR尜[QvdTǷP÷ ={eϘXJXR? ()ipe;w̐,=OYs@_e:w5e¿- YT-ȣ9?n{yQۈ<~#ޘ)΄8yp EL׸XJV֤֢F[NA{b6P 3WpAIC-fĊf>$a,YjGD %l3va)N{>'G-u!y;󯻌F^1]:V4yj$fZNK0=IpmW V=^lm <ʵ BN]&YR!\~r%Qўn}wy;2r?HqbVESTtun?laI%r]?rNmw)rİ(XbZP']]OpOAV0~ymܾfD觌)=`Ϟ?#EXT~qPX`:|a=Xޝy܇?28U5yB@I 3AT4.^ĤSG>udNrK`݃xiG5ʢ$=SJWN\ҡRnwޱ>a'ƽmFͻK긛B wXsCq<'B ~幽 w>XԼ$_J_Q侎>: =Jw^o:x)rv$j#"sݘɓCְ@ڵW! j,&ȉG&cYs'ƾ Ca_[hÃldes z,?5h3XbJZujA'[FgǺ=<'~wZ6ib4,SR[?4M~uׁ,:8χa >}z;In qKm8g D(Z^|˧s ㏛PR4kWҺuhFss?yWƍu~~2f(hP#j.uI.njÇ]w}ZTؼw,rƌc‡zP.Նu]aF (\D9Vy5F;~M tW6GYM ڷm+yi,zĢ2ǔt WP@05~kȁm{ ?;E ͷNSW?aهo)Nfе5:\f ԸP>6*f)E^Ic/^3POjm~de%Pkd@='A!El7>؎WI5% ģA+jPAy:R)a>b:FGƱ[ֽ&Z` m آ#Xf4'(Iu5KY 7x}7 P̦4Lkժi{YFЎW笯3)fYY%fcZSPEV4c XAmXL :G17{1` AKKUa/u(m<,P`Ѿ{Oʋ)S&.X8Vgϛem1̃?+{N*LNsWtUi"`!5fhyLz[!Q2.9P^o "dyjT޽=3s9υ!UUk?ڙ 6={v7P<V-}c[X %`O8LYw߻&c?1wGI2eL8r@GG8-$gbQ|v8Тl{s$w;޲,{vC!poNބ`r8M~3f[C?&u@[,ӝ{#ߓtXpZ5Z:F]ʩhIc ެ}zҌx3Q].ԾZ 7xA(3J1 V[9FGs$O[qjp ׫UFy^_qAT>,mW /wi~ׂ GnK\{r7>-{vyXPt(~ҩZԫ_t2ܨt L6m`J>޶AsN%gqLי3|k̹FYaatcUqs ͷߙ?3(^wp.ܣKkp?v|<|kžL |xiLJF[PX<ο`< ĻJ>ΔdE;"SO rDs0J :zur8kmXncWrsu s u)Mq^%[ '}C^଼˯m9'6`e֭#ۭ~RdQX))Ӭ4/d(}G =eZWi)jEElarv LL_Q-8Na5iP+Z9✩O P@ÐgHC<94WGiNn 5cFJB/nȍ7^/։!:0K.P8Poyw݉l[4˧xASʠsr:tJm-<__(;"H-2ڕB–V喛K)Pa3:(1)4K/ɹϳN/4:ǖ"V-0k,QLTpyݦ DAŗU4)|wuc^Ͽ|Pǿm@ޗ^q;c /QV :w 2 Dwn0S{9sznDUݪUKUX={vwqg;8Mmf,3'wSm <NTB$L,jώ_F<V DS`]E\C}_PxWoSG8[̔>㞱#~?xР*o{Q,1!ieƞlj'5N#xTfw\op$)yRH3ZI}t5#6EI{JAXzSa9ѷDfi /xoX{w`]?B-vy{Mq"^w?<Ʀ{m|ASN id!79xIiF9ƍaqUy39{ɋ/| *h`bV8p#I4-ϻ`AhT_wqmS֬Y+S|.໷ABIl(bZȥ0ߊ=h:]0wV5@spNW༺w`F`ŦR?q*5((r#X{U:8fșRzӵ,prJ8X٪Jj 4;` TE@ECe*h}x#SV=vJ?Zք<̻ ;"k&     H%iELhݝEM&бy,Xd7 @(L&ߣLzl; ~X?8ukFcD4        M:/(UƇpY~;LB[/i7m(rL$@$@$@$@$@$@$@EZmNme< @$ZD&GF/T        7w{;Lw&       N ˬd}xB/ }        dN&ƳoU7f> `6gIH 9+WI K D%NaaԒ>ֲ 5O,>0HHHHHHH$5^˚@*p$ Ϣ&,HHW^!<+ XlYxHHHRJ +6?ŋ1! fdCǒUx      0w7Ɏ;Za7,wH @PRRE)Ud>PQ㵬 Bc=A'     fŊUTHko߾(ԩEeM]-GQc+D`J  ,2ѴiBq>~]Ars O XҠT\Y9Rxœ$O/Anfj֬#}IAӰbMA3xl~"^[      ƟcӃG!MV85NF a$Vv23(<52HHHHH3䓆4ҝG=M}&NO?[{ңgOߴTEx,_l0?!       k <%R`,j|5aEU&41imR!Gij㗙?'\&? guٶm9lٲe| Ҹqc9MXR~4      !n~YOJQeIR]ܢś--P FQL]rYҘh$O>&XO6nܨN6ʒŋ/f͚}Jʡy~eGE6m$asXܛ*VTbE:˯ &/ؓcrZuDJ@%HI4>dQh)ﶬoզZdb-n=E[_2.}MB?s|*$ WiӦeKr?\W(m֫/G}ԬU/HHHHH #+eJClJH \,7ݻeȑRf 9];P'MYS.}OI!*M1ϚT[DGݻW^UA+ڵvG%3IP`:E eYnݺ"K.p @IDAT^.W\yUX|&4}yߠԫ[7^d }$*hU؜rꩅfZ? ~$ oe:GSl~K:EfT<$@$V~i|1H_9LZj̼fxd„+WWEͧ}p+uP0E"r*\AhsӾXm_a+j2gb~'6m"W_uEBeV̙SQd޾ۧgկ,[\z=R/ZUVM4)Ĭ&,2:8"F_ziF[h)\v(W".ZŌ7 eT5=_% 4t:w;L2e(nd؜9ѣe׮]NU+W{#guI}48x*j^{f_99͏}pAvZU[ʚmR033]FPV^iԪY(jyL;8t2m5SNSfۤ7lP?c1 4(eg?fM(/](%?o )*T(/mT!RtF`kɓHM}ODQg#Ҟ)ƻi`60e0% E|+2V8_riՆ[dWz?5`M3|i#s1~2X)HZl&U啗^U9f)׼y 'yK~K t审^P̌Ǡ喟Ԯ]Gt3iܤ[oʕ+lΟ?Onʗw3}zjQO2ȑcd:D*j2$@B[ntѶ[l3fhƍ d? %[e@_|ԭSa!"P޿b].ѰVtcezxe|,\/ݻO0JV%K9z  5HU訆;>l=0ZH;K-G Y_~-1YF`w֭var g{sHbNV+&gʃ7,P̌wy[XtEN7j+4aWzC5g}Nڛt{_aWv-hf2i2HF @(_t?8}gi*?`[\K,:3,YUߕ(@az h!pD 7LM ̝+/<9p%L=]7N9窯 u_?:oUu5rf!W~֪'z;p|D´ aw^|L k٢q|DU/}tjQAZ6oP|2X-qUTΝ:J'$Tqtǣ_Vbh5G^Xl ?p hŴE|'ݒH~8/%++KUInzV"lpnj} dѢf2h[lKDno޼Q^yer0؇hAl˞W:Qҷ֛[olC=|w&')F*@ oEW 6M3%LPԉsA;('F5+dBA>țbkN;|ii]%{D2+`ʚ/b9;n7hPLlwՔ7ǺƉLӍ#lkZ6nSu $RcSMz:kqc NoQI6}|xF~V9w)X'/- scKJ˸O?Rm,o-[)H<2(sI:}jׁü{s]vEkEʚ:ҡQJH#U kH']A /8}W }-q{PΠ)SQ}Qdn @ MXc:Y_\5EڵuVrLoެխkc- B`ʼlc+PBWtz#^*4X˗+qaӶm[;Vƍâ)-LE$s So2lx` KHۍ3Y%yuv sbia{PxbN'1G5k:X$?SM㺙C{1L8Ab|0<+V1"2AyP7%|,YDTS~0%|Wפi~w͟)${VZՙqcL8#hQc-aF3Bc-hCPOw$8$` ,k4CXaNRLb˸2u{m 4Lq@ﹴҎu߫9 Q_YUEr+5kՒ:Y76zܚm?~2A+UKye؅YQ::'Q ^q#5U"wnD9F @~ؤ> o"ӟlV` GM2VˁE-๸Ty&S$Y6p E |O,Z ?ƄB=)'6l4>rh7oǬ*fazJDΝe,(~ꨅ&ɜAd̍FՐ2юŴ`OeΉEM,fl>O:QwnjE 4~  -ްN}%M>-[F6nhN/PQ$5oB4(kl(?SOn(9?nq)'s?Cv&XԸ5~SlUз\FK梁˔)(jpeӖ-[95nܯe-kin)LP@v>ŰmY+re#$n a'U{z>YNKUI4'WEtA?8Mr9ϗyFG'Wv:4+(MpLPV~*)t%h=3Γu$@$++])̬?t}՝/ny3aXlBu->C+5nw>D`/u,|Ҝ;]j:aC8s>tʛ th,E '++YyY]2 +<]YP< f%ڎA(%0iDtX 0ұ'kMիҥ[Gģ>BaD$"1cP 㻧DN:kSrp3rώЊq rzmmcᘔ ي+cl&95C,HH `-ɧ9ºRCbXo9a L{?b<:NԫHənx@G5%7l,f,ga H1<٢~J:LqŚ5k婧1x zsmE;ǂH[7Lc3n|ϿTy_fb D 㬃qw\Ow]b"ОLkɓj2:ukvUQYSO)paoƏK^Qc-il/DM0hoFpsX6*V ^]50:]0 {3.:Cpx}KeB> vU^ Kwcuws̘gJn 3ΐL.?z' rIЍhKxAcQ#PVMܰ9 oٲշS8_ #R$@!^|49c\:k9Ӛ?K35{_xY.]j=s,>ۻ,Gl&get[TAHn`y`Ľ9ss wO4S=p6!,~|XcΘ |v ϩHg=96P[gӈʒ>}zwPdnwD2)5a7&?*-2$],Vd1ąQqHO'O6t͜9sowi:pCJ޽OrډL8ICge[(i WtԠcLsNUl.ĉb?S%N@uR'Ve(.v}-= !8VSSCSar++,RQ:/vw5da z&?n}Ži97l>9b4MKcCKR9V|жLk~2y-8d)ka`Plv/!ʺH9uй>T]uYWN~mFt?Ss>ՙUU5{71 3iR]}@'Pn-Xy=/eVvW8Ct%o˗_|!oê~1I/~{ԫ_|E̋~IuHN\b*%/,~g >[ri}( O>5ߑG5KJe+El^3g,Í:q8D[$0lѢc3z2Ow dL{ٳQ8(5t']Gsȵ^-[ꘄræ]#M}jnOStig|_A9=٥eQܷw|_n(ipM=$J;%ﹽ4?~- Ue - k$N짞|" 1r#ŠX /؏.uނLUҀp`g,ZPI-Ľ;o]#u}ZSB 0wOi^E}KRϞ[n4u_7c4]ݺu. A׮LBO,'P5,hvu.*iHըR-\Xnv79/hQב_|E.*;_fjaE* @@'ʉnݺC)~o9N[k8akZ-5^zQ x;X4cc*i 륒Z ,r܂RŅ}AZbyreqV`Ors V\}tR8ڦU` Lx&0.Azp^ߝj: w}K^^C͏2_JJ}/^|9ә"Y:QG*Ν;#̣1}z٨-[_ _rͳpE^4˛oܸ8ZQ %s?vpHVTI8ʾ0-]1ti`.裏)rڴ!8}m?WVu\ժɃ&CH's}d ceԳ6s=*iΝ'PX%iJ޽+?] ?`< I&dUF1l2:hC񞋅T7VKDģszj3Nq)lMvpi_[tK,pd^'1=x9%\ٽ,jіUO4?V^ck+Ds{3okJK_^#G -BдJW,G=zGVy&] wi>~Q~*5~W"ktn>i&kɯ/XnOQ:ӱ|A_(lRqzFQJ*Vض$)E=V:Q_hgth#@$~]Q~آN xut]t(U(;W&zwf4aђscfG C  ,x .7|C0)iܤ{`)*($xUM:Z h,N!nB$@$@$@$Z7V&|#PbE٬~GQTh-Nɱ 򿝒TMrːʼysewN;Lҭlr?@+PTSB     H7?HQGQ㵜 jP'&kGE9~9e۶>džq:׮PL`H"["* )͵ϽD}IWO8GQ)h*Ca`eVJղЙplc= :;oQX wsgA;v}F N7^9ljHHH'E҇;tE {e*kG̜HJܣܳx/>n8jbA'(z20HHHHHHTرSnf>BLzܳ!P,jZ+wm e.c  RJJ:LҜRqiA ܣWqR@#T,I'o΃ @XlY   \֬Y=sO-'H 05ьOհ,ZS,M`$ @EϢ&۴> @a#PrvJ<     "`|Jb*6ަ\Oh^SIHHHHHHH@,a5L0nT}qO$@$@$@$@$@$@$@$@1XX˗XXyqHHHHHHHH@1Xo_$o4        ,iǿL€PAeq+j,Xp,@$@$@$@$@$@$@$@E@[>qhCw~ƆA C iad Na~x# 1HHHHHHHH!EZzZxCH        JX@bfܹmf'z429VɪQ#'[$@$@$@$@$@$@$@$@k #;"hj;>lδx [kw wIHHHHHHH 85Y.m0VIQ.;"NP9Ɠ @Q%P*\ÿ`LxZ~Q&       XX 0ᠼA^ʜJ$@$@$@$@$@$@$@$N l' óFYPIɊ1$@$@$@$@$@$@$@$@EMvXk6ZhY(7 jbg: Ȳ&׆5 y9,pl?GmٮGK/+H&p>Eq <)JgdYzT,21$@Q 3#Yr=I%q G(,,,W)mm.^O( !pO?J瀄%G6O{bYhRz^xhyPLXͻ][d{ks29~mjDۣD%z`:vyD7AVڋd߯]:sr aqyd^C\*^*ڳ[6ю="w=>"Ful)Ve6+RB|0پ}\~`iۺebS{Ϟ?sϖNGHsque銕kn\z5{F{۔oP}7Qڴl.^ra҇ھ9}0qr1ʖ)#yoeJ޽NRbŸZl*ٴyT(_NשWδOWri]7^'uk,&<~<^JR(<+w)}2{lێِ<| ;'y{_Yzyj_$klܱrs;{>t^wٲu4mؠH e$8ыaag5P%p{H6~4.SIJ%O=.[/#%DϺg4G_Aew":yc(g]GV]SE;zATTb;JOP X,(XPl h *(EAAPq.ɼۼ$//\aKv3 ^Y-<]SX-j{ޛSDu" EIE^,H b\r DŽi[-ex ϸWb\ĭ<0#ip3[P`]D vK+-,OuR I|;nWsрWяfeCٴVشexWeI&[Bs>eNAnSO]=:(7xգϕ㳯L7o1P\ WHsIĹV2=wǫg|nQ秾)fQ}]b[Jx[ JM"0 h;6Dd"^7B;ghi XͱNZUnn^4?=QsEF$]jIVc@c@c , vP٠i7ԫ'ڷm]׶򸝅S;Ysˠ`IOKMqkvh&Ҡ z6C[4ↁ7D֯[Wz2 J1s~.A ̖ﺷӫ5`?swef_,>pOqF, 5F6-CY!;D\ WV"M{a oX"Q,p*^poZ/\ʱAˇ@8{>ءHt '8c^ѯ-E 1":Ote~met.\5k W6"N?s$&ĸ׭=P-Eb6"M*/ZRE>!/ްV4l>CEb"juQLʷK'ģ ÝDblEk͛n jKή΄ʒǩh ޶Y7j U01&|Fdw+jT&Z6k":og)%޼uh@ E-u*C[4n@8ḤkE_W/gԕ{F)D6zh>V*J] \2k7P{7iPT y]k+AOti7Gk+֮ӦN1).׿ vӑE27\'|'ElPGp@0ko_ ћz2Mi(ߺ۶WSpCmм7(PЯǺwR%Sʚ%y`mesF, D۶o&hЏ|WXE$0kWW5hi+Vg=92ľ?CnHy=*[oZp̠,ۓعۯ'X#`'^zuqQGkDJ/Ɯ'BZn0ժQCrN"{XO}_\c~3 2sNnyv=͋;ſLCT$BՉv^4 '݊LQZ'I}8!mݱC!8Qr~0fe\i?Izt$Vv*C8H#?hMK=xGEڣ ;fwڵđG!Oo!\ Kw TF!gX77r4eZhƑ '5882p;['RIqXD*', րH"6 DyKfJH9d0+r{2Mlx6 Y7_Ƭt`"r5j,2GIđtx}e{ ?gpd Ĺ"]5,WT(rIt{>z&w=YdT|2ewH2LQ} !KcFd|PH1g`N [ Hkܠ:"""%7o4bRX&V=^*MO,\Į*&2HԘQ~}g_~MD9xx%~/>Cq-_4C#R i^zsBO+~5 qjWR#NQ'ȍUnʏUi5t%OuCWWx^]j M2͸M2x}/)Ium׋o<3 OidIxB\1f3 O8hRx%3Ҷ (Mh&AP bwEuc;pB5`lv1Fn4ɗsdiZm s1:UX*}l-U$!7ߖ:^?SΎ 0Lyq31=d9Ͽs$E,@ϕ%Xt2^z$psa|A8ΕT7y b^QgENs#vywƧ}lj1L}^qR7\?3`Bx5%}9tvN'-:3PXG<~0wW#DzrM{nm/=GoNό}0"}:n]l?E'Z'(;!.)g[ ք* =+I~!I" 2 ̀X`"MƍFT=9U Xj<ه^)\2{dOIu@x!ל=K6KUHz>ߖcl~x_<@%H>LX ?)whQ~N74)gu\"6s,e<*'^EI$'0diP]$GHoŸ~.NfXiBR= ]e7Uƈ{bVW&ɴ_ mNHi/(:@A_NHlTxV+/X@y54P|Io+dh鯉$"L*in rqRO_HY%7nsأ7blfխ-)ph2^}ť%FrrJDF4'6`ݼߨ@i0vQ?pPޖpX69 *H:p)'[E?,q:ʎ6-[;jN33ڶK#Ɔ͛%repp N q2}璮pVZ-H7!n?t#OG@_ظeXF}uOK%ЪصGڶ(i/cMsN\f/? 7<&jҫE}/m"7׀D8d(n(+ʌ;PX$A,a5 ,a+;nJ= VxV:l<1@7NcH1?!'E*":ӞhAϕ%tHoTJ%~\B[(w0'$%y9Mnipը/ts2wr b+~[-ڲMΛ 6Zp.'mAԉ8`k"1!<3TIāk%N{ou _sq9Ƴ|ѬG؟0=d~ȕG61ϲ-&X_J&ġu1%?$xZKOVj@ ؞ ^ cQihJ1*@ &p"eP[+V"khH?ޱtidGjK)\$&B]3Ĉ >inn;ڔ_6>[KKs88T GmAD}eiOJ~P 6N,bz@#?5fY )>Hg;ĈVb%1eBlI>ߍqWWd[0\$pE[>8biW_/\Kk OLK$*}Ox=ilS9Od0#Hw{k}Lq(qzј]n*1Md*p}-\DpnpXb!In lpGx$-nv1PN),] 6QA p#ŀz< :q|y!sy~HozPyW%p`Bܛ*wkC0XUl|pM&6xJ4|LMqre1̣f~&^wR8Lf$mv@h{"%%Y~E7?$N΢ FHrE9 ͧ:4"\I۪Q'ܞpI@7h7>HXl0bb)pa7O-` `ŰtxcdO*~ YM{6wcK /RK p+_ϕQϜ[8UD5'}}?CjT gdCZNnĚ6?qUr=Z(dUPnX.u`q+ yW 5O`?xqҎ0X ) %7Ѥm¦:i!ZUU7l\pkټvk Qf& YvQlznƙo5 \N+e6oā۔Pmd wzI;ĩ5Eۖ-th+-ŻT"1gdTbY k  a FU eZ +t0g۱[-H!K>%#j<&n)sխg-^ob_ڎqh-RwR j+>x{ 5P$MuH)J' ^7Ş7$yx5 eO:l~ο8ER ""FpY 3.s4:Y)9Us 5:$J"ڬ!> |gX)?Yc%hW>tI׫i j{Ceũ42HLue=@tK /(,fs#Tw2D /? Le Vup9-+c',t_XO!f 1^8"[:?0w9=vA~Lf)oޟc`cOP?*L<ۉkF>3m~OkXET(˼<:g0w" ӆ!-fAFVAj-rḯHcV& UbMnb@Zp0ʄӯ`nE^Jv/ (t-3H=9V1X]lC/%$z^!n4I>yoji2IgKgyLFG⡧ C^CyQr*^a SSkW9sȘ̈́m+X&O+Slv` ];:YO BF*29 Qȧ͡L:HXӤPK:i:ps ?,j 4xM*^`my2Ϩ2Jv+VM5FWu+m;aM}ͨ4A7sj9f~9O~uylǁ4iN p6p|+e. Wϕ1iHW]'5Er핗F34D/XQ0tnBbҬ1{?Ú"HŜ83ۅHƏ:Z269vXzD9J!x(xБO:8UiD8gH<|N?/9%Ν-uҨ &rRE=h$jpdO*ޱ] _##@Yl$#{*X8=X%1[JG_vW2];,r #!Z z77*Rz]WVsćPS}e8B{D 6DxBZz)}tӀ 2JJXV yʓK' sNmf b dKWؾzaN-Q} BY". ~xobVi7 FVn;H| UIتjՌpzeA=XU/+_شPV *>}$VHt%R8u?G#&&?٫\Gv&"?PsjS "f0k,PJ<8iæզHyH́ '$0,FླtpVQӽ}=WcPc 0_8h'Ы(>?ED5B "H3m ~@H&KesS_ Zm>zx /'UHL?-K$%?~\/]:uEtWRE-/(IT_ lBC߸FsYٛt ć~4*;Td攰 ggT;O < _-Z_WH L8Pˇn258\_"2G }'6T.Y||q"Z ޹]]#LʒӮJĈe`>"CGQO].ƶs{w^ <*YZE.V}Pnُ""Uo38lR{_BҲ$Nly%\0 )i"EnL n}Ro f /aV*)۩xӦ*|WlO3^JlLE5^wo8݄k[_NYRl "̮ ۯ-tPw2]ޫ!G%~٩O. is ()ӄ "3fpt Xă8|:wȤ;vP`9Abxl3D':MCAi' \Z"6i<5__fˆU FV|ߣOz"-$@}xc.;iƍ"؋Kd朹h0mVhA|"[Q!īgXk(ekO 5FԷODmk]DOJci ^qXY'&$!ͩ9Hg|9GFҘ{eNpe:l?<1^ CA:܏Դ_5pxϿ>}=3IEx޻լfs\g_U1*ӾQJA BO\V͛ \Nl9-N4I~_|RyQѳ܃ (YbNx 89ޟ嬠GxҜ,.ycڊ uJZd~#81%)px%ז&e%H\f:%a38 'U\,n,SF-0),ⴼwv$DɌ> 2=%:+Â`SWp1n?4.obP*N2oײ tp"՗A@r+:|q"xo'ed" '/䮧*?' H{= "MڕE8NzME/RU}Vs|= c0q(1vjSN*䯊&}ȸd/ 7ϚWyrNi]e8's8 Sf`7Yj>LeiǽD? n|>;] 2ؒuN5dd]֩7cPN !8@kD@\6-o 7˄GZ'l԰` ..Ac\|tƁ3Hm7\'2AGaa>rmnQo##S?NdgK; m nw0Dv;KJ,9GTxЁĉ 9>ў~)anx~@e)H!Z\v B#_|AP~Pϴ<,qmY릡 ^sp[ 2" [fxlB7QHx\Xw(|3ύo+Kd%XAP0՞6  1יZIտKB#85 @a`a];j xnԈ =!'):BÀ+7I6Hϫ/T*q _\;MܔБ" H 2/) ,ZB,Pqƀƀƀƀƀƀƀƀƀƀƀƀƀ@b5Н?^9 e:j7%"T!ӊ&ei h h h h h h h =3fMR41>єƬauB%TtZQb0''';X}xJ"Vn^JVyqfNPLQݴ]c@c@c0PH͐Hgk4E[cJXߋ:1bC5444444c&xÄ E~Baroز}HJJ۷ Z=#x)DfFFаeaNe]]="7\3@ӱC-?@|Ϩ2\Jq]܃9:˘#> NeXԜ^E~Yڋ[vy-X_`~([/S˳L7o1].?FcbPX1Ivڶ^:F?:%eDELn9;11I3Ddfx dB 8>h6 VvaG}ijfX&8V"m0Mc@c@c@c@c@c 6(3' Q :f+/)4=Y#D:HWNeQtH/HCwn Ag|۵:ewi.y8AO-syig&|ǵ72g^fRmZ0=ƇC`،*@EK~Dpcu8$-_:7>$N&8j̅`99gd}yn߶u תYCUC$u*ugcx%;rؾkeQJUcc)NÉy,&~6%VMdR8 W??ovx7=-M `g-敶 N6XzQ?7lmY$۾mΡ.DfO?S_شemNyӸAѥsGK.pŬs'ssX׺E2-&n_uD];x8:}S?JkZC1 /54Xݱff\2 ؏s~+m=LI{t[i \(Qzu#=d *R;Y솃_ԡύmI?EsRb(Jv9&i{pqNJVj[`UWSϟQԉ3͚3_qqlBY숡׉-[4s P gAP?/SI{F %183wg|*DŽe=KKb⪻ozgQ=' K]e{48_.69 w?ُPs_VNUڑM!gKbDIDDV!:}K"mo}4#`^ ѥSGqӠլ,M& `G53ISV-u3}gJ}̀1>Or2'=GwsS?1iƀƀƀƀ@e@ DdN dcoʩgs3fϕkB7Yr`cb/G/V$(6l&݈~_tnڡ̐psq(W)G)4I+h Li%&_}%.&Hs^w|ծUCyI[e+WI{?uM ipھ nS+iq oC:Uv g4:a1nqAy-3V9{?.[!BϢ~}Q0NJ`i{ukג"ЃyDwhb /nip՜wDr*Pʇ;)I/qt iٱ@+pAHv 6ZI!i 9,??_/!> ce!A/^'^Gbv͚AscOQhլ`h_3p;bpnO<p!`y):bMAz$"!?-e^xЗEpZ's/Չ\"q(~W%o>0ֹޘn_2g|/L[\76of"N6&` \E@+h|,w\%''kʌcsΰq) >iV&+epOˌLsw'|<֭[(wA`3a&+/P|>kby,'q~}/I~=3d@?218dOڱI ={B7f\=n#8'_Αy6n\U@|0Vv;uhd79O3\DA0nt@ ;nb3?ط_, :9{BVN#0.ղ`O'+.&g "Ыz8')O99uP)N .[Bۃw))Y" 8"+b3A/F@sϖs(z^Zj?v+ǵcN "qnš2Gr ж|G=Giii$Ps/I+3o^r E ʋ6cD+K?շߓ$ N3^`λO q'_xYi%UI GTa+\rpf.>pas$ !t<j֨.}_AH "WrrrD4Tp14*F]c@c@c@cp+4sL8^nY" ؼE=A 5Ҍ7k* 5ucN6XLkfPWpCzlY$pUHݜ))ѻ* е ֩"S~^a@R;5=|U":I=N4CH332 +-Qlb#*50*7q1@goL&4_t^wH?=˱l0G0o&}!2dUơG]X[NP`U4pSR LMDĽo-x  t rgJ0A ._=Npv2q( (Y%7T l7`ϱiIe"4pT vP9 `!PH SLB(*C07(fORy; M,%f2WKA5 ffjjҴoWL b(N ><_rvÚÄ1"9oמ=#^,^$ rZlHs9F)*ۄ&a =(XxO,24 b@ $/yYZM's_ _ !V#t:1b e9Kƴxx|LӉ@PB s3%ʤf% yypN }&T~gWE0Q Fq>]aHN |b3W5:SER\\(eyqY r$EӾfP;eAqwn|+,,4W7o(+!=J`Nu1+xaJЩcRjgfB"_}gtA["%APFk^yN ^vs3uR'n+sSc'ԕ*VX?n'zّ6R]>'5N51ޢ,sƀƀƀƀ@e@PB ZM{oW6>tY2JiDr`bE8YA!= `0Lr^K.8Lii?_z:)V[~"orˢFRN WA|C E,|az 5Da"{V\s86;qWp}3,_yLEx ҆͊WD_3!fP Vj2VvkQƏ8;My@(ns^%-uAz"k&xCt %^į ^;Bz族CGcM< a7[;(ԭ1m['3S.opo' YiNTRݴ11111P1PÇfh*F*tۘt@f?:.{. Wn#T`ZJ8spzchNmDb b<8OBQRy ~n&:UvB"{]ěB`e⊪[vqGrcWJhs9Rpt{ハ+Ҁ:-f0VX2ܬgrdq. b 6:7&.qM CP5<7/+7  %h'v 9:X)ANh+p^1 @FHӦe C˯15ff}'d`t2;E@`1yPv|YҜV. 55444*-JvXȇɇՄH%]QY=z^ :TVusfMNΰ4$媀HzX졁OqpE&f۾GrC;i :5tppRi*B;=7J)c_ MY8{s= XA6,\C=ȋ|Ax7S^nTE63\+VfwT(5 pe>B…^8{-u1͢7ja8@.Eh+eų²;J5#;oeB A<5^XZOm[uj4^U ggSCr JX{ջnATBUM:*ǧU8vs2O# *`]jɰG:ԬQC\|ۿwFEc@c@c@c@ca@jxH, OoIo axOW_؜:$5g~KNe:J[$W}٦E !qpc9nXGӳ1iPՃ6A̺z"SEh'3N}ݰI|O 7upi?X۾k}/ī7+O5oh`/ןu~p+^fHj=قO EXvt ϒ{؇V`_ǡW 3. {4t_DSF zJ 11ªıO?''KsZ'm;nqx}zl8 YI"1?/vu449&̀T'N&T8PpXM =D |:w>7L&;  6I\qӤH t-v bEpRH^d٠/gp7o&ˇ≯4 ҸAqg/^(5.WKO0/@(2@)q9f³rє j׷OU3X^W mC[?pHv5mu)(B>H3P9 >  ?7Ʈ'זmr. pЊN8d}F(GY q/_ȗL.yyRt)3p8NVQ1ȑ{ƍ/cۈ9AXN _"Z> &{;ڭC' AAWD-.r{C$c Z*1!q-##񄹓?%࡜c>p911111P0Qc]-s&[ kntܡ;UlL!Dڃ+i6-')3UE~kNرĆ8 nf8봒Gۗ I8+T~ oubw&q2 :^uǡE Pyr>Nꄸ幝nj{`'8h?֭7Ç $mݹhC%Xܶ0Y'Ӿʉ22wx nU`8HH넨=ͻnj!0 AƗ059[~{uJ ['@APުy3˸f`a! E%!X,} 6 21ct8' ď/?'p8+sߡ9郛~y0V&pz[H θA]/">M^mam7\'R 'E\J)``g rB&„Y)|4uBwFce8 jm[#^7Kq>¸,UWͭc <av}y`b> X+HOƀƀƀƀᎁ˗L(P&s mNM/68e# ulמ=zO666"Q38}D.IDAT} *XP(?Dހ;o*ڷm*x"&nq0YYM՗-pSyj'S3'7WqHI!ߵH@Y~7P qXneUHqp08]لR; !z$rɯMeCFBwj@شuȐơw8:?@ BDEŰ{sgwVq WX)aF}yyVufƍi^J &ݢתY]44 ;0` *w4,i1N=+Dc~ VP{e2P T4O7f`X̕Nj*LLuuxfbQC4pk 5K>0 5$; p#pPX&woJA"W@g((U6T:Z㖅l}вҷAcp;o#j;t54443 H&.,*X ұy$&I'氫GkʲPOa)q\sK,+$^ ** P*ÌW[Bq,vj80q ^GKsUB ׉ jߋzWWڲ&X’ŒBhTj pes4g @,Sg!6Ҵ"X44444444444444441O`J-"Hb_ؒ"v0aNc1ߦX6>9 Au@RP(EѮ1111111111111P0PXX ($ e9k J$>d.6 Wk h  ;p@Z4kž?Ci4dtrʶyrXI!<;˾yƓ>Q0PTh_l4wt?OrQZVǨPקI]ҫ8d،_1_N1-T~~l&1-_w?hLĀa7(%o2mZizC yOkt Ea 5rt}5-<`pXbٱMxN1Nj̀Pm(& SڭۊX6 +coڪکx/MBO<4y,&^Eʓ$\#;o5Xݓul}~H}6yC)h !6vCᢅBP_J: ظ(\XZzXrE>^xHgE+CQ,o4I㣮H|~*ۉȱ^pQ$)d\'ur:mcĄ ]&7jINEWRN}HUYvM{ !9@cIH 戢5(&ſDG E,e۬P(¼sh^}1ئ=yuYLs=$뭠xV9UHlKs 1DR'S"[ZuW\ Q*@8HBX!TwZ?<]X3:s@E7w4F{K"M4܏Sx:Iړrܒ#'hn2H[>{fizGק$EXݴ_@+h=tӞ@)݄(3Dh>/WᦹJp$I+-d}6( \[R_kcyE{&|‚D¾FGcoZR/^H/! )2+f‹IQXT,:SվU37'HBwcEJis3~45~K>Ӓ}Q݂? Nwh}(櫀p tƨG,F9{DJPHKd6DWH!^ya Mz\a ZLz2 ԡ1nz~n?|#PASEN|B}l}˛!"Z:r爜 WRgP[Ai!e8^IS1!?}])MĊ=<) ;8?tp:HK 6w$[ABq'ur4JƁsdQ3ڋcVyY|2MP0 (+5Nj ? c*~|(p|a"/k [u]П9v7=zw)x#ߴKm~y%:qݜ 犜}j߫š;o H׼{Oϩ*yG2 r_$uZV7Y7_熏ɡCKPU|^$w7Q´_wNdDI:LDZâK Wz?O GDsȔmZ߁޾W{SpN ԹUpȺnW*ޤ6DM, KP0os`f"Y)|P%0EqnWCA#g]O.(6E5"@D4k0Cbꅗʃ n? D{+u{Nruզ!8+/<%rJsE"q4xT'/ABr{ Sj鶭+7dU"ozEb-6V>CM~_bl3YMjϋ趱!DA%O`zGaTrK9\>~题8d? ֺ >Pd?,3t.u.ݞ֦x>&.M~x!JU"<,HL5+g:88DI =29NjrAGB9IޓD_UC{1DT{kGHGoDΔܧEĉ6_% L;Do(P.!MdȑiW@ؐXK`đS&hI1jUɕ>q#.N]&Ҥ]u\; 2ڵG>^D)4\ ndRƘqeN/{ԯ*v2g=f .?y IV0f3*;54*7TlYӪϾ"a#傋VmܰMMTy9#|"B`g)xe>1")FoNfl 醦> Ht0xKq-ll<@H-LYp:|Ad:RQWyK9<_j)Hn2o' / Ny/6*&^uBAޙ&˃J\io&~!RA'EyA?3x%A`,ˢӌܣ|,UC"ӧJ'=;Btq@3Id eubǁ oH-kIx)"1CR%Pzc&X' spp_/QL1FI 5)=bgN-.bj)R.HM:(/TS/P L)n>#x+Iw4vm{2e1^QvD1ҽ?q\7?яU(s2.72nWAK$"_Τ XCKd! i.ER8čKXJ " sUC p qU@ DD?tmb v'CpOuX7u e;Gvɕ"ֻ})9ɨM'd)\U&uHvm\fp14 o؃aAc@c@c40tM9ngR ?aaQČoې"p9KE>`n#С77f" ƒ0ÜF҇-Nc!%e҇S@7ܞ7pCx4|>Qo8]; gXY'2$8[S|/t̀ͪ Hl(iSi%@{# " {  D Rq+ Lp=튁O7*Nd "hu ́Ƽ ɇ D|q4nPFe`{v?s.wx s?DNx+NL>ol!=+`9x3ocwe$qI 5N b sgB>t%.ha/FT" $u: (9{/D2S'匳] kJ"Y@gJ2%G؝#xANDXvdR=⮭5 IGPl0ࠒHH!UXn0ya(6.YÆ rs>UIX qzbNY" 5.R %PX Q+("}<[/iWSsPܺ-B S2q%Y@2ۓaR)}հRlX!6$( 4qxr|M=@_ C %@A-ƳK! u -ÿaAsy"vͿ\uHZC#u AqpkΫ4։f$DWOnʽd"9߈/p$]yyFZ'.g4&ڪ7oXX v?~ E*᠜zjE!>R<3g2wO"q;'Vt0b"I߃lUpSED>U0ŀ΢ 'ʤr EIc@c@c &:7gnzAj|:c)6 JR/S.s'Oyo Ǽ<%8r?$s@z-M&^PCN0;^1CBr:)l!~2#[vu7lL\LLq5r;^x~& L32tX0C2LAI񊄪ވ] IUA,\h 0]ժNHc?.>`M|BTC9pvte=Ib5~< bBM_t$M%y^qT D1p.xq RS,{BGմ _6h5o+n0_8{D{ N+DBė:S`7;GF 1d7^Ҡ1 ;_oP3g Kg2G> 4pZ(@9^֌ت=mRFSx&4Pz)NκupFEoiP QD!>|/XamS̑ U!8 }iҮ^G٢t@0pYY+&|x%cE!Xx\Ʋ']%xM$_i~o+bŲio\`8>h!R" G ؐ"WE+(6nP_vm6cqe"``asi1&N b(\yA5H RŴ:> nev;t^pjq6#8bk#rV?4w#n)._w)ky} =6b?O89 z-vHH* #-ٌz.,i,mSw]o "cv "(O^4Kp$}v.ڛ3-u! CcaTfз+|1^m˂gL>Ә m#c?T:1|^’ʬm{Y=lo?h=떮yiw?Aʷfmghy4}A %Ky@O?6sc@sRvLq5[lR1O$0 l^q s {YN ժU,p3z뗁K{y #M3sWB~hE+oŽˏ?:v^ wOSc/vh<ʆkcĶzn㙳O&$]3*KP&OŮy#k \@WǜyaFf_yMDFf a4 vN]RMp!nOI3VaVMhdiX:4IS:?SHV)^qfdžo^Oa.B3}O,}m#9sH ,xO%{lZ;"{|Y6Pي)i6o9v˶?.Q6sqeK!.F0EDw&iU^CA(m뚤un}pƑ{dn =.hεaދm8w 8m@sI_'j9?ӑF]C- @ ;.ԁ~1~X]4X:v]K{/\t 0u!=N]U"y]u.XBh~_gnc (q}ݢ}E"b󒫊?ꄙHbc+=t4k;tk eCx:PF#m6ɮv۱2 ӋT1 [mݓyWh\Z>/. +4@6i9qY\!Oyq}_qeZ,[mfZC({m7_?W=L?aQx(,Ōf񧸥ζ2@=u-YV@ -:8>^fhO^37hex~!s+ǽCdeS.HRnr===uy1H񣛾>0 Vk|K̳ym4v}UիٻeHǽs?Ҍ{1cc&8ٳ³h48gtSw)-!Q{0i5jx8Sp5D#>=,3>n6KH$/,uSP2э"N':'vԦdCΒЍ&lUԵN- [ L3;F wx0WMSep&i +1R!JkT$0ɀTE^:b=:U G  Ң5GòsaO߇w5ꔅG] |2=4x[6]HZzUbBAp~жjF$;ʼnq{"`*-mo^P({4 _фVs3vDz,yx;\ӸSL"(ѭ bm4s6i45qtJqԔXd٣& 5YPSHHH 0rCuk[EQY  Oؒ) \|9Lf+mj5emX& xG zv9;}2= 6Jz/v)bCmEa/BmZ$@5#iMDHs& o"#     }[,$@$@a!);*iJAcK,[ mӷ5SօHH&:xZz7ګj v`x6SMox $@$@ QǵMSZ:e7%휉k&Ra @ qkH)s= l:M`ljQ#}e\yHHHHHHH>B6j `*a1mm"_rSX.z$@$@$@$@$@$@$@$@\(!Hsl.g @@1USfdyo#        $F _?\`1IۤLk+9        q٨1-ۖnd9TvM8 HHHHHHHHPƷdUtcƓ F 6iX\6HHHHHHHH!?) B$AOb^ U ?iCCPICC ProfileHWXS[H "ґB ]:IPb ;**TD(@be]ŮI躯|oo39s̽wPm٨9/jC E2!əƇ8b+h#XN4SG4( sYg:wɖ! VִPB'JXBb! Sأ\6`@Bq(;"lOMr +)B2An@̐Vɴ!_hCr%r2_Yq!ΐ>R1b|a|*fńٌ/`G HѲ X~$0zȾ8'wx !'bˈ Vkqù`"Vܰ 71lx.|bX@3^SQC ;Hƛ@씛34σ Rbqܐ(EJ4-<޹`ٰ=?~cX bÖ?1HpoW_Xq7}x O G.BQ.8s[@MgPgzw~X YPܲ0 {Cvd2J&~b<"QĚ:oHϏe-eA v;`ǚQY]Ok[<,#'+dCCgE_` ij$<& ~Lg?x}aȿ7>^I-߸0v KxRIe|K )q."A,HS`pK 0,EA`8A 8N 2 ^~|B:!-∸!HD#IH )2Y H A~A 'sH'ry"(RQM@Ǡn( Ech::-@+r݋6' u }`S1fal,K0 6+ʰjk*օap"NǙ\xçxoCJ HH' ; ^&% %$b&q6qqJ$>&H$]-ɋIHE+n{%e%#%G@d%RRcJW)}"H2~- -'xZZGÂad3V10n0>jh˵봯h#)֩׹QFQg7AofSz}4Gy*u`}TF?ZvAb'  эF댎=gj1Ylf9ool,5fa$ΤФ)4tiiYZ;ds7 gYXZ$X,hԱXXZ޳YXMfMvβd}qɰdں m7v&v-]=ՎeoWkОaf_hhr٘1kƜ!aݱcCmƑXxmm\ƽru8mvLww^ERjZzM-mYw|.y<3r`L^ۼ)[|}>>|M};}Y~~~V?ȿؿ#@# ."AI`z`m`s`BphSq J }f& kGC׆ߋ0E4FHNQQӣ~@5rscy*nU4-^5~R|M҄1s/$% I;&L\?{I7&[N9)SNUʝz0'37[HVؼ |_:~KP*xV֓67',OV_egn|+k0;!>G)'%HC%jf8mNH5cP\$wrnS&(.>ί?#~3E3/β|ֳgyY8\my_<{AЂ ) VPXZעE͋ /XxIВ""IͥK,× u,|%%e%WViO? L[ٱeբ7]^ZPxmچuuZ?u2-(ʛ6m\sEFJ*U67]컹n- ڢl;q{;w皝z;Kv~%յ;zw{kM=jZimI{/TgWQ_K/7h;v HìƌƮ#!Gښ=jʣZGW[|lxVqk߉ۦ=xZSΞ<} ^g[y;r| /. \/5]v9+'_=}s7nܺ9f-ٷ_ɿ{{=P].]G?(Ǽ/>ܽ)i3g5==-O|BS_TzyO?/'w|]965mOÙ }毡_ WMK.(?yAgV9 +Έ@ldVjj{/|/@Ǎg5RV[]?@~l{m.@IDATx`o" HK4*(={ņ_} {`D)Ҕ CK'|ws'f7Mܹs7%s9ܸ )ZF$@$@$@$@$@$@$@$ ˱ @\\bI       `h, PX$ J N RX ˑ $       @c%P,C$@$@$@$@$@$@$@NIIHHHHHHH @ c%@V,F$@$@$@$@$@$@$@^RRRTA94Մ ;^ JV}SIIl͚zZ\W$     ` -  Qfmjպ)qrJqէ_?k~r/7#_5J5lHo+YN/_L-]D]@eC-p +6*p1.~nR%$$W={W5ktuN:6lب7rQs*WjܨUf>Yf3gRTTٲe~уn,XP{nYUr%umUѢE}y@JJ/zJ*ZjwO7=eʕ\];e ׫5k֨%m/˻}#Ӧ_ ,L:Uխ[O%U&ӿUuCNZL [1B" Q >HWP)q%>>>6?5@P3{_]7mD +&l٪.|+C0k %K2O3nݪ}CuQ޷oZvίPA]@uzKH'? +iiioGstw)o6ɓQ}zTB?Μ9^z"D6~;'OF .ܹ* D қҥsSQ{wQժWs$TTI naʔ_U;ҠA}ŜG$ 0r9gby#vqni(P7rk? s֫ͶgD3$Y+vQWLMFP={z,Q}n֬ѣjӦ*{ b -3{KT)\[+P]Q%&&jzGKK˿^zEuqF?hOV˖eAdj_`H@pCfBD",*m4\nD|eBQBiP4,;x~!Ox.U۷JLJh٢ׯO+$ F9pM7H\wLfĉՔ_ӧϨw}O=5tNE]<`Vz{כ}_׈7;Gg}9+s:غe_7ߜjS *VLOBH  9+HEyMSNꐃ:uj֭ZboP>WM75}A:'%@Bv@k"IIB*+bÇ5^lu/j t"+T(oVou}h:)_-PW\ݫ…86mڬķUH67ѫ,eiZ5utX9y&]ɕ{tpܝwr!qEJٲrh%N,ow)yڻoD&4y:0nݦϏաCvh~Nk޽{]ȸYM?R%w<> _W_Wwy^u|6tAReÿ\>%JDed]̞3W-0?~\UU.]ssODށ}ۿCO9vz嗴WcS͘>],Dzi&MTYgC_cUܳϨxQOSߚrwVLN_Zl$_,Y{eW%KH uH97jXՖkg^ vbqݹX*?_͗~d/iz:}*Pz 4*c|^y[=coʈ+@ϕLg@@ITvŋʕԍ7ĕM7뤦)tĂgT%! O\ p ˱:ft= T?`=d#y"f{YO a$]Eb-SޔA[+7^y0Wߦ:L% GUR,oqr3Ik_~ lȫD_ .mo5 t>qo98Z\ cCݰm۶+x ǭl(7eT5~D*Xt kƫˏgsʾg'+ʾn4O'%%Z;voSeʜc3__!@_G3[ڵjYq_N4@?tZ- 5[|Rdcӽ02ߥEDȣ{Z}?`YC/jѦiS>}WS<0nc`u@E  uSR3 +^r,U=ª~sL㏹?Cqf9;l'1)QFY.փܯv` {ukӈ(FT1f>ĕf)ǔ̻Zi#/Η0/|#`^b8jxL`з?qŭ 9!`QcSyQjIn`a^3Μ+Xkċ͡[|~ǖҭkyH;fJ'v*"AU|.c3/ ?QD jA=TʰE(#&OKX/n#BI a[x. :^{S }q{4DtzRy^na4꧎?Ət(Knxco和V=Ùk3L80r NCۈ*Hܭ[Ul=?yb4*nPΝ//^,%!aCRREuEihEXt|2=x'M<9 ͟A\D{`~i/Λ Տ?|!rOeU=n԰Q L͋͹?dﯾV{<^.Ep1 iZc;kQIr- լW}LoU]uj׾*$͛>gիV'{Laԡ7W"^7f͚"U-/xa!c^~?;r%!駟:\.rUDH}qЃo$Oyg`x[M;xٶm=wߥW_k-ۺ8ψ(FT1f~rQ\ƣ67[>4o{s;te܄x!tJ,^Œ=Z \^}յ{i/qY.Dlwsc'&wj1#F?ٟ~@Q<|޻~#o#7+%v-C=On_@xupP'&Р>x6 n&7}ܚ5kuONCYQR|9qXM3rMV:+7:(>=igxq0PBBBc oHanz: T{DO|=O<{Ox$#xp<$B(Y%ttu3zj1y垘Eex UT?7^+S⡂.a'Oʴm<^`ׁC>&<7A&oașbl䌂u̧O$u8G4:x6-{xЯdwyn[%y-F;2Ib}5xWxљaPIc_|4cg*|9tUW˱YcBD ;U?9=e);sȂ,){yX3ysL]"~W^õ d^Faٲe '{Ϥqβ~q/PX/?yUDvA`ԛoUċnٵY,۷ |I kа)=N k#.9`8Nѣyn_4nfӗ]~;PD+ń:o*pF3{v-Ch]uwņᖝb | +f_Ib!: 7#ƙm/dN+/!de[x(V%-F 垘NnKM<%ȵk)*2&?6ma  C] aCYC1ۗUFҳj q(C0@h%$(,+Qe?(Ae{<:P0h eӁ*-E@ ZaF rb ꈔJ<+vCFy]A>q~(:!ZJ T_Rc%Sx(<: ,*{26{\}.u7{\}~A;C2j p@ O˛5k/=1?;4,z˭,ՠACɋwY`>;q`=/ӒK;hYaas\p?& š]xakB{҄sefAmwq oc잉Dw*tTּN(#mE,@EӊWw4*B'  ,Xo8wM+>Y[}7#rV?L:JCewVڭ8C ( C5<.LSN,+*Ѓ}h1|wttѡ4,IhLy ay}}!&zEb[oNAL xtYx\9ð+WB26>8rbkͤeժ: C|:Sp.0zk_p[_dϘ1Y`uЙy}佸֛S6S9OѢŴd ,G(Ydci';v A&jxq#ut½A/Dѽ=1;\a̼k4IT|&nv=et(C Es;Ʈ_ճ% y@r.磌*íߍe.dV}Сj䲏=Z4b# eO[W$||uk٭67]g4S6gH w DHZ)$HԽT^48 !?&ǹ B]s+yQ "ϊcR |?fs#@x:S0t8LCrM61/3օOú\йGwڌxym@i3 b\|gyM:`E#5"НW yO9nfy `WLNO?7.:ytجuժUKk;G[Ĉ*cx=؁LVDO?51o{;?.d8atKz{#*>b;9ib|蓏{ &{\H*lP|ǘv{9^ef`CxL sfi?D@.={OosۙM0G}mWJ5|D`FTA|H|9w}N0k=1\Xbݚ7onl٪5mh%9 BHU&&?umWr;i %ncsۿ!*]u@Q7 D  ܪ=}K$ & e3j(A=G}O{VN_p(߾3Ym;nVS׃{wV= : ɛ4tڑ[䉓awAɶ TlڼEs*'K ~y Mp(z>$4%NPJMmO&˭\ ń%J<<e "!IRòeHؕ'|. =;׵Ae }LF<`-{b~wdud}\SD@\-$u#Ͷm[x8V#mO$? T?F z '`ĵH͘v*zފJuߡ #TD^G,y(| 6I 8ݭ^*N/_ur> J<3o)oNJ/оq4ܴVzQ͜+i}Tu5ϗNg=$@$@$@$@$@$@%PT)+sKXWD-q}>C#ڈyҤjժefU6M 'V֬YJ$@$@$Jd%ڜͭ @~&PC }'       Zg&M$@$@$@$@$@$@$@@&GGJ$@$@$@$@$@$@$VȱY qX'GI$@$@$@$@$@$@$viiiX ;UVH$@$@$@$@$@$@$o0(j( @x c%"P|t$@$@$@$@$@$@$@a&P0^$xIKI-Ӱ8 = @4JvR"`Z>x +( W+T; M$@$@$@$@$@$@$@ErDXў)gNgK'4,b+J\|&^+\;]PfLx_fV᭘1u($k(  D$@$@~o8,"̐"/bzx#*gN*g(%bkR+(+)k$ xqHHHHHHH wD4H<%J*X*V୒"@i*.e% a@q"ZQ"G *X9YdE; J$@$@$@$@$@߯?[N-ZLUxwѢE]Lٳդ\WiߡбH͌H(S'U qEUDT+\$Z\AI`1O~*:U x)o3(0C"uV^     mԼk+Lge5jTW6T5jTbVŹ\ Zu>f&o Ƹ=U ?.*F\Qńʩ[Kԩ-=R/jfBsUBDUn#PX) X**V U{ٳG`YF>tH9%Jɍjب*]= 7r!ؾCعCV+zSΙ%Ӧ6Vj劕O~,+ ҕMXyUV+V=*'C3 IE Ȥ8nwJ 5( X;QRmٲ9&̘>Mǂx*V,&r\1G@T[ݺtOj' O>Hu=U4`UZj cꀸC :|4|$.S*_-?Z>YfKXjР*+iB~muRTEy睫ʝw:묳߇ou*#m5ir*Y쀷| g^xf ޽[^R 6w/-ڶm y^<"s ZڢE`s޼رZ$UrB7m,dOS]ta rqI??ty.bu\f~P'/Wke@z[,QVD"AMk3mT>V0:HphDc[g;*ϐHP^JvD#H1U%"aLA%hCD);٥O~~1bhzᅪjժz7 ۷OmXN-_LڵNp?WNjZ蝆y۴ ڹc^ EnnFNBP7E'M 7 "|˭a6%%%LŠxmPRJEdApLp8SflBj7#2t=<'˨AGbK/թSךk?$ӰSQTg O'ԋ?yQ?N,~0:E0+LuNs]sUwv9&}:\$xK7?cݹoM$вE կ_*!j7>LǬXD],z㏜!gՐ'h33mBT+S R?1aڠPTQCxӿZ'\o&$mw%X*^IBqvC,7lX_?LsjlµO',DuH̀ms%P oPk:NJs=W &UHbғfįyF=8PXQuNc?ą\uč#xsx睷[֯8LN (([1εqPEk孚uiѠ~}ue5~c?,XbO}HD]u9<&J$1>O^ncܗO>C-_RmyߊyO׷ΕZ ki&f{6D޽:WŠBo Ic0zBK<)Cډ±E\{ҹ^Pu7r πlu  N۟, w}8F  -dKFS$FE?n+VWVZIyYDɋOXK(a ,jUNa%ZqSȂ?v{u+yX#?Sᡍ!\}U?զM+H\exbkLs)S?{aV5t!"CS;)E:QxUO k&z8(D29CPÏl5k>j[Tr"ON˖E(C_.p=fwgBF =r!ApݭYVG01믿3J`sۭ71m_oӦ 佸;d]y-!\%K:xo,vV~}h#q=ҫVX ?o!19+}y=~f/iA҆0}ɭΕ+W[C0ǎz2:}& MYrz'-ϯ]"Z,w}IMfδl [M_p/Fۍ~VsD 2^R嬯,;vfϞ%cHH?VPLH?sdB~f m"i߁xWryBPF Hu_/ʕ>2C[oc%5;O3}L6<:w\{bkmbcVƋk5eZl ,,^Ϙ^{S }q]&2+F &-¶Yn>[:_os KXYn./2l'B `C|a u +sM7*iFxEm_$y/w': .қ@ǭbիko,@9tCa} C]P@L( F˚px%%%˭4¢րP0]d$c31)Q}&}d9i޼5C JRC+B/'Q뢤xn\ܶ:9Wc?˯k)v'û} ފvl\z:uʪ&V>wб ,o2ϒHfg4h3 ma6޾nm <|5VFT$x&nb23 CQC>6tŒ!ij֞+|_:\}ͺ$/i$ bէY{̼c L_ft` oL=,y8bU:vh,#:r58mJ*宒|0!E >1cFX@ 7O{pfd,t ;Yb7)}DX=0a~OѲ<11Qо@c~2{ޝ!"ҟ;qŲ=)P{ r` rݺg .JO?v<4Ct 1$9Z5wo_} qfp/K^;mTbq3^}&v-ĉ9DP2W*'FNXS@D68<>@k^%,aun޴IUknV^}sžu\Pzvk Z aAw4#}1m_ ӌuŠp"'Etԗ_>U?MdgU3$˄oL]zڲu J{+ SHP+a x F{!YFke#GIsx/|@G9=`+?7<5I|LfZBCf VU 5T}Q?$fBP8ɓan۵ 78 Z 47D(۽[W-bJaZX`AuM(Iʑ f] ]k>k:NܹKWk:a_q>R@bb-CP-}N sf϶_{ Kxp(8f*!nA(P0](mϙk_5u{6Gm!$ޛ7g!%s/aMrm2~sƛ]tW+Wqw*?'f6u<ωrp/O UWFxQhBݢ9fNNg@n睗1Fqs'ږ-[jp[xOΗ$bѱ˺X Sߖg}fttF,pnn(9C>cxM`w>]`ec&2g8q\cbFÏ>sf, 36G3Jc723Yp_ E B8 WÞv?&p}b4P 0<6HkW^SϜA&iC߸.Wp.Ў#G-yHɎq;z_בxXG]>:yxfLA\?gB81 mhAh3Li(/FF]l?BaŋH1wny{~v ?mڴ fPA!} L!ow@M\ G4N>T%Tլh C+ `Cn) 242~C\qܜ~/i,NTQCN^|DxY:0p5f1#a:lԗ7/VC[HC^F p)SZ.5kEjF0N[d9#Cc,Λݺ{p=a[p4s)]n1l?C'ڄ Z`5|xbYsޣ G9{EPTREקE!o8 5,w5"0^Իc{&NUMJKPA~ܗqRQ2,1cՌrx %dE un&UxY9XS+PC4^Lc^ֵ{B"I8VW!vwBDP'6 `ض(0$NJjJFAf{ nU[}>nBndv{nv!' <]Fq%m0ݧ.C~=. t,~y^^RE=3?S/Q̘gF= vzc< v X֙ "l;ǤÍ!\ax?y/)TctXCɗ0! 5Ĵ |6 wHXB(qٷf:&Lxν9s3KNޤ0]=} #9\HIrv?2oi"c,E]h%]:y.ڈs/e5Y3O2Pͪgثcǎ_~ᵑC  oU\:QWȥv㍃|n= p3صkNY=JD 2VHa7&kM4euS3IJq&}AXO; 46~Pdw$"mJeߔw~\dF̪#zfu jVR[! t\K @`r-fhu4\}WXF(`; 0*r;[^=:t^N7۷Sӧйyy @AtP`7?e˖!3B:mQrZ$`OVZ{ȓO2 7?M7ݠޔ(7~D@Xњ5kz:گ+V*||pτ.AwqwctNLjnd*脾9uY:X>c>Ìu1ޡӻW^#zf:pZB;̷ ˆ s &_|9JsOԇ{fMwF^ۤlS%g=&:ߗ{K½ًm:}8 03Fv̒=7^5lWQ^X]xrCJ{&DGcrf6I6e{ݠ k$^qEEwsϿ=@[-ƌ-$7Np-Nxe]t=w)ÀaWnuqzu M ?s洔Ż$|@0lˈ;3/<źjת<¾3¾`.h+s}ǿo# ,+_zPy}Ƽ@ +O?=TS0syo)S,^$ENcQC1<ܢeӈ(:Xnxmٗ2 ' k?ZR:JJ=uB&?fORXI"仨OjLsDlIuA*#qǎ vH:nh$DBm͚z8ӍsߎY>Ě&p=+k5.\yB9?v>Bz/>X:xGp8O}wPVh6`K\y(5[pAq>俒g76 fnc^N;v7F PPљ}&! /^.5.zpN-?GObc 7W.v ^V1}L`[8RReϊֹb/t8}׮cZ2_%; ΁0cyI>@ON'Dp?৶mVGf=HT*J\V QD\KW h@i< 3+9U0B`W>._m +mkV% ǩ=oZ>#7 +-Al3SJ$σS| s.廵c8[X Lptذ?SCKx3z9%*eE @rx+Yx $VZ^( Sq3Gy+T(S fDow͈٢3D AX)֍A),$Ri,pKi# f WIR nT%nQW.Q$S{Ty+/ZL%=W%TN5cV (NRH~cEۢ ]\ov5r @X5Հ%)wšBis֙Cb>}fqńJ$A)WF ֭[uΕP\]h"ɥS%:'))$auzj:'*~$t>̡x hnH ~l4nF1*#G}͛oՈςd+V!Nk嬥YX$Bd:U-1L4_$,*-5E>o0#q7!mI$Yb>Ao+w r[jժKrݘwu?_󞽺zi?Sػ,z<67/+B(5 wubĈ/un_q} )i(M5 _yj mذbFEbOjk|OtR>,Y Jz㐄xoS"ȧ%CD4E( qK D߲G\O+XH^rrvZuH'xqX5jH*SY=_}SX?A[eIn|v ʕ,$R ΍+)S\ O8+#|D`gʽw˗/pWIH o}?o_亂13lop#}/͎Xq69W$J*TQ?5ETDXQ)򁷊Ab3Nf&dmԡCqc ҈P 6Pe@ek'ȹr 6 σ<|]ߕHk(h}  #;d䗙›cŹ Ɉ=i<)$^+%G4W ,]Wxϗ8/CaHe @$ϕXٯ.rH rBTr    _sT LXa>ZAX       $y\*       !+1rL       "Q(D1akHHHHHHHb@I^Ka%FI$@$@$@$@$@$@$}(D1aHHHHH36/ʔ9DB!R2Y2!1yL̅ <+Be{8s};>|9Zk $tXI7Y$      c%HHHHHHH H<9-VȽb6IHHHHHH7oR C$@$@$@$@$@$@$0JY* @|"@t7       E ]bfIHHHHHH{YNIHHHHHHHb *U*ZIHHHHHHHȲXɟ;q- # 䈅+IHHHHHHH;d;!n%       /h7 7Txm$@$@$@$@$@$@$@$@2pHHHHHHHb"` ӎ2in' $EKbIHHHH\ XqE%       G"       hHAhTHHHHHHH| p *V|]HHHHHHHH EC$@$@$@$@$@$@$@$(V|ȷ$ @ڹSmݲY=zT]~=ӤLR̙S/QR,T(4xPh~#S!   &@J\H| rr~z 24S'OUy?[B3,X( : LJc IH?lJڵU%T4iK$j+W-[s4ߝ+ H   o(HJrE5PHP @\.]֬hB{rk|.oܘeJYxI׬Sʓ;*` ~2U+?RN5 [gΪO_>XyZrv*Schc*ez^bUk>o7+6#Gv;WNGŮ;nϪtHHHH qp˴XIWE$ *Ŋo?>4nbٳjj֨/~:rĺQҫz=X9yFX?qu>ղqX+>x`Pz+Ws-TKb Qyo;Rj'wkڌm;TBRB $@$@kBXAO#{|Vd ue.u K~<@dׁSӧW/Kr܇H5 TIKv'CjCe?^Bp0{sHZv(dg)z>2O\*sL.;w?uqu?ACGɓ'jVwُ $@$@$ ú/awʬsCE+۶mW -QY2gvQg TVsqG   ȝ^ի{Ls0 kZ(cEux B$@$@I@H+v2fTi?\ovci_Mմqzu$@$@$@ XGĭ CE JUPV5qL[# HH2 ԴKx1w?W[y+L$@$ ڽG͚3_ug6ժeSpRiViVs5Euì uUSո&j? |0UeϧM^nҐf[~t*?ŋQb53itQ?qR}cSi=kVsֹP%O\UZI{3 =!>&}Mk֫(WQp9h|gE+;w+<_sT%Sʖv|^%`[oX ?K]|Yj֨X޶,_XV|Z8yJ6K̪jJG[nW||yݯ/\vޭΜ>kl\ҪN;䚋TlK?)<`g~uBwf˦""r*1A#f$@$@a&oU-VsBk$@$@s#߫ZaϿy dvƟ~}ztU+7nXF5uT ׮7i"hfNLz6z~?4>We钂9CZ6oulE`X"jێ Dzx 3 \xq7X;Cj/ureҔ/%Ե\) E`u0/gVͷy|mvuAkfb?([XOԊBydggoWX|d?L?=b72]i? FL2NM[Ԗ;ToJYpJ$@$o ĩbeyjo/o1+[ Ke߯5/5TLuq.?DtɊLwMk3$@$@@ë_˧0Iӌ3d06P@JQz7}z8H5}ϧ7ڶRi (Bn/Pݽh aL,J*|KIeڵ{]*,"eЇ[.BF΍2gjsr%\n>dRZ 4r+Yl93ORxպ˗W.Y\[.1!(8 7R_zQV+({:.t GP?Xgmu.u?l4@Hi]'v2x&`e,5id?a"kqiԠCf/;*V{ieKbVeFc.]p 䇍7P?\A:Z*[.J׮_w Z!),vW]Yuoُ<ޙd}?ݼnG=Md䩆 @\ "PHHH >H9fxb~}z{'zO+ZJʏTMa*0~V JwK>}F&p; *PƼݱBHH 4@"r UOjwO~f &MjMU%BpG ƅTp3]1$tTE7iomOae$[/ 4!pτ7 B`5)neJe Ta^wELO <#F12D`M_v ֮alt Lŗ֯[G瀵3HH3+V5xAӕL}R#X80% da_% ZS+VҤIZfN`k@IDATIHpbE܀`6! `X9԰KhW!5kfD@FQӗh(O2atiEےZp@[iH. zX@ Nf5H!#Uq gkی*(m4n[c^|ɚ&4R[u,`u֯43`}cNEZu[d_9ϟW @%Ts._E{Aۧt宻/rHH <^QQV׬akkլ#yBaU]fUSOgBF9qVD`ױݭn3)gla}qCf.=7"0xٷ}NK"CwfN.0 gSzrkM|ވ<%pǃbɌe+ұdOgu13BV԰J]x.`ֱk8rNC$@$@!,]B}!Y"UTR1dZ) -rb<eʗuɘX'qy˶h# FBUir<bF0}zvEk:ok^fBLMjπؑ}>2|X8kd%V)>}Ha(Vgo6Μ5e'"(Wv.N`TAܠ[E#N<ӉjA&{F`}Er弥uLaUA^ISP|ʄ1.I!J:ϨACG. @}X kz_W`yF{E-ӑ D@ézfIpȐ56GanZT _MKaFjU~|n*T%W(w5~0 eqȜy ҬId ܧ?ϛ]pOP?aYh=ޔ*顢Bl-\uoaGC`'`V[[3fgXdA,!iYՆWۑJΕSz<Ҫ iݥ33٩^leq_#duhɽm"~?wD+E*h =16+V~o8f'#  8${c$C kZ@=# PYfl|$@$0 =z]Ʌ!i'^BPLU#-,_}Ei(e;HS]ڥz $ nk˃-[m6 |_e!pžH_U0,!)iĊ #PM|J*:q9?A?X%TM?_|9S+;~Smh?B%O\ڿF 8{M m&_ܲMhD|A Tn-u\TI8uT-Za4n:ʊ%~7F±M7tJHHH @'FXP"v񯋷zXl?ҫUA[`sY*U"$@ @Μ9MVl,KZ Ԯ"vL 5| ZspOl-\D*h|#&CMUw@\ xTA^c smKZv{~cנC}TZl^8#fIZlj܇ĮTX5fyq:޾q U홁5gcGw7ٲݡaX|R噧jOuzO!Pr۳ԼI#ewEBgX|=R4G)p0M0q>`t:`[^WC?B5-6 ,8Y]E̵k׬׷O+G]vk"C׬8C$@KdžڹS͙=˜vڪD sFƓ(T(6.#l ush9+=^`v`#.$Cq6mZ]|^3a]y?s?cPN:zd)*4][GɐRЖV+n0$@$@$@$@$@$@$ aEF|ƈ!      d]z-\8/ $8]bo<%'&      @dTJ_ȶm[թS!KQ~ÿ[B]~M+V\eϞwo^1CFUG7Orႅ꺏#=@rxw;vM*m4f͚ACS񚏫Y*<@jTSPN6+W2gάe{<mۮ<2ęTbo>s.k}y]{Uy#Tr=V\0UٲGƿf_#G?z9gΜ*O駟V9prڵkuꗝ;C=ܣDQ˗C^5ݻW-[uauIݛ^UDqUjX۶nSRvQM /^Vκ Qٲe=H#J.,eaF]쭲̮]ULT*r@2ϗ qOw]we?)RH1{Uw6۟~)zZx]ohEΜ9֭[o^"Nu֩3gr;z9qf[Ճ>x'PRr/)ܿ/4%_8RߦWrڵkB7jhѢ|7V R,dW.… /4x 0*ϊFBسgOܼ˪q&s̶>}Z[cǨ|yT''a{>ob߫?ϟbT<Ĕ)4{iv F?+WE!CMPjc60oUTRg6eWx}&MR^sQZZ1+ҵ*YXYQ/VG*N.o7;tTR.;v0cnjUʕL.f>k|ڵZzutb*O<ꃁTA]|=sQBٛ+G p}+kU®X>|VYQ+[>x)Sb%֍sj4NUNp7޻ov0 p'wot GӧWk~切t@ȺE K rfʖ`B5j(:]SzܸOMsk?9I.oܸQ}8#sN7&.+i4iju C-+Yۀz e5E}[䣏?X^6l]tbE`rJa$bş|lذRฅ F/q'/>NI{;ƾΟ1cǚKbT… [dfMf\r`_I72+NjA#>eJ׾>X%!ORw|.1)Vs,/G>|R V(kYӼNgI[CD-S*CifEw0CPOܡ;ؠdvGc{dH W,D,hߧym}IW~8DNkzU[Z$%M gΜe.&u/meԺUkWj . O?v j{ϰ87VI&-AlVJ 4l~iӧW{^ڼ(/P1|zg՝79\rKmڴ6LaouRSN5 UVZbWZ/>9xjFۺu UFpxr{t9VY2gX3)~?bBT~l򾧶 :L=kcScNyF׷yf/D;Ú5k\AXM܀*Uz?J$3PƋlUwSeeWN̙=Guim_h匧rvH#3ݫTe^E?~\iQ n5rO$@k72vIDAR-[bȈ%`L`1 Ɖ4@[@PA?"p7vYP]l)|Ȋes?V3dPV} )UpwܡcS: qK4yʤhJ&M(Ç`im&Cxt?סr AO7;w,{7kҺ׏ @ٌƼs",JȂެD_֮S0^{+w-tX[#ekBݞ-["O?ݲԑ}+kdpXesqfѢϸ.=uEHDވDan.?(UݹnFSPĵ:mRB$ bejdɢ&mk0 +䯿RtlcQV<.=4𽾤cLdmK2}Ny\`dgϞ5!逢/iEBDD2E@ӿAAZgEx.$~?g_iW5kא*5i^{ .==}y wO:mmORD0;>G[WoM:P^ =_g w+USl ٮϽaN逸4:i'k%Y,w֬٦ `ēOh6n߾ݸ4,TмoNQN̟0e{0ׇ^ЖyzBFn,8 m57Y'5favUGu.t;K[ݭAP7~@˔)cb7{tQ[t (?-w_dRf6W 4Fyg]#ङ3e61PU]๵}Ocu`eu06ցEY0mt 8)Gk}<[%KԱZ뀢YLe7pJcώ j2,aN#4&{_-rk8"}+1(Y+n~~2ɣLFrj3Ňo>]~H rsY..)= o,g:FA@V4wP !I"IAPB(@iׄ(O,?{~PyTy=+[;ta;X2X}cy;4P`͚=Ey%E۶mw,C2fʈMa*U2ig:F<)??.(Wƌc=t.`Fbb&\EF~/-]ЀxY_/!ϟ>CWKy>P'OqnN;M|I{sr&*NnBFdq_T &4>JU0P`ءvA|Su2bHoRKw ="t 58~xXƳ'R4"([4nRCZ@3Ϙ=;u-9nXmMb0h@~}Aٌ6busΛ;BNۜĢ5oڵΏQ֤8r?~(vSq/}tiѲ9 #`urJ ?~[E]3 I@Bpbŗҥʢʳl~ }\SC 6$ $beSTRLQh- _ha4WS *=Y/\`#QG>is(xX (_DFi}}ĈM#T"XwR1՗_PH%Wc$h-* TR=Y%_rUvh^~i܊t@ W*T^~~Vژ$(/6#=A<#x>=M&BO(=F$XM$K=}t D p? ,0"Ob(OWAyJMw"c-{a%1{vTٿx Z͎o%I2\oeu1Q3 Æ f+Z=΀-nX{1m;p 2nƞ(Upooʵ%#5 4B"Kzpk踀u D,<0wO .H#Rd +9N0R?)Ulmvh>cdAw-3CpXܺ J =|Ǚ`FJd9 YhhBk4!2Lv§zʾe(U EM}gMpC}PV#_ O:Tc`@r7va҆&NI I@`H-VD $T#{l:qE(aϓ &u =2d0[ÕF*IӦO7'z=Ũ0/Qk"ET &Q W>tE%ĊL:= J/)A_L˘B O7T+| x&~5ȱ9E#=PbEr_‘Gq?T\̱njڷ`hP•F\P\ :m.T~=QGk4tg{ rW \ih@QV0@q>}ӣ+bk'=ZO<{%;f2iq |QCOj[6,;e4p^;n5-\Sg~ mhH:y7&h !~S | Ar;i8xM+- W*w'\,I7WRNA%͢qXnjWpê ; F7ԟ<{WSEA}<\2#RB0e[}?LkO'(5!tem3(((N=Td i>on4ׯoV)RT}z1]'Rzm"[ |B9tI4ϫ> hܖ,^lΊNuCڶ}U֮N"8ۻ]UW_3Y(.lx*Vs{K(I(;qq̜%Kl2_LW FMG 7of\]٭ra]KL3 {LW| lISW?m%XΓ@b ǐYRE Z\} Kvb䶞[QJ2D*r]ш;kREVҾ0l֬e8={NfP 1飈Ǐ5ļH:,}ّڒG~h*ӢTGR {|<Z9schطz^,B4&4-V}@S(,p@O4kA)JI~ԩSGf)F6\. \]";4iXfcu'tHr#x@JfsyNm="(y+Rv,ȹ\\v~GnG;.'uV7rF?r2ZoT"/_VyX;/iHO"gΚ_oDGpor4ʄ p1v_LUcїv 7j3zL3}s.>P4hѳ.1spI j a mЫRnIb~L\ͣV?ჹknS9Zȡ LmrB Usi8\*|E/5wՕnT!xZjc\ffKBS_Ј v>OCeP[bemį@@jq$a"=f2-ޭm:yBWH^Sg}?l ih^!> *je[Vr@b>}yX[{: ~Pە0~C:W7_%c eQ68B$Ng=3t,)v\Q4YFl&0>+(a>)L|afdxv@gEv #T'%o\xMRFUXz\sE`׭][ϩjO+fu-|-ep`@Rs=ٳ߲Cw9asi-mG :0}a}H TbnDMV\#?mx-:}P3|1k%Xm漎HeҩӱT ýs|А:J#&wT*Wl+: KOpT*h5xx- ?X%AK_*'0}oРA4P7BJAP CF[LҫWoZDMݏ P9C ~0G٣yƐotg^NMZzEbiYG qj/e"{:2/@Oa9sfew'>-<|{Oٲe$(VF x"?7T:0Z|~( "(@쌍4 Q1 %}oBg@;ͷ TJjٴyqk ww'g|'Md[Rx$zs'[ԗݗtBOϚ", nư",(Yb4njkYag,(DA\̄{:?sk)#GNY6=xٌn! oS[RH )w!$oVԩoK UboF9bFAtLrmfvS˜ t;7S#yX9]j3LG._lMaw+o#0C@"ЎagaA Zw',,<&㆜"zŅEb&sMP!/AJs 'qtJ?ס$=;q:!۩'tH%04jH "N1dobCs8?t/4j12(.0I ,]ZagfWvIkHQP;fvP=Hyt1G0\F ʾeu(0L,{`p|SX eQօUVqqA9Xb3l6G70j\LO h EC"*, x9Z Ų;2amXΓ[Ε[]|I=LXGBn7FOɛ7_9bX[Ke pI l{^Cs )V~ qwI>WӦNUg3}bmMk|=q30"5;w4fP bFN Sd}?ohȈނ"`z!z{JoFz;_8!xTN& P.*h,^ ؆>uvzRFb߈ɺPLQl„++EԐ` #=)VG-!ڌat./asW{B6l`62HhW[L4|:ȬY3,c⣈% [w\xRܹdPb|#F}GVd߾ (ww ~! 5'#h&xW|)btx tWSƐՠ6ۂQYМ`ϛ7O ';@~ףgP 8~G7C2iPs#a:3[CdBl\1R~0A`q % իWa=G#iN4pGf~J,"i l]8OI`W⨨UTTA*Uo8;B&M8DW:8!"vqpw7QA#@l#]*X9`>jӃF( EkG1Xfĥ^nK'ٿ̚)\r)T02ŕJO%Z?l9wf͚ OP 9 F-n ` %iD3ӹQBLҞvK0?=2*}]lKO(Ή6(z/2P89qIUٰF!=a'\;+ʮ_t! /1CC(F{=ˍ_""ΎȘή\^yV 7TmtR@`.xn1dlԐPL%2ܡexb?gR}C WbBujQy^+Og,Ȳv:]$tPb!.$&BFrXĕH FDw1ѰFJEeclp RG7AFrͱOLˁ|cJI )@2hʚ5>VΛrp_T(H!~J>ܨ0uDTleD_e RFB.ju=q@,>f|nDAzRɃ)F魇3uĕycÒ XbLӕ")ҲeY0}zcC.ڲKF _R.R'N4pm2#~.5]GI-y *WfM5RW k׮5B":PI~/*An]dL0~4,l߱͘SվloYDXyn s4~Ecta/ݻg*J炥 Lm?+#F w-Ve:Tڡ/E` "#W#Qb 0֬U:,4 X5l|XaAO]Y%C9E_Po9X7_zQ+9gvo(ׄ筽3l0sp'N& F er_uh(.ھXshYS\J>_$2 h( %/~䞧J*Q>֯W߼hH%2lZ<#\`^hY^#xw(Gʝ|\x*>~F/{Eڵo-`qK]QB{9޵Gl|yD[ʷO")El^ѱ!m6]^?;-Z4ߔ :F]G#b B A],ޤoE|lJ}yO]a-`} {ۦ^Rn}W)󈽆NH BH-E[啿Kµ\ybAE}:j8g<ҋw{O(\} % u;F5BT??N*\H):M:He0҅w̯;> c=*awHFB*3qtXw'e[e1p1re1V%}B(0U阎Eey"ٙoqcpӒEfr^]G= (So@~%/gBqJI~ڼ&.:yӧ*׽bw# ʞIl&p}:x TLi]K!#Z>\PJk\*S#X9VO֍*""ªyK OD6]3.nV6D *%S#^ '"]x5O(z֝׼3eh=\?0"={P=Y)/pG_:m>v'N0>xEac").a_*ҽE?zM X!D-1ohL猭@ KP|QeɒոH3|$c9y:{)#Q&c#M4F+h8>+:Xm轳!!sԽk(/:|qߙ& $(}V=󇺡ⓤH\7#{w=+>)V+VjK,y @B'!o9j&=XAUb A}C=UBg @|!b b@IDATZ(2J; ^d  +_NA @y $YT+!m ŵ  H#*U]5 $F~XXd̔Aץ8t [L$@$@$@$@$@$@$ @2׸kNufuQuO2ʙ3*^*XP@i  GK⻧"F7:F$@$@$_$iL :O+W,Wׯ:{˗WV< @$% t^r&w>I~^< $(!XA(Uj׮J(ҤI+W-[s4ߝ+A$8|IWA;+)G$@$@$?(Uʕ+ԵA!#i@q p/~Ï&/e˔RYfk֩GɝK|}ckfɬ>\8pPٷ_e̘AU(_Bc\_͛jڵLoM vuui.6djGUԩܵ7o٦N9iӦQY2gVFL2F/ߓ>믋jbU$._ݓ9;*v+ub8;/t.^>RJa{7LrJ$@$@[ b@`*AZPH{aAŨXo瞍74'N z/'c?SEĉb%/ʿ^Q0,<0Q(V9Wm_O)0T(pTHث>^J(T|8Xq/tƸoR]"}3ߋru^CաCG\P-\HjD*`7,>kޣxlwܮJ,/y& xEڵkSH@HZ>\G$ H eB(XҒM +\vUE >krt֬]o:J(FJҹR 0@?d+a'&@\^ݻd/}AC=Eٰouzsw?/m,'|quqO}h@牓Tu+sV_6sJ>2fبf9Uzk1^fջ@m1j԰}N; $e*H<cQV,o,&fNs/R>SL z@=Tz5# pydvzUsu 5f[on|le+C3p5>DM^JtZ) 3>Oj~J)OjJZ|~7hN$*S*Psȹt鲚3ӎiRx:<@hV@.<8[yC6e[^ߣM0H5tKtςZ*`+ Κ3_.gn}/K/ʕ-?yێʝ{rmKU L򃆎XoW|xJ[wBwfhtwCv ̽Kяq UH3xjT.m?o׉zYkzk5dhu󿛦.S3eR"<Ɏ%;`kmPJƦc}p 5ugyejyF_X^}gK.75zu@jAڹlΉ#@Po%u=9< J<ըV0D9A! E-[%o]`u ,~hh1ȪH>}Z90~ }F /vʫ=Mߜy r+N 9?Te ~_Ϟhʘ۷S삏2~pq}s׾9)sw޵b }KJ< |{> 2d΂QM0W1r=|ܾڧ@ CIotVo6J}X~qZ(0zY=&Ae.n9}hݲxᇅo1_ؿ+.^\nvQ](>$nJuץ@'<=z7nG" (76mPxv~|m:q&FO۸Eh۳@kN?gq7({ yL"I9(Vp 84>MA>u0җ u7!˿S>&8(V iܹU6-u~ڴ̡-J{F適b-A|zdPdโU%\{]?keYC:'bg5WTJi暡&D Wx/?<]M9^:p?k=˗7l#:0yrbc+V-cs3!2@e~?i;To$@$ $իWRߛ(U`Dw,[6` ?nRO֪aGA:*ჾev5TC6zg}|cCo3O2;8xPM44V,A-'Ma)U`ҰA=cm͘qMG|2NuםVZTt(F/Su4FDa&yn/5q?xC'S@Eo]}K^JB%U})1x PJ=jWս5V6Z?1C4 Zqo{`tҖիURҦӍFMGPjDOվc)yD# zS~2̫n|H_/Ka|1B(U=_[a~?h_+Ut7! +`]0*uvf3Z-& yB#ܗVZV &+_c_xPVٮl?pTemocxna֬qt*12Rt9ܤIֻ1SnEW~=򍈿? uy#yq^xQuҊR(ۡ(ЍOF4ìVfѬ = uț7X,]i z:FPmas|2I$ w+R.xWѨyj[xp#p{KDDݳstL !:5qxlɿy:Ozyvy+""ECɃ4%cN.f1׬3rdƉۍ%YT4Eь|J,,v;[ywf=Mµ_:h@$eg 8ɛt!GcŽAYٯ绖jl>Sȵ:]lUzQ`?.pS;*U*&̀ p;ܰDM罂( VB2VRS!=|֝h~hբBRwUaSS۶*V,R! HWq8XC^Y4c*զteruYӍz/ʊTZIM7/bh0}3, PlO̶ZDK m۴0 dȲ z6w\mSz "wk6cn F݄^*{Nč6ު`[i10n-r@UhQiƨ#:>eV#x)X+UxT"шI(Uܿohϩq JS[PMty zs'uS0٘? "(s pw={=눫./gdͺ^f4 x']Pdz ߴ/rQH 8ɏtiT9r_*h@`a3f`Oe%{1k2g{"|߽SE 6J-\jkVCD$6){0uN6y"qI}9|\ruƮTW(E1{= ^'[^ e3L<պvõ1ؔ#5W8% %aiawz6y%jT"u|@@44 )ErWHzw%{hIʔ*i>m`$Gt {WcǏt*=swxlw11e+&SB`Hvv2#\+A`'+lCM(͓i3O.ez.=) 5cƌV-a m;tڥ̥W-"Jw^ǿ6QhYv޲e#TfI'EiihРn{:;Vv^}gS?.mR+mA7aQU(wFyU׶bni0Z+}6U\/ookŻrJ湾GFPyk))ފ7gLĶpE9r]kF^SHa*yO펂㱠W$UEaCu䕶Κ*oU-Z59D1glKd*r/M]8/-773OO]A]3qw]ZO8 J ׹Ծٶ8Σ1XQ嵩LÔuI\ (N?,OQc e%]iJMޚOaϫŋM9 ժm\3rS ^]7t<+@_Ml;k_w2NE6VVa?qhz=8);sj~kMv5 '7Q͐cQ}|Pqε #\i#ylVq%UޖW7rwU>%y>e?ҫ7zl0=҂/{+έM/?( K9ʶjtG>Z{Gj-rͼ/}UC7Ua+UQti[i(DžVZegWQ?Llsjtʵn畢ObTy^7TR%S}OHWtO=")')tTsQބ7| WJ*4* .67=55\j [-R*7lq4aU @jj+lf)Rw '̅E=TwJxnkSgzGkn;׮Y]>6WkPwſI-ғT()zbXi+&r3({N]"NӦ0ʡ8ͤcJ:-[@ F9VlߚF*z3׊f75m F7z`q-wu7(t`EoqízPR/>C8,Jz0\߇J%N:5nw71M뮹_w2+4=F!CXI}]K 4䗢O7}?'W%Z$EI(a)yvC^^j}`"=Ӿ3I7N~*_rz`*']Rp uCZ5UW-jTgS.\,L,; +W:~-/_^ݙ?DN~XAjJY(!W{}zX2I*W5kfteUxX>拂 Qyϩ9Z/s:Z}駝rBmr,{ЍK Pym>{\+ZPHzHNz[Q|QZ╋۹,MLΝ: zկW'NP9{LT'vܡYr58]Q#U $eU{ibտ0J*#I}o\vjw\x̾-.S7a;x31Wgm7zLA9R^z%b tX͊ҋ/f릇MҀ8Dz+oT$H7(wئ]u\ ^/]uR}exұ:}'m=/:G[9žjJI^˗VP? )JUdLA& ^{ K^5-fr&ʘ1_z`'=8v88@ӾPki:?R8)'mieD)qVd|L6<_1c" WwQyQ{ Zvc׋#U殤\3VqE/y.}i. ~t  xj4}ez-{η#W٦S^u0u|GJ|qI-i ݃,_F8'I7,zspÏߧxUԓmy͚Eh^aWJzQ_|9}Wq_w)+jq}"QJjBQ1VѣCYk7B!'wL,zBdko ]J:vjjگM0p9:di?u˂aw@o(MmuJzkQ} ;\!yfCj 7x|n N aiӲbymB:^ t*hsZIJӨ"k! j6__E[Χ%kQ\o|_٠gzUl4N ~}FJ)keHI wwd໦yoȇT ˕wguQ@FDzc;[/Tn҇Wpq.Q0PGQl Y(=Z;R^ZxT8vLWRN_wFI%HH@VNJf1"Q\}_PI!/+b\- nEˮv#v>Q)Pʼn!R 4]-q[C bJS_9!N9imD%5}i_$'e.xeŏVQ|W/S5E|= R/x+蓚t+ h/u?ma>l*6}͉no[g5l.ڎNgjew|MmuݮH[,u{tLwΙK;Jz {6{TQ>+Tү/((iyxjqbPNwSԹW@<պQ;qAsO\ 4mmάBw2s=8 8I=?/*$A76Хֻ2=5M9n I냮wzKNh]krϝtY.SSp-kr9?O>|ǝ|:s^/&|}= xj'[Il*~V*!}Ox9yuuqԿݱG^ErN8X3~$3qdG/q sq`sVu/<~ |%~{\cs֬ɧkh{ч*K6COcժ*&!K_UV-7mFCigP N͔_t3l<BtPR. )`.&ZQe_6_?=i>kAz=unOn> M}(~vȿSyg_۪ya.@u[rSŔگGAIx[iWиe˖GRR?~2S8𕩷N=|nh~ZOp>8 ~EM(4wq3<h`{ޓΓ(Ǔ"\uE]Au=n}kyzPY[/ʦ |}"ܧ*y리߲:F(*nU࡜;+WtsDQ7:'u uLɛw޻nFzʢ}1/o->3%vq~+tDǾrfS,C]&Q{ >yk}ApmA"vdz^B~u818i& ѽYxӹ{.ezr]]hI{ /rC7w-t߫k~߻t-u  VƎZ_6ܡH.ӦN5zͣ}iӦ.(3UN m.&ŗ4:FX|{DR>U>XǾ {*"fVk*Z|L?}AYڷREI.։JUnު$q?,Oo=R94T=4MKFͨ.oXmUN\o}~0,8)hڢ[u\fyHPSO%u{ߣOڭ\r*X /:`BWqu5ۤZ*(:ިW; : _q 2]o[pd*)sA(5tmȯfvSrʹj9Y/v=mANjwKI?U* uZG~a鎩LQ]GMCL3uy ~} V˔yCEy}*W>XJ\4?c3Lyϩ\ԵdSXM도 [@X?5jT%OM6SqYVI@` p};(PP`0B|G_V2z@cOaUN6_kmqV͕@J+Zy ժ0Əo1e ʪgӽ6xNLם@`p}Y7*9_RL*Pyŗ̕S# HX+ZGv֑E ,ez_hq:.z-^6 d* +=J0> 0VR^:xS\B-[TUJKK ql]w' @ٱ    gZ@5gG@@@'k o    P eX)Gf   1Xjw6@@@A@ŀ,@@@@ Vj@@@(&*-&Hf   ɡxr@@@K 斋    @rY3g|"    PL6E 0b0    V ;+{m_}4R.] ,\}Yڞt!@ZΗ,D KZz"V%- =H+%r>}VP+    @W&"   @t     dg<    rp    c%"!   X@@@@" c%"!   Z@@@@ pL   X@@@@" 8!PU 1'|mΛgj֬aۧ9M2e_o͔ߘEgڴ<(i< 4O>󜹠9͓6%/1~\/q͊6`:v8TV5I%/17}>,aQc]̉ǵ3nIK_6BsW54W\r~-T%|˯̜LcMr|I&HҥKw c.0ilʕ/.sݻ'y 8Y] 5)'7ݮ4Eq.\Sf</K;t2n昣/14y9%i]1PPko6#G%cuj21m|сM{CZl{.n c?|4nUDy=o 0zH;oy K@AF&0c,a[rA6rAT^{;_>| dFfYf+ortX[0Xg}68) 5W$0~5~xo*Ֆ[&;7E v>>W?m fW]YǸEIɪiӍ֍xc`3˱1GZ4K.zP`O]񲋺-[܏+ne.z N%K`o]SO>җl'O>}r8H{[$.4tX"\`v(U+\ u/BYYY:VB tނ(RN⤴|og[MϺﷷy1L~v(q;csOχ)W>&W"3RRiV'U`t#~ltO3D/Kg|6ȶ$1lSy}@K@AGTҲe˓~\_×L ::Nt[`Bsmw‰_'0 N;5nr?2zaKXn X8X'-S&wS_>HzusGM[YsC(n61Z[5F=mYʖg`{96.L^rA[7ÆpQkoռJbwJsC;ܛċ *MKM/"|ycr=vۥ9SGJ/q<:T׽RenٵNf܄I慗_wH1F*\ZSTbڭT.޺rq /YbK.X ݿ6^u9PsO<?-"utS|yOx&Qs> SE p>?w-0*bwUhmy3\h6,b8_Ҳ3Fa}~ƹ"A{{(TCns` K 65O~= ܟ4D× (@0H%U+/:|@, PWM /wsQ[єr8EM:)B]7#?4i*vϦ{> zugeƍu)^/Nw09W|KqP+ J ~;JuVe̻{w%.Gۙ^UnUXM%Y<Ը>9_drzvwl) NT "5)KZ*LtDg~\WU{2e$}?vf73\wx Ӻnu9VVIPq_s;;kΗ|yu [KޱI/a|&(ko@d3B<x1ӦA/J2.ߴs-[%P{U +>̵7q)'oRRD9Dž9nMrJ.s/ǎ7mcy㭷ݟ[52^@XWs{?Usg KCGL6y;iĝ7rMȪbN'3n+Wgt/tȿewUӱjBvkLt/v͓_K@Vc%W$DyU4\t4x}?x{ y\zuA*ZV[nW5ŕŲ'}"'rRe ׏= ƴ4>-(2cMp-6(g:skr B Uߗ$T@*=T_>qŀ8_ ^@#X|O5n_ڻ]r6|EFw[v^q/_\pծE֡mZ٢q*7]wuKp@aӝ6lS :pj'ySv:=| (0X'֧w;> kmq{0OtY9YԂP¤(8Jd#:4ׂD^3Ͻr0W \AO:Xe&g|Y;ŕ81U=|>z7f_zQWӲ:stT`fM#0K/P.!-'r fˤ)O?6f"@/7]wu<8_bvbsƎ9ꋤѻt/e˖j%ٲ#T-YOz/­Ms…W͚5S&ިYP P ~l]bۦ[]Ηt*􋓀Z[x|SK~: ~T]F4p/q䧳ӧOqzCQ S+#\_4ͼr"Ei6&Uf[{Z$ΗB11F,cAH/^ϸ A ׀K\ۭ:yv]    @hT d2@IDAT  $ r$ @@@($R(*FB@@@ȱ7@@@@ #rd    @BV8@@@@ @@@@ ]t|"   HPJ$z!    ʨV   Z`֏+L2ن+> x@@@*@@Q@@@X0@@@@" &ɍ@@@@E`@@@*@QrL   ʢXl<   DUvL   qXMl?   Dec%*!   @hn9   ȱݎ)@@@@  #   @QcL   ɡXy6@@@,E@E6d    S2Xg@@@@99ѐ@@@@ 4]φ#   @hnh~L    Jw?   c%S"   @6   Dc2@@@@`ʕX0@@@@c%    k@@@@\`ʜ)@@@@c( Q   D"Jd;&D@@@X de\+`@@@@999V"1   \ ++J̏6@@@" *PD8&C@@@$@+    @$V"1    `hn@@@,@QtL   qʢ8@@@ @@EcR@@@jr`@@@@ - D+@@@-@sl=   A+EcR@@@y    PdX)2!3@@@@8 Pymw<ی   EȢU#2@@@@vvrs׳    PTUV    W[g@@@@4\D@&G@@@8 c%{mG@@@"PJ@@@-M>z@@@*@`%!   @V\I    @$rDbc"@@@@ !     @W(PlL   CQ @@@@ @9V"1!   Z +Z   D!ю@@@@ YYVb~     QV"1    X8@@@@" &ɍ@@@@Cs    @dEcB@@@@Vuy    PZ*"   @Vc%G   lY XDŽ    o[g@@@@X)"   @h({mG@@@" PJ    Q ''kf@@@(@9    @<ːc%F@@@ "RTDG@@@ r\<ۍ   E"29   Y+ql;   A:VǤ    ol*!#   @T+Q@@@b/rJr(@@@" c%!    l @@@@2Xz5E2gc @@@@q    ȱَ @@@@ YY6Jx@@@@ @NNvL   1&cG@@@ @@@@ P     I`5HnL   [ @@@@" P(2"   @c%mG@@@"*P@@@b.+1?|@@@,`QJd=&D@@@x r?[   E J@@@,@@ql;   Y:VL @@@@ 999T^6#   @h@@@)]+l5   U 'g"2=   Un@@@(-@@@@ Xg@@@@ԱR<&E@@@x dggSym@@@ X*t    {+Wc%G    I@9VƎ:L   X\ٲXg@@@@9WEI@0   Xv6@@@K W<-A6N3goV!  @?;i@@@@ XH@@@@dɱEi@@@@ m@Vb   QDc:@@@9Vb~    ] @@@@.@Ql?   D"J$8&B@@@hc@@@(@`%"!   @@@@ @EcR@@@x@@@" dF@@ a~hVZe6lhCc%zO2̟?l-wC ̨?w>m[rĤ |H~dXreS궦Zjuif%fj vL*QV=TTiM]{~[}n3˗otY|^\9ۘ5-3Hl;BJbvf{&|5vS:;=w鬳 +fg:u3}psoއű<T|k`fU3N7u)5CڠJd`1իJi? 4؍WVò2ߚ~c;tM 3"j]v9& հÛNϒ!kAҥ1_w[Q^]Өao9Od~^ؾav({@J*zyWTy$|*i^|" rʚʕ*96'ƟaV^5oT #_{<7/c~u®~01PPeԩO b&}=|=yS"RrX;o^n*Uh}"߬m0S~cl{{}} w+q5Ɯ|'6}tϦf=4Z6oyMכwynj;̟7_mmj/s̱;.ǭf+3I&&;71ϝ"u]ej}77h.|2̙;kغJӨQ#s1ǘw#u^z„mi+V1_5msD T͛U.}F7R'}D/xX#[&γU|C[瘟~ɮSL L6Lٲo! ej{?<#Z[omo#<}ik? ϱ?}fr9k<9WJ^˽垕Ez>n*\ӍK?@((T?a?n{?npf6^r{xb3+Cߛnhod}҃矏t7>3]ݭfrnK/yc@_NZeGuיmH1|Q@BI>oNz҃īnھ1~v.Y4m``87+=嗾fl?O o_ /d}L{]>,Yb}pL>l /_~f}OONwgu{`hsB͚mm砃?,4uvޟL} I.Sȑ5W_Ⴝ*. )0~4#F/8_V-viY;rQ|*SÆ |fwiO+~{7\WGr7:姿ko>8\NϸjxA=?].Z 6(N.U1)ئ} O떶Uױ3{I6iӾ1;-<ߒ(>?}W?&Rvޚ)J.uAGo%vhsgּf7ߘŋiS6OӴof7u>+i0kSF5n3;v0+ne4P{!;nYn/dk2ni>=s77w|{did'6wЁM͚5* WbKSPWҼۢtI*҉'lZje\cƌ1}y̰ooޏ>L~]PmƷcǎf'bŊf 恞=x (Nj6xs]k\E;ui֬ɲ4O㖣a)y|Ӎ.R>(nf;aQAP夓O6jflL ףiݺFR!ӧp4z$|P\; MuZor_}u6!ݱG =D{b_͏?vysD0n#?zS'hbϿv}a Bx'm2]_/zvVǫr+k+ޗ_yAl١~}7>A#hkȬkѱ{X89S^]KEz|iJCҁ>{Af{5uEʔ-c4|buL8o{/p,@y{UA6Ǿ>)(pոfF/M|PEڷ;ԶA_l0SmH SPEAvuS?q#1AsQ6FF-_A-G_N)S7N%Q|P{[u+%ucq6kiݲ>/W{8`97O<` Rc=L/a? G۬ۀͷ&#L1o=r}?d`~cm90U&  Ppj|OJCzF<7%^f i`o*Ȕ))S..8Jt5yK^oqAn-@ee6Lj~y(ggvgEJx17}5mVNg3gΰ-']\j?E7/ڣÖO88׭_~w9@3۲ (}asIprL"R^)D] % !lQz%=)f_sv`m{zt~ykIemRPEE-t>ePҋ+6{Q.A7Cs leb^fmS)lK<))5+pk`ꙺQ)d:QP@=,+Ͱc \,M9 j-3f r}A`J*VueNj;v4@ouku<`|]2:t\K.ШOQT*PjZE|sftQ=Ŧ !t7|CPlNFM+@+N>Db:w,)J7?(>?}Wʔv4}+Z {Ud޿yga>g.^'zr.6wZ 6>¼ڛF|1+sk/c:(G^ vj®ڲ;zgh3`62*ڨ'goP*ۈ}C֧˻4U @`!￟kaՖ[s Ja/­rk>>fϦMrȐ\`|򶞒ċzTVŋEKŧOf%iժ|-mA?uDY ~gsN}^}WџgNpjPC:u̳=g|W_L҄r/O_noW{^ITߎnK޾!`shq4קד]&MvN ;3\_'L9{*¡AͿ-Kg=uTH8v鎷4VtJxbanq'a{U.#0zH>CrիWwA}[l}/z׺LtNEm1# 9n +0".F,v:/UO lQ('t|@$ R}K ڮ+#[N:j6]STLAtR[Uz('#\nѣt=SjgsE>;).cVgZnU|𱡺tәgjWk1Xa| 7Z7?Sv]wI%G b u\aV훥nP}[R,];E9LF03l{X˂ t}1򢉲ujooR,Tr(`6-kegs;?9ë@7 :HZmjShu"϶qǛN:)|3ސ:HqNNvKi\"gL+7<[n~Μ0-ZnnV_x֬g߾%*T`ϻ}l3Ż\3*^UGi3V<| D[t7ުD9<~ŀ|?խ6VT@u\(`!eR59#ۇf=8RuEӥmXIM~VO6'𕑆 w/XSħRJ. \=0*/SR u|E|"mQtLxל*UQaksk* ҖZŋg Uir QB'"+ˣ\ HUN?츣-Ayt^uRɥ䗫]mtIMԪ)5ն`I׋Ml O)mJ#l-b;š*ۗ \6kZszjR [EVJrT.r8 [.D=;OL~<)ݖֵY.|1[ՔdK8-pCߨ APO[n"K ȑҹ6׍p ~Z3#ơXG* -4WT痊 }a~۲ks/L_[9X"PAI7bJyM7~积Y#n=<һU-OIw=R+QTT$}gksߥI#.}MWGNy_0/PBb5G7>_xz-7]~ǝ~ĉ.ѣM=݋#<2M?=|>`~=lͮ*¼nft:e7Y0ObM#ԡ|t *ne VOhɝ!7TRz` GNg5j_+JJ[ji%rH}LCEE<"up64~GԄH+l~e^Wq+* V2|)ՒO~IɏW\~\+eywM#\H3J]Lܧ =JI㫣fu8$=gpA7_riY%p,3q+`6`*~mnvF\՗[3yA'么4ϽRk$3P]8t˗F`% @b:uJ0t(\4%HPQmQĖϫya6`eW6,Q{Vr[7򩩮]D6mjZHMQ/߲bhy;G\PJ;3O`߲k("zkSkofF|_[z=mЁ P;aQMh|pE]4Ɩv >n|uP}V[#9s4K =_'TMZ RR*6teI&ϟg_沚BEYi;}gW#k{OH Fӿ19***)]qߺ\.ϓN> M}=9$~pdS;e{>ڶ̨e}]ku揆ԁO=<蹅9̶"w?4}3q?s1d͛Gɳ[A0_쳶o_&PK8_軚'JTDEjNj*BVR0[l;me\״Z4ܶ3Vn\IElѾUwo/J/F%Zr#̏Iu{O:^Q~V͚ ( aPzVPQ&>kISUb`̘nֺ(nQm]N7?TZjEZo*r/ϼ7QxEK.SVsƩI?jGi?[S'I8-[fKww][@;EM5kɨg6+ZzNj{ @@a% |Imz%mCu|n_Y}pꩶWyw\ EM(&X+թ[UN8Y_\ރ6uGmzX2frcPG~XءbwLHF-sY*5mKw}l1o <CD:KwY3]RB.Ȗ7*~޹gӼXIzpMMeBuuPo6ʽR혣r{ zE4®;`#P}( B(]}7sA-cK`UW^k+XQK.2त:TRQ?]x~:ttW_wqФzs4ijv֧2ev~>u (|YU~ j2L% f`;A[ ou1=wr.Bݮn9kQ+@>d's;0̙60I%cTVJYTY6]oJV7z*VSjQku_;w>Ҥ{ѱ:n hSM} "6-9g/5[Coy7!E@ܯ 1>*ے[蓹zN( ^\yم.tXL7\n@/Pv>.3]q e{ UNp9 dh 'UHZ)m%-W>LC,< 6l(+*%+`Vnmں 3F7\٦KqgJ𣠊ނ%)RVZO*G*缎%1y|j(H@AT&9Ɲ7:ux=V}=2V,+ĭd< +QrvOE~~y{Ce+Vmٲvujײo%j /<2*HRMmªUu5e~LllX(۳Lq2P=   MV    SիWS(F@@@" (P"#g,\2Q1]۬67h2FF@@@֛@zU?.Oh֛ B@@@2*+?X׭`Urzx 3}@@@@ `諵#G?4nԯ[gn35[niq~}]:c3ff̱G5oz_.P/G(*F+6MTV㿄?l&Ll6٤iꠍeժUfWzukM7$mlO|}>w|"  @X'9VJ5>sJ*zy3nW'G|PzL4t^eΘt3_1s٥Nԡ`=?Va-wpzq  @IDAT7$xTI1c/4l;s{ss$-Sܳ:H@e+ob*Wd>|G@@R$VRź~6ʼfX)ҙ)rͷZa*T>Pfݦth>so~y鹧[ܜ+Tws暻{ A~#{e͌Yy6h`E6TQ0Em^rA5W92t[oTU0@@(VJ~6Tj-##ʄU^vmla=r|9v\*6r79Ni*qQy!nO99]wJ]>h~an^7d^7uG@@5*%[`5L˃0Mm/qʉAb".yn]RX_VTOEurԤ~̨Ns:uLw@@(<NJZ$5eQ_5~0|?gYOnUzp 1˗&{Gf̸񦑭ԓYވ*|?tA̜5ۍ:Cn>}YgZo4>ШQ]'ۜyz^w|=yѼ-_nvu>wn[ fדM+n3fxͬߙm?UzygI{='ޓ7^{)Wv!3g<3!fo .;don(㓶A*5q;ܼBlC_+VՑ{쩾f񒥦~4KhUG6'O7ꢗ${]E~DA@A""**" T{7y;n:˷Lɼ3vS$Idb|P<ʃyFTHa3K|PIEW) 1-XnI"F.2nsYCb?/q p_/o8 . .aUz8׮W?h ̐5ac77()XD4>Cy$&&9F@9~ԧg7ŤLt|:94EH;o߁[_yЄ&V@`Sp7Ͽh` ?ׇjk>'.^J(U/#DiI<=ɤq.+[A@A@A@HJT@jDФ+BӍ7i9" 0,h}/@>\-wk@gu* D*]A7 VI^.sH}3=my eɑ?G];unY?ZF/׳XjRZ! 2Os8̚quM9͘!45UCg},MוbhJ"i5P_MFyreŊ+WвU%Vp!7oݢ? hct|5˛_HN 2B]k<'0K. {Yk!5aR5poAfQ*>K~~ojzMu}s^1|m"ez}*0%t]۬jټI9=v{}}c =(]p'6]RTf)U:E#k%Z~9Vde&X?lԵs{e~-0sluͣȤ.P%fV  k׳ Eg{4p <]\ L"@RMCf  ?k H@h8Bdՠ3P'X~ѻSE6E |DWW6ӂ oJ5 205fT" W$ۨ5VT&Ҥ9v4BٛLdȐ^;mްH]{hD&y6́E<6yebᵵnZ* V WyLrlA} + 3; V/Cl֜y\}ڵs;jzJ8PKH߻m:t*$q&)A@A>E$RҤᾞtlCyh֭V<|03:w̦)"-/A@A@w8H/p\{qZ 3_!A_W1V3垲Lu`Vd:!b?,L|- CV $/化#@9 cˋuk9QsGbLcm֎P 09X*>^# "T|I/mW X")0%!$ڼ, g1hD;-z:L<]i{Ca&{OrL: >wzL` h` L@d 3,WჇ, uXI/qظq-ΩS0t+wܡ'RtlA@A@𺆗 :b ?žsi!f 3Lv& w#-006 Y]6Txh2Ud]gΞ/^w6vʪ5Ts>Nl?81͐TY`d[Qk[c;w7ÉmDrMa])zvyv׽]=_"YV\i .(+VWwīMq$Gefsҍ! (@O=t1f ܹs+.3;t}/m۶e/9 'Xrr-bŊQʕ9U轠2?A@A@{j+qcv™Oˑǔ}vAΩN~a%7m,Bagb`G&aܽgۖ8VK,Thua>U >&GwoY9E\pNv`ۻG7+aF$~[nvO+㺂?+W{5qGC+A ״iSھ}{h!*M:Ձ|AaD)@`)?@ڵ9r YITC;vp~pYPܰaƔ){ Ir,  wz~QzT;cЈAjS ho_?Z)]{aY*__e] Gp_?jG]H(zy-Ӟs2ACGy~++y7GenJztH8wBڙ\gqhsu.]8|5Vl=zeC8cDρܹr(#ϔ 5}[S[\Zx|w4slQlw1?z޻ZD-t$ө5 /Z4| _ Rpkj|MC ΁!ٲfVٱiټQu"O;3G]SM8t|iǜlќ~3N9ļF wftnB;(K_ܥ//k -k pA0 s&٫0m w?Їʰgk,qtc׮]Z[) ?%}qW'>cVZk9}f5oPڽL]q0Ki_DiFu&-QZn*9${ ;wa푼zAE$kt}ӿקʙ,KЀY:@HWCU8f.ZMe'[V ;ƥLM[-B.1]q>qvm :KmgKzi;;E#}>ѐ@k!wcxqpNyg$O\-H ׭[WeZYVRVK V˖-֭[[N֬YYf[z 0]=ܯE4iP|\֯_ʕ+I0QDA@A@G VLVԀI8F=x9!;|PX#r~]tCr D OȠ`Mq^VI5)ӄܧg7jQߍ5J0IZ nѼ1-Sʧ>Pi݆aj۶2f* _~,"_i&yD'yZ|= ׳0fݟ^~Ţ{JfODmy@c^ֵdQi@Kf#7~nj.Х:\9Mtf72\@tlHNɝK]{ 4jF9A2hoߤѼȇ+T@09rԮ/Rv>9vzAXsܹ=Alg1; 뺾gc7#;9^2-^aWeLͲo6CWJDZXټyU~TjԨA=䓊fGvJ :zqV:lٲ"p>e6?oiժ"r6|SYVA@A=H+A{q<ؤڼ?x/ 6]kwї-7kܐ \\m-fC~^Ŵ c b`J*ީ]k+$/$ @@̯*2⛁45z`-`e&[\2l}9 _Pxlkv)΂0M¢&Xß wo_,Ʉ qL_֭ZA `bhujTLj1}u׿7uPya1/=6}"כJv)'sZքXǡÞOh-_mẶ<;QΔ?_b|*RЮN@Վݳ*hQ$w2~߾'osPle"gb[#c&ix" ݈t30q\˩SPE "ٞ^zɁTAZ"V QND.^^:k\d`o8t^ ;<9XR , |ܵ \'";LńLh66ٍ7h&jk[eOdʤMED RrA6mYj۷o_;qFف62dWwPu̙*ND>g޽uڜ9s2v`>ԟ~Wv.  g+`2gtMD݂QiFH6m8,1~vQ]L-L ⣀P/jqaAWPAo-aT< K=& o%K~ yZ/v`?sE *ٳVk֬FY0r&VP0UTVy ǾJ"GW#Z6Τ _?t ̺/  xvbC: ܕAs4J @F a g:7'y#˗sZU~~̐:I"ė7aM֙1F]90ڷo5*H'Ξ=[VA@A D;RM+B$ D  Cez3G`[cֵk*s]!+T@ V-0 & ̂Iԅz# K@T\9=XS5o\#:|MƌdkfG۷wh9U؃ATce`PfMUҀ    ,  "Ο?A hĖD)S$DA@A@b\UA@A@A@A@x +9    @ !kX!pYA@A@A@A@xp r?k @HRUA@A@x|@`Xy|ΧDA@A@A@bG xKW    ! +ɔ   @" QboMA@A@A@3cvBe:    {{XKO    ! cvBe:    {^Wғ "q)6G@@.UA@A@"@NB RQ5QLߨ%*].JHeA #ֹ؍I(  1(xΔ(*gȵˊq     o~&D O$~*Z( B*     NDcun rgEIQ|x=8yO<:GΆj?yr:mhz<ƕDɊ& =Yp0ݼ8LϞD)SRl9)y9AɇgOtSq0RH.\8aO@F/  D!Vz{ :sR(X'w"O4(n=[U `")Mn5C ?e૗ڄ)ڕpZЩtwJY>R'A )I|5m;$JQʊ5(¹_Fv{<|^;H>hӦJSu/Uj:Nr]JǍ $x>ŝCge| U6$P]0Spxz9EJ҂E)Iᒔ De_A@A@A?@"^:1 P1},/pn-ńRsB[ |LMC*?"wW1Ash/eiշF7;gnD[gi{Уw*Ν[NG(UFVv@>m =8zJܻCf;텳X%[I~&xK~iһ4Q&L b\0ܻV +YsX@]ez38{[Qfm\۶nΙ-w#c_j g=86ijWvA@A@A@BĄHc~cH5ez _F|ݜ=6G"\*I deĹң;?s f8IRŔɽ͊6n$exh97TIsP1c0L@1I|C΍y,RN^2FކUϛsP<H9yJNV m-ǴRL5.TL2uo[* 6I]8߳F ,=׸|Ƒqc͞L O2}Oi$ΓE5ˡtpEySO"UR֬GI `L,g {k̤|Q:U <|0lqzNUH^xD^ZN晈   $<Bxo/kо%Ξ;8//>CWF~΁ML+ ȘҿŪޥK_uWikӕMIټ6$tZ$2IQ*~S>LV/qeTZwnQ-E_oo.IT^ܵRU A:[8u:#2$irLVF$Y'I\iښ57pZI>Tʑ?0RqKT0&JžEYH[׆+<mصvePul[&>k-Ny lSMA 8z8>Lp2M4'wnʞ-+[42a,߰n $?Sҥ!9oTZ$I-]xE#͟Ms7HB7cmSbi$;."X"_SpSϘ;T$ɬ难MںۯۗnM]6><6+a0& bwׯhI84L=aâI[Aq{hC}}1B_SI :y,~p 2_HKA@yoAqGٲfQ\9=_-xZ~5LFȟ:hgsիVUnTYDOsr'q5kVSҤImgӖmԡG5KfZ077iԬq#j v6nJvs[`6wC2#+)c*D'KOڍ2Qϫ.fNdwv|z$g%>8W>VpӈnΙȕ+ SX۾^-Q.Y!P n^F6Aa@ND7NA,$R7:*'LQ>{YD |{VY츳bݰRgSHظfP10ѐ#Es:vd;IU!xMƟlԤYڿ43$'KᶎfS3tWh(?QaC4p6B"% s Ws0i!ɯ g-g_~O^F+Iؼ) s6}s}XID7¹3X)qN~@4.8l' sݞfg`0=;X-) O_@}ICҧw*~>vGuU:}i:=&VJǼtmzEߕXt U^N8xP󡥨Wg=i58_'4'bXJ֚^6֌RT֭zs"m4utw@uuqR`w?TA_26O79J_Cj{UħJBVƊ)P\^T0 ܺ.T3N|u a3dRf,(a|H~YέҼAeU4 [~^gd9ڇIiB(sLn,Vu]`_lmp2El{ ͗:5c;v҆[li{>6/ŧq8jX!}Κ%J &Y&LP> :7rI8(Mۼ-=8n͟|%aS͐X0][p<8t-:ٓx0-CFoYCba^Fz^T)%gV ᄯ诊ߘ14F'"sxNQE6 kva4D&Y!o\d uBLM5!+\Qxʫk$[jR[tws]NkqG6s#X]ܧk}'A vX<~Q5k˸ w~kF/q׿ܕsJiQ{ҷ#!b#3s:{*^Ӧ @"yHKHn #wǛXL|={.c&Xk"s g#wW 6J_(hP(J5Ԧ~M}I2Lhy@$MgYx ǽQ.gs{T/|{Q#]IV xV+D1#:93?UJХJ{~X(_E9ӳv#ZL!-L2vM <=|@|Ϭ~7:b! DeГ9IAqG CEb)jG/,^Joˉe(yYfvѣЪ(Y8|Zk³ =>è|,ծU:΄IS_9!2e6o9߹s :{,yQcB]Rع́:>WطIyE\tOL4ÔkӉ'UR?0lk˖)Mac`0 d̴)RJ/ÿ}L;~BiC'˖uksמ}tA̙)b`ێ]~mV\kzL]/O)wsHQfNfgOk:&Q|Nۢ?C9Ym;sJ k,Z(VghhBeݪkh$gOAS#qOՂ Pˆx=f`^nI/b=QGmDiR5&ݹ{73#u58>}CHJˣl޲U4Hwk9;~i5;յ,{}>y_Go;Owhז\0>]/7q5 2'|C-Z^t+^:8 }͓D$ 4~f@IDAT݃2.qýrO{&~S5ߍnco|0l䏴eSZhF];7;`0Pr7[6aR.7i"D-F~0Y٫$][z7^kըJ_-Tw:oAtlߖF}7%Q5j0wFWf3,`_4( Go#԰`5soT'ӼT!Ӧ6FBYgUb,&:Q->=8 CNFUTBBc@k|ѧ2Q5Obyn5^:Qӯ?g$S Iנ}ωch L`YstD|>yYأ&&K *p/;F_du~tm|]z'@TFI,`PCW^QJ2Fdbp0ٍȀԁ8Ed蔛œOk)$̾޼0BսɼV<0Yʓ;7}֣5y5~k}nu&Up>Z`/T Pym%83ethLRl+za 簡^Ѳ @g /D0,*a?fQYo^F]9A3>A Nơ:wo[fIX3LL3|U V%ߌYsU5\{8ի>׬CUL%z9H9ҸQ}T1|`Yr% o35Ɔ`AXtuiټuNv؂~OnE?QO:uz2ʀ1}=D o;3YiifMwJ>ޝ]cŮ#Iވ%+X4h/c?5ڌE۸H0Y{sqGwuV97~XEє*U4~UTb]2|L8,wk l6L0Wh/>u "X"d.N ֭W\3ǡӡNQ_R=3bz8 ?Xas ѡt'Oʹ;wB}X^q'N`ӣAUU;'^6R9a,F[F#[<_C jWLL#c.9&pD?!-`mL"Lк3gϫ]8Do1G0M<j-0g)nM" 4αs60gq=`7lY3+>DUY5}}VF֫+!$ Y5,hٹk7߰g;:Q\~r,˥c ΂h>)%b sHnܸ u+;2n,3gR̮gh?.fY̯ymۼ m\nf}QwЯ1n  t(;A4+v Abh@t9uCDk4MjlA0W[`mtA54pK٧VSl8<ľtz6|)iRk;3R ~ϿL}WOzuڤ)[܈fgpa)B;FD߫"zs=̯N>SeB\{"։5:`9:I+4yVJOz‡y{2:)# S^`ǃ&rM~ RR2@fUC\1? 05e/(M_fuڱc޾}[ErH?.pk 4P@(aN!M`LZhaFgΞ%DKnND9oZZ0Zqěs!֬]mVwr(h\30\++D߻_Ms84RQa'LpF ԩ6Apjg/p_`}ZT+WS×UK6]54ӲdXV;~|r s- 'DZ@c\($j_A8`< |Lչ/ڽ2^r) u9fs XgM&Vhw"v4EL;K\9$<#Vf;0'-̧"3oCySmozj %`ǼF%#:k9_fLd Vw?{Ժm7qf3^ȟϪ'Hv Y|U\3|!KV+wLI'}kK/j^S˹xAi"hN>xJ#2!$) AR~u"3rĩHoDxv2)6pz挋qAʙT5־<+JO{ 𴠔A@p2fhSHך5iʹ Üfӗv F Ӻk:x! uh܄V=>I@2h$ oݶalpЋ)/ #R%KPR%UbCZ8Md7yj92[AJYmv:kKPߦJBߝ;c__vݕK_tޙ3bq|vx5k:"ѧj'W.u 1qhy=,vl뢽[wƨ_E a/6 %1}7tI+ diܤiVg? *7@/ZBpR`RӼ+|Yc}NLlI"Sz/XLsXC-uP7_<4rSܣG ^V@Z6\`vRNԬIc_8ϗ!iɀ74bC$`ִPC*^fr}ɇL `AOY?@ӕC/Q5korHH֓rێ]Lф 裮mIܙeQ~Z0Rþಈz S~mnEz.PGrZ0L/|^`mJ&\1~_Vsw=Eg:YWs#ڶjL>_  ,*SQ~nz"ڑsYn]r}MH_}--d;v+Z;v{Mf:D&;Vr%岭V9:?4@zYeq %>OɄ%+M`5S:掟+vfއ#H)H[6;s6+̭/u|^ NA'ի[jVJ~94}w4m:']tlzlE_O:SYIdD1 =' f'bEnD|M@SmU| /9sPIϞ;OkU/-^kfc_"{8VoCa ikvCGaBsEE45yRg!*ƈɒ%AC},cis8Y\#Z P70sƑ@hs:ɀ{%+; ͔1clu|q}azaƞ8up4fހ̩Θ!=l,g $0(k':u YA><'2Q͝RK=uqzHi9[ipg Ldʤ+V!_?`RA@pIo`)MU/ξHi~P-qÙ;vf:hb1H( ~"YYђhi8 ?Ou@9yZߗr J{9vc! -oTBkAzݢ}zG +\d+JOP/˳ҾT!V±=A@A@A@5u>GOŗ:-B@B^A@A@A #ɓ/u0D2BGNLSA a#vm[[ȓ'/; NỤ`|1ߙ @E a  WڸB^A@A@oVI!   @D QD0.A@A@bGQPpC~xjP B<|@~~\焠 zUL#3a_A8 ɉa <>:}~;vKgΞS{ܓwCILE<QL:iԬq#j CsccXb% -.\F};$·4׉4ȟfNg&E<[ӯСCG*c6ET j>s~Hyrin[/S*ϙI1q| eʘ }kݻҎ[4oL!<D?[Yw_\n?yX6}ѧWc:},޻ a7gT³6mډ/7nޏ~v0U(tlvc} cJ E޻Gmw# E4i"QG]US!rmwgLWfN1=>X_z-^ 9glr%:qos2'!jϋP-x~A?9'ϺϦ-Tw|zVRhaJ8}.?sa7!_ڏALWРoFW}ۿקTju. +A8Y!V3)sA@7\"UƏJ/ƪ+ޗ4ioZgE GDlYpLd\\gz3k|?mug[^d=HmN;O~e_576K[ z8-\LN YsB?f{Ю݄vyTU*b#h^^TM~J9 YJ+  *.SOP%DϔaB5M{ oZӦ9؞_q<B5pjը&]|>z:;n:lzSJ<ΒmB Y$ԸQ}6mѹdٿL`#>% =hx A@ pE6x ;'?/{GӍ7@|TTIʒ9c[=Fu)wŊ_Y(R=U a);ڵII&}>]Z~.KzbWW^KWNyGhTA@Htٗ|NWa`^!}:!ÿW/f"$/4,„;`0۰!olNidF ԸSi/\BB=^y -^rH^@a"`E+V:e&`?MH N9[en®J6GlhբuLrfN"eؚNەO甐Γd!ÿ9Ƕ:}%o?M7}X3Iz<\zU9-_G>x3'݅˗ZmZ!/|L*f~Z6; kb;OOzN-3}_-:ya~julТ%]x?k[D_έٟx>8W8%LҬ:2z ^U$Nwmas4%E&N_̝!+Ƶt;EeNi;'~X6Dž{x/ ["Pc' ^{q l?pmFf~)w*]EFseѐ >VDiBAB̹sjB 5_|/0rж{4kx/6o*\N}VF[dNn_ke޼I#CބӕHD+Ztj ~J6{K \0-R_ԮI7&<_=[* RUy&f_ЯH2'o=8|~Vu/{YTDI ;xAԥ;Da[Læ༴jI%s0eʔ8_HyÛsEz 79sdS90@&W쟯 ~o&m;}@xrnu]o= _TZ_3q ,OFj~w ZWuNRaYڋRTfoQ{([Ⳕ5i c Ne\~yҢ{jګƗ#[6zlikx -t ac4xIu6m޲]7-NDAJavA~yu M;ڶ h7M_i@YsںKy/-R= mيUYvz\\ھcs3.Ǹ;wx%=:3XtHA@ﴦ2R&ujQ`Qѩk7*ĦyUi,Nk# /Xf1%^w/LLxI DE"I!:4גC[^xJKOcƩ'%sԈ!\ |[}=6w pݳ8\j*gs}U* ;Xͯ)"b9AMZC^ ^aaDyhfaun߆I>pXXʵq( -DhbXF]WYw|%KJً惔D`VвxيPbJ%I[OY R#ޙT}q|$$BIҬdi0"CPDeL!e+)" Dy{{w|wgws묵[uC`ХG|\y>h(B[(,2}E-QlVrpܷ~y6L϶b`;r et֩%Z,1>_otNF(Uގ{ C&'PR}HǗ&qu =uvi8ۧ1<-W|,Et? .pum_ "XV$%K\S fLTm{!+JާrHۆxtl%YrH<C[i["śzk^_6HWFl{  Al)w `d@(p4oߙG(6G:q‚` 9r$! 4϶z&\Ws +\yjG핝r@7 J[geWXB띾=}&MT!酊sF8 {~]]8Z.llSr6N  s@))sfXPH~lbEH"c,FR*!qqSxob'Oj  hJ$}[0m8i,wE}JKb[O:g K6ΐ(kϸ\wu";MC\ۗk>gXd "|PvW|yb5vvޜ94cGs+7c+ 깕˒˕N?=hX$ xTb%>ޓu@3nT1`ɕKs]4.*ùv+$E%6KhWUb9-¼:3H6:}Fb9D٤{zEvHʴFOxwoe,J? OiaBq} )V篬hê`; &(f;O&v$s>)]{gļ-럷`Ѷq@!b1TY|³l$P]K'FI V%Q1L`-Fy݆6f7Xkup+)/M񃕬b)\(tY6 @#`ޖ  o}.oZl=L;u}C[AΓO$$Kޮ m&fEGK8h1o=9Z2o;x 9>]0u]Kk{%>c0X ALj_Ch[Sf, UsZ۱t[UЦjփuF\{F97Y̊Uk\"m`z i"KuDa+rp b kA_^ke+UgZ D{Y?Ym4ns?պ]TÆrF8F8ϕ[ʗAafm(V2G0C$@$@i@՝?uzBʪhٔ>~TCwbz xl, 1G*  AD f ɨeƮj>@G44𣻶ڈ*A[(lq›69E<}7yp8hWBj"SM2"3 O89PCPR@I?pP0S,ޒcaĸm`HkKG 75Ա F2cL`u%)>mQ{Q8} {V\^}kոKǞUψM[ CA16Pˎ4=wBuk-γMIlVNFɂ>ަb. &ab5rlѤNxof] PMJ3+*~ڧ3>իAW;(`E@lp  & &x.|.څ|o' y&~U`_nu~~VVGҧ~BX*3(UmUב>{2^ܬJ ӝ&XmRH8pRoU}˵6e{犷>xQb6 @\@ySdl$wzdAdH@Ї<17d2POYC8%}3`C?"%7 MKeIa;UM ;Wڌڔ!3&fELVafnW x4nw3ǻFBDK~p8WҮͳ>p2qB f RZmg$`ʡC[3=l_xAv|Iמάĭ `[O +) &X) >L8w jPg8^; %ĸ3M}ܓz?SkL&2Bi犱jTc#P{"N/O+S> >wίXZvgv>~HW*<[^loıeD1OLҤNsܾ^qOUz/vIZ|2A7UwI{3&Uu-Lm{F02 lD]t:5:PGir+`r=1S(Fw 7l1;3(EW:`߬NsZw;b7sOeTu$x^M7Rw6/'.vd @IDAT~B(Ie&C w݃h&7C"y$pvǘgXv}:JMeNkX+ @ f;Dz]we֏@:=?gz-p/ڹsTob/SSɡCeΝ%ska\ޔ `n>)%P *X5"_<h\HMN{ o11+@DRv$yjzm`/_J0z"ʵgu5W]yeA/MH ]p;m;'@Y`>[&`xcB_%|P`2UOi3fJ"sg$ G7nED1soy,caH6yӟwJ~lۦHdgVL6ZA߽ g߾w Ƈs?_7E\tIvε犿S $牨d2˴<baO/U*VRrVF@྇4}J=0:e$ N 'eg놜mhH}C$,RB$;NJd   d?(TL7cLM$ +H sf $@$@$@$@hԠ r926HH b%K$@$@$@$1$}Ua$@$@Mv֝o= L@THHHHHHHH +HHHHHHHH X}01  D@+cV       &@J|?{O$@$@$@$@$@$@$&X  @  K@c>       '@J_@$@$@$@$@$@$@$X  "+ HHHHHHHH t 7*V{{       0 0JHHHHHHH@+HHHHHHHH  ^4f!       C+IHHHHHHH DTIHHHHHHHbŐ7 H19 ӧ*/       W  "@ /       3g &;f#      sX '      Ws 1qwvHHH""s{34PDٲe(Z˼s׮6PQ- &b'8S|0r1 N-Ό9HHHHHHHH@Ș1#Z        p 0J䘏HHHHHHH :u+q}$@$@$@$@$@$@$@a+P蘑HHHHHHH+HHHHHHHH gΜb%pC$@$@$@$@$@$@$@t5@$@$@$@$@$@$@$@@cV       &@J|?{O$@$@$@$@$@$@$&X  @  K@c>       '@J_@$@$@$@$@$@$@$X  "+ HHHHHHHH t 7*V{{       0 0JHHHHHHH@ 31 GPed(T`Le$@'P|WH 6PY$ @`kHHHHHHH X2PG$@$@$@$@$@$@$?XfOIHHHHHHL(eq$@$@$@$@$@$@$@ABJ|5{I$@$@$@$@$@$@$e\(@Y @b%~ƚ=%      23g(LY @J 4I$@$@$@$@$@$@$ ^,HHHHHHH P. D[>SH$@$@$@$@$@$@$'2f(㤯& H׭I1]΋rJB E[v%9"w\v=i$SL?R` ֥CˊJF9|Ry חqqHHH Pc  峙=|Y|lRPM!GON$o<Ѩ"͕ʘȟ_m\).wq&9 _SOcPcU.75)P;vLWqXWI Yy?3"pb46hl. @Ȓ%|rSӧe>C=pYWn,׹G."xޱ$%H&c+|8B3VrݵTSZmǻ[,Xع;UP\3*)zU՗}YG\R_4]T#1N1HR~JgDz'eHHFbq']-G#F-;'g;&c9bz@Icd>us(+פR'4Q1AV(Sawٺm\UJw#~yz۵}{(HJ8=YRz1\19 $C 5zF$4w XIs M\KK@'O̙by7*n;uSdA;UB n@Ulٶ}Vɚ5)>s2g)\.Zw$_yoPg5$!6n'NH_͛D9.-ɒ/nNח)!QއG))*+;&Sϐ.GJt0R ʖ+Ww6:%QnAblܸYvޭK ConɛFԐbW_-ԭ vk/m8y++eAJ}l`sU%W\kkI%nhW)>{C⋥`R ]w㒳}YmY]~Ү|W] ؕUe5ir7wi0V+e= ,`q :1cRHjX;S6n"avo䧟|yKuȯnT@۫TUR%f|OE37{fսlWϏ… hE?ɀyAYשg~K _ZRka*e6ٰaUk5piKԳ\&Yb}ͭǵk}?)5{py֩N/s3gkY_j&}V7ǓpUxmHH j2`nh}~yg{j"N~,ӟJJdc.%Öp@ސaΊ4&mԤq,ztz%=¹jb2\zinm݁ӧN&\kF+&IjȿAMI* hHΜ9]9򯳏 |i9)>G-5Q ltjei h xWkx̍7϶|iT (be:)ķ4\#V`:S]ʕf^&lNceIJ!s׭F|mݪd=bR$=6iJw!Vv+L"ܳ/uR\z x xP4k|yÑrg۵"c ˪vLQ!d@aC%f#?xץXi6Z!h/͛4(xSkԠlk|~gٳKW'_ʖO{VtND3W)l{~[+:<@b[a ڃVZSvD}񓦙]w3Mrz(Ն?Pۧz-1lu1=v~1>CyFP[בO@7u!  X" @J%Y MV7N6-\~txz [[3 YeBU-|QʚS >_C+ٲ*`%^6JkCwQuO4U[ՠ gMsM_D[9`]r,lR%߬ߠ$v%Dd$h>zⱇDA2vd3N(AO1S_S]C?(+#ˌ,L~eS|bW;x'Ni3fiİFȿJxe!z< .LWʓIS?Qז)->k` ڌ1R1_,dp 5V^&o(@{}wgJћK[NMtlݾ3pobGGG2m8m E7(,n!S?j0,(T MYn[د(8n깷IY *џLJo;P&o.=JwVI6 o=H?#8XICŦ .jR{z!ثhYf2I;et\<8p@ڶ9wh6kG@V#P C+Yp}%:2}.p3$qMI1,+:tD~ߠʾD(~dw(-iR_}5J+iO!͛j³]_QJ+Dժ&x&UdEV`)֭ZI!MX&PTW 8/_RM;RnԶ14ow|6ve/[ry{SWYR;k;q3Bo!M1+%=<˥]=,ReҔWno)Xr3ijY]6ԞzqvȰ].r&O(r{  wu~lO>!_2p/ LP@`ԧGm{u?5OΛ.:, ![w{#|uwBeRӾVkvByF  ?/Fh6IMRV  HpM|JEI]]>K| ާ] JJRF5RC*ޅ]wͻnD/rE9z 72s]._+vg:ݫMU?yCEW&*@mXۂ j\LYdRMVNP1&ZhjNo(W•/LpB6ϩ RFC,#PU1Cxb,[$ʪJ[.ZTjq4˗mOmS 92X1?-P }(2W)d P3]b@9|b7~!3E`i|+7+ĖPJҵgpꅃ-Y2'XoL佂7FmI O&sL4R/R@!mv"\`hùP&BC  H09E*R˶߰LFsrc.Ug5cM\; *eJJ c1n+HzG@u'_cs}$kf9ǚ7}Zm Up='gNarA;SpfFro߱Kڴ xk\lp`N)M 5aܵ)4Ll+~WEZŶuT:7PZ>7Q0}oUSn$߻s ޺}>-^ 8#TL(D7{vuwP`˥9Jivݷ=SOjEʆ[Bj?K{_zi='2WRVViP"rLި\f Ǧv.בWd;w5. XMTS딻m|GiFO?ܧUW<#e)>ʼ&kp'=O0s%6&]4ҧc}0b% (C$@$=p K>0PhXV+ܢ?Ie${I-ۓxƪ^Ujr='m FA8Ag?)"s̒OUEyek#Hc ^B )BcMh'ɓN X+Wj,;~N܀L;_} =%6y#FܘX]uhW9B)~N Vc2RAO O|ǰ˱-n +DCr\x6A̝*=_Ty%,a)HJTOe,N'ƮA&SO *C^~-3f$-R1Uܗp%koWrp|yɷmtU*ޢ]`Y c{U&V͟J7%| zDW*0 Oުb}N5+9s\=}oxj^*DzJ#S*VR 4! H{dٛfX++ìPP-*)țƫ@S,YdLM=3ǽJѪVx vrڍ(^! j@P#9.s}{vLYxn09br3VFՊ/V-`uϧ]^|~`H5ܶEclӇSR@1M-_[ f͚dɕd֭@$.KBŗWZs6Vg0ϜV`P &&yKxHXQ[8$3J>qL̖ CLƹ`%ks[6\p &9b`_bv`E, .O:sGX9 3뗽Ӎ ̴5>ys.Fz!&Y,D (N /  @5\c:nOye Wxc*c\w`m bu (-pE@Z%Պ- ~nlg̐{.p%|bW`d V.1VB\L4E[e+&F={ԛw $1q &4}LFtkXR{=Q-EUZ`1A0h"3 %/EJ<]mVNχMh &*m$r؍sSkp S;u_ P|.z`y0HIVDH 0FUv– =܃p=q3o]' \M\۶Kwծ,3fΖgi'܄\*+YLߡ4Nu:iY.ŰZo%v\I_8 K&  T#PLق5~-ȑ v6,glx{Nz-"]$/RJ';&_~هec}lPA*\8)@"\k`"hUEtU k9鰁7}|hZw9uVOooO oܷΘ9'c'1}|3ϥv17v|%SZ& bT2%rv{+@WKF"vA\emq.JV`n"|mc.mܦsJUޚ5nAae\u ns.h):MKg=,1m [>fvtP,HH `R #ǕF 0w RU붮6,O}#X7pF`a+C.-m~2q#StS @A\0 ·ZSWMŎrۭ71b"1.eˉ &昿X \0p?ӕ,S A@b̝H$le:KJ[74u`uȇ#Ȯ`؇e%ؔz?SYU0 ݡݜԗO]X%畔 'JDU˅C&L.wĽ%eo8\] Ra\|@y`[}8cvXb+K`ܑJ}`',ϝV:|ST߀LL2̈)HHH@n,, 3{/s,eqf,`u|*[ʪ*rsltC= = Lʲۖq ֕S;Ynl ׈WUM&OIʋZ:`4hsmZfI`L[IJwʨ76#pALzzb\0A F` FiXRnX vh {-(}jA`eGH[ ,0yme1 -j]BWFT饶5 J_|AOaQzLa1q#q}N7ĬUVl1 ;X"kܩn TPnGgφ ^-hWk-uD* [fi|)pٸi๖M5{:@)Yw,[ڹ21Lg$y^ZXŠů)fn©0jk%\^E?{^nS;/0   dR?8jXAU*bM(>prRчC^Z+e>!>=jʽjId&z ~h#֏2Ca Gw׬[n.,pkV'Y<}v'pr1Ri/WGRJ%F&7^_ZD@`1'ڵygc7K?:HM-C07w;`Y{c}:p3X]g1y*NC&OAaQQ$h m,ڿ:9!1VQ1h)N31ۥRÑk۪T-KfIٲw8dU.>6b ? zJk92&L/ hkҘroȔ3dRXbs|`[jzM >&=ɥ3>cX}-e1McwzZ1(l_,*\pٖfj2(RJ)@XF(Uo>v8}BPeC BI{}0~J "H7a.{r7I7cGHHO`ͺ\  -&-X &Xȑ#+qIٜjrS#ٮߴ#o3X V:Ɋ-pB,b#zXZ)Zzo6C%R˗G.X%}Ò޷Ѯ3)w,j+(|δ˴߸KA7*w$ܸL%_Yaz,W=o`go)sjr~SߔK 899xw='8qRYx'(., Tօw&>WKd`%e6\+J￷ fn~IHHB%+h Dlٮ?~j<X>$'[A>>J'uvj vlO4DʔXn{s/[%=PF`yAIVgВrK hZq AiB(]L` OjH}J65J(f+C(!@JlT   H+y47yo'`L:Cf] ש%%TleU+m[l&SӐ D@:+WuVL!(> ?(qOSbCpe$@$@$@$x6əʇU03@<`VO$p;V(U3(xݦb%^G&  8-URڴn =VʥWe%Yo5K.AoZȕLkeJ`7$ƴ9b{]+귫{:uܤU6nƚ0' tFkـ;$@$@$@$@aбm͌$@$@$@$@$@$@$@$ປ1Ϯ @X 3 ;3gb%/HHHHHHH <X s &+HHHHHHHH LT HHHHHHH[gIHHHHHH" 1cF 9 GԩSX s ; DD@cf       x%p$@$@$@$@$@$@$@+PdHHHHHHH (/vHHHHHHH |Tώ9IHHHHHHbd$@$@Q fwQ(E @#5kM{ hr.@$@i@ʤ$@$@$@$@$>X s 1XPN$@$@$@$@$@$@$> 2P>>$      wX+'      +acF       x&+<; @h1B@$@$@$@$@$@$@$6G}&      ZD % "      7Tۈ$@$@$@$@$@$@$@Q#@JP        x#@J8K$@$@$@$@$@$@$O*V  o# D+QCɂHHHHHHH3g o D,V2G(B$@$@g_% -N gEJdɒbz&/6nr )U\{m)gX|ڽ9tmI޼ysi>c;wn^<HHH,b,@g$@$@#k.yW..޽ސo-<СC2o]WJJ('e$-iPz' YJ{ȟZ|9*VQ /*Vws K{7njʕ+%`;ŗ^vR+N&n @:&喩XI̮ @(W'Nw}웍/,˾RK!    pd̘p1 KL22ixW_dxk ,Wv- tw,CpR~N@p})ɔ)SHM .TU *;sRG$@$@$jhjY ΝkIa(rH]%gdsfWklP_yg9r9o(VVA_|R]]v#GU*Nc)"e(VMTl^YTmӦջ^}IW\npAR/X޽zJ;PLm}1z\[A n+=Wq6S;o6g1>~{Q)^O۷!U]V*/TTQ^xy#Fudu4YMR%gΜNG:vzoP )\ m3_rYx\q9qHHH U7+ C[[>yH@_/lȟ5\cNѣGwzBTX1_zQy}ZO4m4I(-Ytd͚Evة@a+/ъ(Vj5)rH׻ʞ={]dŊFX|`" :5k)SdJUI E>T|x9IHV|ŗV>rI'G!2lj۝4P<ݤ^5N M[…I>Pnj(_1qS vr|D]   (tfA$@$!be8J0rӍ7-7&i4pɘ5{k +/ӯ_ 9l >oUlҨQC.F&&&іHgZiK@e l$3>*K^՗Gv=z >HV9 O( p<{ U%_=E ?R5yf͚ÆчS!{*7M7zeQd@{C VA*UP/v7M&9<|b9̦;k\yC$@$@${9Ǿ=HH VM%s攍&11}'C"W5^i*FI#u+F[Nk눑AL!Op)+*U(k׬hw/ZߟĥTܹ"^l }(S[[x`fΙ3geXHƌ*8#,(:t윆e\cwy=˜(>֬YkoL2I:u "Pe GnU֭U@%Kj/eŪS֭]%/Ir!5ʔSMRߨ]ұ+#Dž!yHHH f/_1 +*HS'JLRC)S5BnS짟pQ_^o(VjgaL)z{ocsE#єq':az? Z:%x-L<)QLcL:Iɖ-SX|O^:uUEK*^<njot׊oqc % b̭0E%X5]bݯ8bg7\rɳϵ&:?cx+/$-Z4sNl[m6 gtJϣ˾ 9Nv*緲U%'nY ٫LHt-N5x$k@@R[@x;a,$pF;IPԠhvz݇\E/}wAWXi\AT,TX\x&ITzuS3] 4^f:fsBE #XmܴI^\v/3cP&(UL7pΝۧPIɸqv ze7ix\s5;$@$@$@>h⃄HHR}Ej7JӰc H;8c{`E#PN n3&~e}H*Tȥ IXAJm5P2X}Q.B+}`O=}޺G \͛}HE-R>ng6o{R;_~Sq%|ںuv;VlNX;w[IE,r P=  T$7Oy2~dWMnĺsŹ\n>Ȇ)r(VܱQ@ br$']w}^ر+|P$]S|p+_ pr7o^}LQY`+W2ʍmm.mڸqJX*JfU*Pwժʵj܊]}vK-{ &ZhQ6^ɓR!oGxH@IDATH!XI. @Z#pW;K7 .,V$eJ_nܫ\^WF"uE,ٸq1]MF = h,Yt0Sju:c.bbVZb(~t AOgt9/7P@O?B.]_wVBM6 ӧbuѫ]# \lJ>GӇL[۵-Z>a)k'ci@- W;Ih$U\ B ZrP-P C=(#Gvb ڵ%Ϯc5Hg}zhbbUZ\L%K&_}M/\@~8(NjZwOC{B[ 0e $`nF'=6kF;)Qcxݝt˾d++5jPF*GVkʪĖ ԻŊsҨ`;w1uL^Hirl9,C]J.-֭ 삵 ,ZzN煒b*ꧠRSHK)$x+᎝ n Ĝ-Vb CjUd>AL[mߡw11fo&'Mv)U&bc`jh"'TA}ĕ;p+2em5 (o?~KRLiY}GKn Vڱ'{tª1 m &;]۾ؖA(RoRض|,=^_//دYҸPҩS;ƍ bWmm&M֯qGu+g], @ +ѢrHH L7&+g|:V;usPH|6cKYF/&9 V5jѢI s7oՎp-cʰC\ٲ>,qчRbEy;UŠ WdqeM"/N/ mo(F!uoI͚wx:]ZŖȖsva2xre9ǰq?Xɒ^={4^eңnݺݺJƌ ?̷)/wc<'  p˰z3vn*crHH\֬KDzxT?C _RR$#Z`҄^(8a\b/ԅ8{Ej2v\Wb?;%E9/ugx]S=(9s?':q P,@(KRl~AYRWѣGerDe ]~=Z6X*im#˯nէᎅ2-(]xHHH :XGB$@qC +̔+Ѭe >>sH$@$@$@$@$@$@$0J:DvHHHHHHHHwIHH 0mȿ&J,;F$@$@$@qF1بXAgwIHR@JRBF$@$@$@$c%Uq2       D4 @b%Uq2       D4 @8}PjY @z"UXIO#ʾ *VR 5+"      HoΜ9C6 @Jpf-$@$@$@$@$@$@$@cӁeHHHHHHHbO3f $@$@$@$@$@$@$@[N. 32xmf-$@$@$@$@$@$@$@]㨲O$@$@$@$@$@$@$@1'p)ZĜ2+       H ."      H-tJ-ҬHHHHHHH ]8s ]Ո3$@$@$@$@$@$@$@F@ GtJ> *VR3+!      Hoc%(C$@$@$@$@$@$@$j2d൩F io],'U߻פ龰$@$@$@$@#@WdI$@$@U$R(OFٱHHHH2 @D]8V|O?*[~1`K2g$ + 9rLwXwkݜ,3Kwݴ;wɷmtWr`4XmZN] eENW-"Jw;eMN+R^\'rtCr]|Zo9ڂ-;˷[I9DyPH0Xb%  N$@${LqKYɚ%SN^dz((V^+ 1L|@˓Mw6O5V\={+ <ε9BGo:}_4Xٰi/C}gdύ>_n׍T$3,s="c:SԹB9?+5+)8 09?Dl &V11V+f?}^p}Z^   8b6HH\"`USsQW.əU[kV+v#/u*TP:ylݾS_;~k :H2*sMʕAr]#5ȕWH52.Ι DBH1/ @&`Uv;.77\ѷH|矓VLCﻷng䫯^۰iV)vU1ț%LS O)sӪ}IZΉč۪T|HR{#< XP$eN䍤\S~FFg36 rh2H&<#XIcV ĘVcxc߽wK9`hٺm>ҵK"U0&ӡÇesd/䗭۴%\/Ԩ^US[ce+d֜~gK|y}l,Z,\lپc]RrRWUxyuGU(dv5TP^{Ll̜=WVZy)rE!Uhʾ0FĮ6bh٬[?q&xlK_'=PGr\x79[Xf[([ڀfjRR|a:C]P\NG]խ{:]XnظYK so+ x.еr_(sT_|p{dk\e's)u-T>O6nޢۉK_+nL2b>o;E>}R@~},rnc A'L.6/P({ :I1>l,+ %,}H++F/.N~@9܃;t(au,YH/9e^Iܮ/s5G=gL4GJ9%,8(zL~əY*]MxKX橘.ONK$OLRLviRbfx~9iiw%;GFa)ۥ P,>[,>(K ;_UZR5EU#e䢄6O. T$?jK&]ĥeu딽@v854y>ٺL_,rTJ*=$= ]7*Vw$@$@a0*)e7Vεk7&` f̔`ӸsdCaɄ)2~5kV=ߨIe.=\q˥g2>Oiզ) YsO& F=zsCuma׺ZEf(ͥ.е%zۄm띥q8Y2j} w\:|Ow='rNeiA~2egz0_btdv9g=vقju-|>9WG.=AEz(ڿ򪺮V9ّ7=_{TР#ܾnK}:C-PO?k_OjPh7w9zDPV6UÖ'O/. hrJ)cvyB:r@~wBgq^E>oR\=t,F6>.?]*=|v;mCFVʜ/jɪI s}`A٤(#&(uMz_HJ~Aa~AvqBW rMbuf6.$ VEJJ4X5y҂=zL[m-> wǎ'mw8m-r2l>tE ^pe#|c6]wTs{w-7?"P0I\^n} Qx/vLbbiRs؇,|J)*M,%J_yЙpsjRV4mƥT2A=+tK$P~Afd'}v}P4(ک kդǵЪ͋YY"@:tJ*?$lPQ*}O/dUS)CZ4ooanÓJdtLڣ @TM7IA/g)Cw}&fNxt= |J{o/45^0u;^}YrU;_.;KG{:6ϺwpO鵶2SU*+<`D}Ax];J?O_UPNڴ|YI$V>pwr:cfe Y ճkګ;]յC**ηrg{va *[yL]x#-?K?{%)=>+N x+ {5|bEݧ]*DJ(맖gnz̠>@"cU &\U$"9ʇϥw|6<]%WOdlqϔ}X5\ p*i|,ZA_=GCL!!`^ lAVEeB3O*/NٓJث*'J Ia[xʓC!(N\3v$"Erxuc͐mF6gO2$q*/ ?7$U2Mxjl!YH~ii!ɶJ1v嚺ʶ :R?!G3םvmYUOwI ] p'@a垿ż@  zOIˡCkaZϦxĤ\+0?Tx0soRo+aDNx8!;o|I.D,/ e02`nʔ.֮Q=<,Oې#o1 l%f30^PD,:O*e܏#kϾ0CZL.ʞ-^;^7w~)cނE}su$*ŵa0$stJ|zP6؍|W(WZP"U^Ҥ@S>2#xf{J\lk[Z5;R*9zԵ+@SzQ^ =".|ʳ+AջπPi3"Kr8:pU ׁ31'tB`[10XśԷw>߇|VhN=Y^44j O$<Ƕ D<"TT7_ϥsۼr-$',;50J<)ک6oy2,DX1bh@B P%vԼT>*jV; `Z`;-f dvobRw=no{E 쑼6o\R ^Fe=FR0QΕ#̚uX^iU#D x=x vy5[#4;5ʕ-m7j=GC_Ϻzjz˘LmcY ~?^"֮S7jJ̡Â9D;bO^x1[&7a5Tmz~_\nF:+Y/$[ڰE 7]>愓yV$ 73$СBW\41RX3/+;MeG]Z c7&v^% $JDV"wo9_# ͧ#G]!1-k5FA'1ߛCzYۓLum'k $axS#]ye#,a@|c㐮ϱs Ǟmwou#a2fqJo:繛_;SĞy W/ U1xxgQA'A9ee* !Yoشjщ`q,_*eJ^C~ʙ]U!zb HX J섩 ]ƜR1_Xn%ZFδf?¼L\=U2Bf-z٧4J`;w2Z59aKv"cWȗT^mFX~$W7"!p6*iO_U(用`Y~D%_$+ ƒXFRRmjg$e5]|*׼oܾ p{HH"AbW}肤1>_fV|!1`⭼푂rŋ1#}*"DWİnNڽg/=%1୽V[kxu5* uF# dMAh ~)€gQc~*akXل&L켁Gޖ[驥wd a-_~!= gRHaɥBmu_ڸ'TFr"cZCl&3m}nP1twπ!k۞7wnף]gD~cfU*W4%g{dHehCrJZS%óC%0MF%SS/rQ< )Ǔn(䳗03rD%!jJ;.멧W(ѩ$y¸rJH^\#!"r) 2{Zy5$@$@$@ɑs)ȩˡ>ݐR}<|K繝1jDkOV'Ԁ{w#Uhı:av}k7eJtn$q)TPF}1DO-m:/aǔ2-RHMbj`[Dyu޾MKyZzG1 ja$hQMqޥttijY2ˠjo09|k75$GyO矚zi?q~MB"rŐO< 2lpIc"<ć/5p ck֭׫73Fkj }Eסg];{ԗALJ$|9NaJAӌcϨ1;, ww>vt4O$!C8[T);|Zi:/FTAXя]H<‡l51Ǐ:V Y^ҙȈ*0t"__}۲G]>8km'lU IH&v&W]6oɀӥӃxe)Ì=G L1KTōػ#$;EΓN ErKIL:{C;&ooG?&82)#}CAH ~ɚ53G`6?onB6<P`/\d:yiDfמُ<H4iRKL5VaiLϜ1}zV3'OcN|AN>R߅'OP; $NL>ĄŮ#$Zɞ>$QMiQ6Ob GjK׏̅eﱫ1U|ɒ&8sEaHH @a垸    dΰ      tQ=q/^"cy$@$@$@$@$@$@$@$ 7npٱ D@ 59#\IbuJ̸W% @4$@a%vHHHHHHH o{H$@$@$@$@$@$@$ 0(:HHHHHHH `(PU( @t#@a%HHHHHHH F`qI       H N8L^o D$@$@$@$@$@$@$30(f'HHHHHHH  o D$@$@$@$@$@$@$ 0JG! @4%+ư[$@$@$@$@$@$@$@1Cb}b/IHHHHHH! +K$@$@$@$@$@$@$@џsD{ DcX7]#      6HHHHHHH J I" @$@a%zHHHHHHH . ]#x2|s… IlYmF`?رNeJK4m @L!@a%)HH8||3;y(ak}iࡿ?; A$uTvDW{%I$2'wN{w_Jr W_\Q~W*%CͮK:}$@$pG`V +w5OB$@$Ò}v{ɲh:>q' _[ DUlYe 3x\xSNˋ\SGk$ӕtJ;>Q3^@cR"p 1VjHH gƕ+W|v{őV??@,^ DiQfhqF<`9cǨַۊ_/ozجi+K2*egƯsNbϗGXqAbٲm-\0J?/dUQ(IxH"H^c%XHH v7$H TN؉'C={6W{c2yo9uS4iHٲ#KҤI%Ke՚ur EUTv L .t9\*AA#u%Y[纠ސ*uksdu<^ďº /ZT*N9ɃUHreMqg &O͛ctHF<3dJMRJ)-[ ߰Q8\'#<}zNyP ɐ>=Vd/_-[ɮ=0}'k@>P%=rjټe gM1K~K؏ٍp>c+vmUVk6mZ0|,gU.y@=|v%͞kAFO\ /*l*`؆'NȠCѣt ׉6[JR%ch_Cԩ,@:Qs+\`6ͶHH `Ɋ$@$@$pX|ʪ*ʹe*M !|l'='}H>G ]t#C*z0?P0Dۼu C2.u[TօhW;i1^W@ݾԹO%|SmxΏ)w%AC%BuB?/L]fȀ WTA]U}>%@lTwx= mHbQ.Q;#x1𓾟kφ~!=.m;y6=$E~CN##(Ho] {D?S~spZN[(V9hjy>Ց!8hiF:U*|GTs)8d+ I2T-.ސ O/S[A]d[]N6MA S()< @!o۪=PEOGݵ{>ꆍ\ou2)vUЮr/gtȈPͺ:B{ X} | f!?hyx 嫑8@Ξ='fԡ;(s͝@{)o_ڪh@|:(s"Y/>gܱQV(}Ի%pϭ0' ?P Dc Fp#`{ނE.aexL8c"Mbffw2w^!C6ZTACvXvA~/ש%e\gT 'Dh_} +hx}' 4U%ck[ތ/֬]/iN`j]sT@_55tUL{3C>/`v'h#q518ה`Q/']Pc?A+ tN ȯCaFH,3GB*U4iygWt Zz |svb+h9n h@IDATM7-;s柠 +g~WXޜK oz xr  F+r[^i X;:0^6kxmV3`o!5`7y4$<$pn\lH"KH%$@$@,x wmHD;Yϐ"C )Ng%$Ŷ?;h΀Þ4 i3C63lXG`jd"ۏTJd $v­1f%NBmzP)aܹsÉ]wQ6x\ywcf6[\ܹ+$9Dā}dox|P5k< \||'?|$DX>(\+2e+Vٴ'UIf -_ƙчkiE$@Q"B +QB$@$@L3˫>sp1 f] ƚĢf?޺چ7qCy.b|])݃x#d͚WUg_! ; ^4ɓG_!|}ػKpt’5KVENk_D  Y پ )fͪ3ם)Lm옚τm'N7:fSm0L耤ȿb=] GH%j(+c9sͪki6;g)n13aĘ[z4ygU89隘2oFb$LY39iMa4a$ڮB!+-&B>T''ӷӞԱߣISBjLa%}bxQ1;7Ei%̳r@B +t'<^q' ;NG DGh{Lz=Tqd٪[4kXy0rIRHkeYFt)a0^Zz3VdI@Ϙ5[缀 ^6AU.,t7q2kQ*DXY.]vEQV\$6$]j{'@CÔԾ,'b:u:O/f*1$^]V_~,T$SnK@7GTf`O˟?<9D(Rb;e3֕GfЀz?aMN-^Ϡ#+ķ Tߑx6:Ytw.*qԗwIkO;/+W%xL^jɟSJ2oo_M)q6ޏ_֕ h굡wyQ"jU`o:iy&N׮]wB)0OrvC芈2[b՝\! @>SXw}  ,YLjը Dq|e\gb@jfPIn=:Ђ ˢS [HȰW%脘y5oę )]xKOÌAfyzc/jЄv?c'M4$Qigrϟ5{U7];fvGzVM=?xp$*7 @!F;fmFd] 8 ϼa8׹ktz/ mp.L O$l-Ue c|2gePͦ joG(BDVp1xFՐsW=fDH DGe>TNMG$@wCuHH ZUZtCLyjЮ-^V*K2 Qbic%c dOmkٶdŪ5* %H^3gΤgپs 1 V)cie>by%:1B2ЃfC[:`xƜkf5έ꠵^ea6(sP|(H.>j|H~>KjԳG‹ |!֨iM}8XͲ *e ) D/)f`=hğ!C2\1; o-k o*:EVpܻL thFByu 2x ěo!%KwUˤ< 0vWs|Y3Vwhڙw0%XxQsDgթ]SWC|J01IXq/{a9cs݃;vÚOW^3uHm5O9zg:psJJ;o)|>P5oFOl.)LK qIK<\6K`8Qv[XIk:@b<46n$|+'}tRg-@l@A43_B9,ڇޒojn UC>LC.'BZo<ڶh<͜>w_' ;M Κu]Yʖvu; DokʩߍI52BS n)<XOqcJ( 7`מ}H͆<9JunC$@хsD;~ ܳ'&Jda M8g#Z\`NM 3*g2ī*' HvRa'a?YF`!"JƌgUڶ7 岒:D,̒k;پ!+>5kw}EDT@.eK;u\K 14 bkck'  @uN ||o{(^L1}Yu-SLJH:ȍhC`ڐD7CrZ @%@{m Dx)0'xx־ 4$@FⰬtчVxM`WCe_DZ|N.-  I+w>M$@$@w'L?2d ~~v+'uB\!-#q6ϴkKrBVXf:>ΦONJ~(&?XS#!'V r0ymp5  0y=y$@$@$@$@ PHbQ        PXipHHHHHHH"AJ$`( ȱBaHHHHHHH"IJ$8 @8qGHHHHHHH%@@ɱ @'@a%?@$@$@$@$@$@$@$( +c=       XOJHHHHHHH /uHHHHHHHH        PX  P >$@$@$@$@$@$@$@$zBuHHHHHHHH& GHHHHHHH$@a%@pF$@$@$@$@$@$@$ p}y$@$@$@$@$@$@$@Q 7n\&?V%       ^> @`nܸ!Z$@$@$pطoع˹|Jܹm{ ĉήL3:۱me%rU}٩R,ׯsaÚ5 \riѓMȱc\ %g:up˲ Mݼ<7 @"hR@`ޣu +3g͖]SҤI#e˖V|Io|Adcm۴rٽgc ԗ/&H$;I: Ķ9s?pP3@ܯUL^Ȟ]?XMϘ4gk"o={xVq.Z4S!CsWSχ;]d ];Iʛ.]Zy<}׭]= !'JjӺ?칿j+g$SͪtZ ?}xA A٫:0g۬ 9%ZP 7ɫ-[~I?4PO;%lUokŴ q`ğaa ,%͙٣Hk3gG\VU2\`OAGH>KXצuK)]$v yh[}XBH KɞJk,k,z鵵;6l 么3~σ9_ 4JԄAX'RM9lw EKݷ{8B)?WXQ j]^= # F0HH"`*x3l>_|9%#Fr*8O FT;SFۢ)7^Q%}tRRP34@+Ms=a O}UL=/NfWЗ^Q>< F`*+f8_%4VYV\JT^'K_5 ^!歇'}ٳgbQ^EO~1eПφ~ᚚ f T#6 o[T12" 'N\3 3,[H9d -G# 8xc%XHH"AZho |:`C]ݮ*B3zt]rȮކע<]"j)YQo , VlRҶu+ o[x[{I``}@hLw Rƒ>y[oF~@8{n! 0F_};MR:$軱\"jYTvĜau^!-)]J.*1c޽kg1Ssyrt3CRDq%u<g@F gni* X}8T:DhQӜ#@>oˣu YU ue\A{0:uuOxB# 8 +gŒ$@$@$irx}'/AB!9Iƌik6mvaMdɒpvұK7lk摮]: ŋ'5~lB9._ Lk*Jc[7.˒9g.aaxN-)S}Yls3 Dd7C Mȋ+^~#$$!,ON|*I%?zs"UTFugc e*/Lz9,m_~w V0 :ҤI-/6o&o|):V( ŠN]U-=zU*< \ uK~+/C}|Y9s8 .vCH2΃ &}.yk!yRL90QT(ٳez- 1Lx_BSL ]Q‹1H &@a%4!  (Q#Fzc +GӇbʕR&AȜ)Sa<8ۢ cPڵ[=v\Y"n>0)oHc2Er%:Dbmebu$Pj}m(U$_޼CD… O?Q 2Z1 f&*m#%" ;TRU\AUbu!t̙3mBd_! BR Kʨh)Wr +><X2!ǘ C'YܹWUVq%Bnc8$@$@~Š8M$@$ċ {X|O]S^^c؟;|֔g0|fw)O _DSW{?zKS$ #21iooScEelCx/xյk2|PMY?NzCl^0ӊm8a7̕r.mIAMۑY*TU3نd3g΄$R6ۑ]bCD/aS ~Wu <,P )l}DR 6E%)ݎY4 kF.2p( 2|vG# H9Vb]cIHnL'k۩SM׺wKABUP &aa`iClcyĭ7~{=w}ZPܹs0-Ljwjf_JoYCBdL2N8*&+Q}$kxUC^H4͘ 6ppw;HGQX}K$ݤJ{E= B^pd zgK/ 0uD+6m>Dh#Ϡ}^y?xm٧f}&ټu-:lPg_O$@$ XC@$@$@^loPc`%[Ug[%0-sA;0E ceƉkVtm"O\>$GBZ006r3`Pj!_cA r6؆z8N13[G ㅁV5mύ ۳ *XPU!|cF(AaZ5Ѕ` |wDԴD(W%õEG&\z&x!]LrcBÐXטc|%5˯:m@xy-@,m6ܗſy1 mq * cCe>V4|혱 Yh$@$@#Pqb)  X@+`[JK\9D8%ߏࢁD^OS3[I:pߏ3`zYR%2us S޸΃qD 69+ ^=,v= &^x^wߏE@}Ƭ*7I홃 `9 ؿG>ۺS;X% 䨃q w1}7* $ ? ?N҉3oorV{@ f= 'LE8:hۈ.=a,yL krfe~U0:i/==]'"gM9\2iʃfg1KFwANc,)3s o-+ t4iy2{ k-Ɣ Bȕoۦ)7r }ji#"KSy}5SwZf̐Քh* ϻ}"]hs` -g@@AW $ņ7ӳO?%&L1>?S$X0֫~,T3 gN ճH# @y|aDŽ޳$@$@$pwz!^yYP"w\oJ'WD[6vAp'$ۆ875~@ ynZVw,X$HpS5^ߕ`:'}@ʶ;v:S@`8ilЮ.+)|B.oQȮ{klv^`5LuN#5:~}zK5B=#(/ztR%K8B* ұt-#G}4wRߏU!X SG|m]d5Kpڥ Vy^$Oe<>ȳafw”/C27uUN o3C$ YnVE?b7^= <p^wo{1;v}I*_;vrT.x$޼ ]JYX1dC_:tj6$3uvDp}K[#\pMW`DTÙ7KdAb)sc  1cr>]ZIn N'GqJRWO<=Hk<1+Uܹ"ujCx gEyDY1e }Db`pؔ5K9]U!iYg+HV $͖-nU >>#M74  lHHLoK/&l2훤s !=}o̵,$@$@$Ww kXu?W x WGi4fDzY ArWc9 3$ 䵱Փ @ Ķ䵑 $@$@$@$@CbŒ fp,lHHHHHHH V@(Xuy$@$@$@$@$@$@$@$@a%4 @"@a%Vn^, @`e +vHHHHHHHbqRXUwK$@$@$@$@$@$@$TX *N6F$@$@$@$@$@$@$[ܸq+f:IHHHHHHK@HHHHHHHbŲ%      @AVHHHHHHHbŶ;%      *'#      M(Ħk%      X J6D$@$@$@$@$@$@$ĉkcM Cǒ- 2Vb s#[!    A7n4  @Qo- A`߾c.b+yrr7l'N:_2el[GȔ)/v͕Mjpn[\a_~ݮK >=.],7zNN}F&-9sd='b$pwKC~~*p7 ܫ-^* /q.ޣu +3g͖]8e'O~+>Krs} %nܘErqjתsSrhy'Oj mpNY`0 m^p\yQCi>6bJ}l˓;L{k}浮}mdI*UkdžȕWw;}i6E._l{Wr)"c oc6z(OnIZTPL^~^IѸbNeŪ5]E}\!-Dڻz {˗8WčW*:@9X$ED;e˶r%u"üZx?(G)RH̙d%][^7!uTR@PecOlʇ)<\َ+&ZwߋCJR%B]D?/nv4jX_zt YYYsɤɿ譙S'H ]/\(}@%)_86QX ;HHH v`O/+>/6Ġ^UXQLs/t֣ ĝ(!nկWWm·~˚ȀhУ Cwl*U(' yޜ9I*4aِ=euģVok/3f_*mdQ1.6cˇyTK[KجIV]? (YA 58qb坸OE/\"/>ibUzL[9{ԲH$ԭ]Cyz_ v. ^t-tr J… y럲qfy)JVS^ 6BzGuD]ByWV|Q>  2 B iua='`NJ/%Ydz~HTn1diru|Ͻf-f'=X^gm[9;ov=OPsm4D b5ktкĸ5}? lhؠ~u8E Jf:[5{+D㣿_tqȀTb=4i7q.zK3AB < gBx=nQQ*G^k'8^{oݻtP!߾A6wIHHxxՄ OIv]FuXO㯿˴3eϞ}rII0͛G_TԮ骳}y -[=8Iȋ[|80 cK ?\jW*w [::!8ZjV%7"hT^5q Fӧ*>j*ø M7cT.p{d]C(HZr%6 s0Ĺ?Z(>Yڶxɚu5Wy qM{1s\bWX~Cy ʗ- IB}l?"r{ ow"yC.q7R?Lֳl钷~۟1k>#`>uMyZPr5U+{X=߮Hv<;>>Br̡3$Wez?~<)~[i΃[q~NRr %kR@'k{}sTLWҦo}î߫.693C!![,"w|N'\TkxP F_ysҿ ,ŋwN!lz`3O[E$Q4 wmre%MSEz'/ 4FT6r< +;wp^EBWT~ {N-:mSUBw_tAqF TX  @ ``>)*={ٰqڲK(1WTW=2[|A:wl 4NJ~)?w]W独[Uc6[ "_ ~6P.x1Re*Uo1hP9r{T%gҦuK)]`56B)%@18 5 OoKCrʩ܆7i,!PP6yϺP\Kx<̔SNMyUʨA=`թ_amKZ^=+wn=i1lP #?2\zDMBw7H(r~O=4@ƫ7 m=OsNy{5 Z i y}Q`REg7|9$xwkj u%N~s{AZMgg 9^IAnOxu:<^mt-PyL"s{Mf?Hթ}kyזw> P Awz=/ԉQfudYKzu?iځ{ܾ+>=f2Ѿj;볲ډMZ2mߴyhD̿V 61R_*kׯɔ_ff (1p4^ҧӃP%3YYlCَ]tDfMQ9Y2M ~;vie<J<$]ڻk]nnwMF}@M؈yOlAAE1EBA QU$QD$, l;9Yݓ+<|.;;393y??ykwmw6 өySVbm]<@=Hb> {~.uÏmڷHɃugGm-ՠk$oAL$JsV?AhCV{}eclE x0RiQBy-Z"(֊b\X0(@l{(6XZ[iS!V03bH>S &5kp6cVRbkT :%A?' j}wWQ$k"KNڇ [;\ t${?iJ@9T}DÄ uQΜ:0]T!` ~x da Ꮵ #XygRSL|57EÚ@ 8|}oIvۖ7^g ~3^Y 9JTn:ܐp,9 ~퍷R5éseRm׽~?ĥezϿEJߴU~4DÊ~,;`ɵ̶ vu 6w]/n[-4>ʕ-ku`:]uE-Wdd!ٍM ֏ޜ'mnmm֥Nq>K[+#/X_Nӆ8픓d>b#9sk1{&yjGjue5VN?TsT$I&f'h.\0"OSݪ[;nфv n|WI$8/E%9~rYw a3y*k9 /k\pF2A;Y$+~ j$h/T'QD/=b+$ƛHZ[4 aM3_5^Mv-3ݢ ֎$BR}KR&V&DM(㓞ZؗWA&L/gofh%nJ3er,`n~O7LqHy};rGL|>s;mq)'3:= B{P;ʱkvr,9ef %فߊHMI˖1Qi]"(VaԐLs8z1-/<8r#mt(AS?0.=1*!^|ru9J|s+`;j_P,?q%k븷mMD9&`r$j%1#E0I'/y"kYc $c/"d`f:;cLDQx'wi;˯?VLk cN q{p 4vD8 {h|ܭpCfE &Hqn>2nebn]1zmRWFD>4P8 Kɒ1RAU|& "92d`~:jVs7{Yg#?a;W;晨I1-6Mf7gqFHߏo1&9`LЮzɚI>blog%/h"{vin46D/rs3I LF+g&0e ",FGt dwnNF&MMNGl le?$*R6`*sCNCK~!V|2Au]wv Tė*ŒSFbTrl- ìU:Vkm݂Nøw 6eri? : -o \m(LO8LrKb|+䋼o?btBq@\I JyO_}w}ҽ勚ɤoCZmnbժ3%9!Vr5DK?,h *-nTmL}~a>&EFH{]SEthw^ n|Y=Zbn|6~ǟ>Ψ(*}h\G^"nw //)Vcb0 8|t__]~ך|G.+`^޻;;/ I$&{?cPĄ+^K|H0"ly6O8bηvkΐ>aI&{U߰s7c^5 ońۍ5,_#Ll{&j ׽w#FأN=ci޺m[f>vdw%JO0 \D1?N([Z=01@nwOrSFZq)KRM6$y[*jx+ UCD}'T`o&s/0ٶURCd3] ߸  ThE_MhyfCs9= %m3_$D㍰HJ&3桽lίqˊ&aA͕bSAi6szRRj 9E@P 26/o!O F9blҥF*J(a_SUI= 2mzp VlP#K`r`A>v o??H"Nt~oi1LJv!ߠ wj#Re[Lv_9C 8jB .ԨFXDJx[&؋Fiپ-Y&}IDIπj=-J (o-pͮGf?Hb8-5{Ud(?` ^;~/kR7_fMH7jkaH0%_a4k%Esѕ]DҩGwA_\a!hӽ}sQ׸!X]E@PcGLS\su]S|9wR3q@1hU^~,װ 9S:D#87C _Z7uѱS YA]m̀6 _[om{|N`ѳX rzV0 *d>=ؾqkǍTw`>9,[܆RcGVƅdЯW>"0@?PWd7) o;_TjK !d*UhG9pfbZ*{ya1v!eH@C>Hv/N"V$"V.d L rϮ^4 H\:=a{~O:wǛ.q1=kFݡ =o*nt̩nM7p lL|s*&S |&CqʜOɊʦ+:uY$mrMc5 筹KxؗwXϢL"Ey Q~620U-75s DHb:j"("!Qi|,>b}'s]̨1&m`#"XS_t<0 l*)W-E[LOKN}o/ޒ;<"Dɕ}5ArH wY012/‘dXf'J0|=mx-mk7 &Gt]SBFjӾD cΕQYLF9dU6wvrEIdIp*AJVI]HY"͈̜D qv+r>^]setcB6XFpQQ?a#rpY 9Sҋ<6$.8@TGDM\KpKFKxkfyH_KT514N\zIlBVY6-L~Ym.HY =<Ə-r2|5G+Qh"(VUߵWE5.[aH#0+#Z.‚JWZ!46X",aW xW)G+ ׅiVĿ/s3N?ՙ5M<%\a,W͈SUᩏ0{3_]a6O _kf]g ^T;j Uu;BV^]Q{ik. 邳Zk>e}leLqF;1箲|R@l)TƔ?1!$ym&,9 nàLNwS-=nNT1 `2%MCȧtLLVYi7L^pbOm޴qX8h&Ym{|SuL+퍷 LҪw%*7.L`}]\AWҋoZgIf>_HQ%V "([3h_tW^]L~¾TLeZM<{-fgi-IP頍'hu>N YBR%K:c9tWΧ}% LN;8bЎGs6?UnAY3j7O f:-׬[JGHl3N7|3.Ro.bQ| ’mOG 7= tOqJy=ug-!L_#~LIcH`AIUlkֿVdaPf6CLTSp(?YsO? |\wPɼTM4|}_t&hvvǙ0l+ÿ9 9gȦf7{3aaOhsalk0"ދʐ<>fY5y Jy7jkIw"-ai9d4;d%wCL#_[\X|I r<Ze>ΧA\w{LA:2yOX=p}8-p'!w)d%/O܇HB[f5J'sfԳ/,v"<|ߞ̅5c 9Q}SzWJwIߍq7QÓ=G9̞t}w;ʱ7-Sk-*"Z;jҨ5iwF]34oe;ęIyΣSx֒aW7މDmBcbwedM(Nʹl ζ{V ˽+¶vm GR!EO|Kbq)ϖrO.BRE@P6Q A!BnA4p稼4w՛pM嘕(T|_-;츃u^{ngp8_帄NicQ&6eaɪ,LK%LL1-AXDhbׅC[F=\:C}OTɽr~Τt3j|5<3͚lդ |=@(h([`o [:2EH5Vu"(9[f1{u=wˊlKVG c߿++W]i @">j A(@ۣ_QiIE@PE@Csv13!&P& ECU%T<@tW@ࡑ;?^m*h" JdQE@P|Ԩ^͜u_f+_w95= 6E&DE@؂"XZ"("XJ督X("("U Q["("("("d+c%E@PE@PE@PE`+G@+"("("(@vcER"("("("#@D:%VF("("("(#JiIE@PE@PE@PE@Pbe+o"("("("c%;ܴ"("("("(F}h#PE@PE@PE@PE)PӢ"("("("u#E@PE@PE@PE KJi1E@PE@PE@PE@P@@5V("("("("6 д"("("("(jUE@PE@PE@PE CX0ͮ("("("( Ċ [E@PE@PE@PE@P2D` kvE@PE@Pr  +n/BE@P9fCP6a6o1jl JoMPE@Z4e9񌪦w[#{Nf֯E@O>1ּlohBA>.ɤP.nl H5V6W7("_ g-uSSd ~n>S;#8P\i`/fW_{PSΑ{kF#|f}Ix><[oNVS;F37WֺT:>X \X^D=8fi|9Icrf]w5zpzҶJ켳kI3'8vs37 ~,]~398:Jfw g1]3>s.%)W~єmW"˖a!$<\>Խ_~Ք,)s Ǜ?O5s3|2#}W=ѕK%(K $el?оCpru+#dyGﶛ9J?˵KOk$m{TvѕFޛoYwJU;\*hvq|״;d=0eo}b~wSؾ~SnͼquM+m[ĥ%;0dyr|SZIk#+w,><gSHU"p~}?ʱ; mmLmlv~ć1C4\ד>}r7~o})'~/ӹ+頤yE@P:/3f v{~k͛6N-n1߭>.駞ltbvDKkL::UNN;;~[Zzì?ƴN7b'ľ@l\rх9sFAzs^nHō\mnj$ȴB& י֑,SO/p Քe5S4h}ԑfMt8+K0GGaӖn??vr"S_{ 3K\RF֭[gI"׽VqS]NTgf'==Ǟx*.MDo};ݝ2{U-?)}4?paHnK& f<2SK^ ◗x]粚5'QIr}L~t&}@+đ􋳧Oy88N{;:v rkھҗ]y%1e+ң}O>3W]~͒l$i32~ݵ?lDB$je}Ȳww2G~hTC""JQȕj:beg͵js7G>iyO.?}E@PE`+Crt2dv?O3:w%_k݄ǞH'ىoc|YJL@Do&\g/sOEfg'z+`Uii8DV"u" }~ V C=^Ŝl˕-]e]*_lhGޡQ0_ON;F^(ZOۦOw5ZRGF֗N >ű.s͝ V3), *U`v)%tmr }OGYM  ZcsU@DѶ =6qڮ@xGU=i(Mr.S:=p=#Tx⩧RZ[njH"DU#&%KWv=v;;AB[R%.fr'ҳ(n+kK-K·Wm缳4|/3jWJ7U}hA8CxLfB n`H{4^YAmϱŵT+"([N]c&Y% ,yXݗ ]3= t"GvH^HLD.t*F:T3aLPAÄD;hZ$ǟ2Z4Mu٬ο{CMQM>LPo4`]v|[11T0\fm ԼHT@ipu|nR5Y7amu daS>4h•1q T+r`-h_ĵ cf*$kp-w&u:ٝp, ĵ+_V|eεUhr:W^_.g=hQըVHt*]_͜s᥮U' {; K6g8t=baC7o್5ܚ^+"խb܄R-Y| _dfhN{Gv5Yw3&c +G6 >[4_}Gvh佽i}L&_~p{K%6}"W^^3UK#JX !Q4&fB(BێK/K|>2i#ȅTD"m뤛[︬D& B13kvm)Q\Y晅ϙEϿhWsv[Y57 [,|d*N }7Ӗ.[mm_QgY t8R4KD\| c_{ﵗkL~7ŭ*PX W_}~;+[t~3fh<޳H҆1*Sia*Cf"UʚWPE@&EL&x@N6$\Km'9< |9* Ɉ ?};LvTvKBCG0)`{k|3;D?tYp?i*q;ww"`3̃(rJ1xe]_KKHoI"5.̌@rE%V|Dt_PE@BT[aG_ :YVFRLlŝqv5]9NjTGg*,dJ kS%<QvqNSDbqlw]?쐃omp7næ; D e@`2zO|J`*v% _vz(!'xb1"+{|x:Xqr%oH$'Os;QőLRmO(&wӤŭYS#znDˆ%]&jDjH{o1pfr80'ڋFS NQyH*'h@AX@S]Ɲge}/ygUNUh4^9S$h9er'(OHL!-]I2j4;p9RМ?mV<R|]ժDoOd|؀*WMyl[8lgښp]ˏ6ru^1ti/1ӻq̩m7Z?I| X&Ҏ:> 6'sCۼ |HuK"L4DW_{#̇mymLS/\d'#cG &-ժk.j7i'a_Tm7^ u" R+DvYb2 > Z' ]g؄ \'z̩9|j%XKK[=ĥ1i85_\ 8,}q >]ӫ#&^ z]Em\j?Y\~bI2=|+5 'DXy֚=9o+~_2xC"V=bbߦ?# iSƏr)P}̨q leժ$pJ6?uSwA2 w4iTߑF\OB(dkvuc}#4[n~p+ĿBGy2sW̑i z("(#k( ]q ׷ L)>Pe&!D fzU1_HvzdudK<ԓcR D2aUϗucsQTӅT<>JA>&wض&$)M"*>S~N7O@@,,~`dq2-ϬUǰ-:ZEGcU%[_ A/بRnjw&JMab»N$;Z 4AyӷG7D-p8TtWH2/\6C\'r1ŅIsxuܝ1t  E/ė#FuK`D hQ;SS>$%uݍ5F /1;ȭ#Ny߈ 'CCD$(R !5ѼKF) !%E^}mvhcr &0M& ->O!-ǽJ=VE@P2@_YŬ@V~@`Oğ`KZQlT|K t$0@!3l"D0JW֯_F-,Nget"df8.oDK9F߮^mǩ0X=֪C}zULl.&-j.HUĂI'zL*h1}l4~gWr u~fC"ExGa9-I¨wm].֯ IvX<4 hEibU,|jV>|pߘgR->Z@7Jf4ᦀpe*M-`XPيo~;!ZI.Ա2O;lV=yn~i=#(Q&'ھD~;\֯[+;#b\$~swwYބSE@P6g6׬01 ҋcAeSs8]|rvy.|W#ش s&>ҩ1)H/[ n6ѪV<ヒ""H܅28-0@Je[@%W(IVZ2`6(mv{,Q-‚fG+. ̯ PTHMHkm]hM0C@VѪBKdW^ 't"YLYX8W|P}9g!Fn d`OL4RHGhG4k(.uu^ӸI΃O9Ivu&CG)a<'_yp=-PN&e:TiNClr|ɴR[IG DQMD…ӏ&n'+9фtʤʣ+"(@ >xG`˯h/0I. ٓ:_ O_4C|D N_4?>_裏/5sMX9@FHf9C$^ao.1Ϝ3Ńp\lB.zoq wV,W?Nř$ \W#0}unG{s\WbjؠNԵ5t3O3m䴤XE4H5mBJO~LU(fRQϗl ]FmʄFj]ԉ̞wq1!q"6I;h<`pkĤz}*/BJ($>ᇟ{H&&]; WWQ' aV{K8ID.q|n'ra_#{Zړ?7N/V&d+TB X>dL k'P%ШEQuU(,T '췅¸^;-᧟/T/8)L9ByW=NebLkfS<_Mvs-nhmwg ѳ|yKﻯKo#se/$JtH H&k)P&hi^E@PE &CX#4L֯,>M65!ҋ!$9N9Ճ&teŊ/]VRw˯0yHg(?!3<ҌD` YYyLD&20$ߢ_dTTcup!N~V Äl+FbIȜO[i?4"i2 )<ܦr4Mg_&DHGN19픓)m%6\3IMY}Aݙ3w~pXܳd;Si}L6Po~OX䷥U6zgn'蔾qu++k gABlm#qe*BہDabX8b*\Gc()k ;'?G'>C5V5*"(Vp(v |ZLJ,|.gUZnrolE~ ݷfK^`V0x,))[le`\+͕rʂLAO.euЋ&# .'dX3`DE@k%#F3?Q[h "q5ƎʣDjq|RۮN; .5L|WdM"=>ق)\ %EEhS؈AM'Lt:ڶ+MG&/t $hצ$yn@.NP.=@n.L™%4HF/!7^"d<'==9S_  "3{HAl\?VRN9xW}1^BAe{jv%РK[a>8f2N]a pDAЮ7`hnmOQNd# 8;t0+~5j^JWzDЯ6S" }`z!۶$twXyO&~1`lTӚe%=Nhu1G'ʖU:kf@B74:<3i׿m95"(" @T5LfO#cǻA7_/P e-xA+A&7V08)gf|i#dB];ޙ>:,sLW\T˥ʈVI)ز7\0_#n˻If-n>_^I`~YvPx!]`)y-+K1O7^oЊ5hM!s>l{Ϣ2.+Dۊs=r ht/IDATA1)S=8&%8Լg &DƜrY+mf{%d/іmtի .;l[ڙm+nM F 5 ]1M1 /&f!hmofi8 "c!KAAN֤!J 6F,J(Q14>_ y+)ӰiKv d5yiӹwͅeLh24xqv ,L%ݻ9+,4|*NvL;C!0#h-WYMT9}y҉UPV=̛}4N. d'@8 l}ͷ Q},sءhYKȵ)۬ic.})}TGt8$2vo}{C#w}.u Iw2׼e 5@Ȋoj_qNҖ&><$5kȉi+9z1Z"(0'} Q;& X5V-[U|v^ZL e0Ƅ L̘T?<~dB»:u¤a%nd^{3f@_C#\洼 ‘.Sr2e50QngޭÛ &[p/@d0s ߿ǚ:W^cP q{FFs ID8PٵwDBy3,frÄ`tHbԿ=Q2!U>?2S׌:Z4#Z/CɟL(&.*;3bzYx>BNc3aTwؤqH'+[ @ aZP{໑{ )'.} wĴ< 1vPSqypc=$fȄQx_-pِ%mǦEŷ&J8Q&XN^oߏq"%{c+6;IFeֲ"Dna }px|Ͽwo6F |l}3Jy>`fHkxt7Km|~Dkq6%\eM6iX! ?3N7 5ABw!UHSF4Gy7<8z|1~=\?^|;ȸvy;8ƕR|90aҽK⺂)Z|^{8c;*Y=("#mB7ϗ0;aOsdXp)oStwJ+c+5r'~$ʑ?gѦ`Q$~/[#b%wDa,oX+ :gH]翾\^sV [y8*2 _JB_pMx9^g+hnH8mdIk~ŬCyh$erdǽ<ݶ+ ۲?**ψhֺ$6 G@X"[)"`JB¬H]џBODf >_&!AV<~~1cJf z늀"("("("%XQb(k)"("("(+[ԇQE@PE@PE@PBJzE@PE@PE@PE@Pbe{@"("("("PT(RTHuE@PE@PE@PE`C@-)"("("(@Q!JQ!QE@PE@PE@P- 6-("("("(@!Q j"("("("li(Q}E@PE@PE@PE@(2_5*2B"("("("E!+[ԇQE@PE@PE@PF@5z=E@PE@PE@PE@bPbey "("("("PhD[("("("([ŋW[ՇQE@PE@PE@P"E@Mn"("("("l)S-es("("("(@")жE{I"(""~?.p7Ȥ l7?h-_a~{sѕW_c֬+V~!n0=~dv9bsa]v)YϿ2|YlL1L3G~9\2Iܰ_ŗ_>Ȭ{9J2o]A+{kƥs>~RnzO>5;9,L X~grkm)Uҽ LS+w5/]no;'&ɷꫯGjn_Mɒ{1'pnoYoLվ{\{=+?R%m@nf98¡ǟ4f1˿XiOWC3U;&W(X };TT 7h+wt#͎;׭[gz罤U>pN;ɴ_5泥:vmMc:QOJ~~(a^6{GҺXQ_ /uvT(_ܱfvLZVO*@jI]PE@P6k5a}݉G晅ϙ8-Xa?nÆIOI~Ȍa`s%3qie0nkevC.[]◗UW3 ooqQ--MW; մ-O~ɦO. q3w|wOH?裎 e'$eBsUT>p3n 1/Z\z' Uu*j2-oigF G0FssBL͢yVђxgTNymwv4.yQm]9 F&-ԉcܽ, \d|5OQӖnrʤ?< ?կ[ͩz\z()PSiE@PE@# 4|GHN9љaLyd%>+c&G]Xݹs]kLMy1nqʾc&n=8SMTgZӇ7^_`ƒgR]&H:6qƓV~\z.u DUlrA˄I*RL~\`r)*{ӝq~њśdzn,z> Uoc֯c}|ǦT"3f=V2xGqXB w!lDQz]^g|Of~Y_# dL/Qm#Ugym=v"Wҋ§V TMT"M(p^ ~#=$;ܛ5mW|ZuumvI)<Ċ&+WO`J9*@~Tc%?&("8~7𡝘mWO<|Z7zǰy%vÕfo&8S&e?~$Y8# ZnxN7mYqR4OD<5/XtB w`KɽyO~IP^msM+ϗfXS%GX;h9HCVsyL5t9CT!?L8޵>D?]ϡ ˛Ch5~=0B#A+Q(L6m~`&> T:(=K=l3 ; A&Z+z%4rݯ<weO=)ͻH-%P]Q9f}w`v{ A'UHSanE`kC tk{z}^E@PE [olL%۠~=V8uސtBY6X*v\X ǜZ ega1mjYr趉ҧ{8_qy'LO=Ll2> q1&E`ցN*Ӓ"8zl,zeWՙ~D0YjU׊ԛj5\SHgɡy֚HupYT*ANSC|ER m[ȋ{ul{%a>ʖuvr+զbrtnl0=]&pOt'aiZD33qw_{s^h _‘SƏKKv@ Ў [2W =g̚nVraޣ8tY0B\W;I֭"c@:E@P-PºNag1 :O~an՘cj9?,yݠ/35s \~f[Y{XV ͣeh,o#ְ*+f2vQ!Ng׭[o}>&'M-!U1G&_|BXF|"!ߺA*`L>yIre/-*.&!\)k1o҉~+bG5hD_p]].;?R~'$yg2!^,~#U_xsfњZ܋rU;;sMc;DsiiaЖO;$SFW^uSfm5xf"Ĵjԕ[k#)q=퟽%7߸CWZ%mAt=Ik]Qg*\Y8B6:khZsY)~ )!=h`\k1R /?jϯ~3 I'U=]º=WjU5V׭(" hr5<[\~jUbH,f- LfQÇ4eEM|˜hW\e}J46,?0r\$2o3K$.t ]^!\ΐA7nj$+x9Ǚ4n}tVG>if"d<L{itjzo/7%h;P w9*{Fi[]w6dX'Z/щ|CdMV/AYKp Sg8 .Z@evr%2oBȣɡe1OmJ+{ **BN<ͬd|"h;|b~-+&'Oay|qoA5fĐ|_\׬{>HSX4 Y&Sgm޴uzD>̨p I@r|{\LѤ˔XAGu#r$>~KBʬUb>)y&JoR6C!1k궀ᒴG4pjo" եE@P-b.r] G2a2Ұ~݀ /.aUH ,\&20„~bEŴ\3TYiE_)qOq~"fC\:h #.icsFJ(ya+n^HlARJ@w#)cI+Os2L=:AHVߠG,ڴ8bD4O؇Ta2CG&5 L`+{z {!xs=t/|# pI,Lc/>=l\a1פO?SbK 1o=\na+ ">XI:^?qˊDABmo1~ +IAuU_~ʼn.u_k׺6*{JWy5yojL0voGRQ! S"(" PB|RzRNs2IO&XRva{W7dO&e6DarteMhQ\0[h D|s/T'O?g~ۚZy%-1R/WN{T1il)O4}<C-'[Ɛ6NА68[h= {[أڿך+2<||~2.*@HjL 0vZ@h ̢9pu#S̓O_~ݞhbAϗ0+W}̀٭ӤDI%*Ie (fyNAI&Y5<2vM[~gjfRp@83O} 4josU"b)2j>\ulm@+u}^E@P-OS,NLd⛵W~"ÿw}o'k'Ʋ/+}a`L (s,3zDؚKaU!+&F?i$&`"-BԞ3N;!tV=a}ܲO[Uf bſ?//qZQ_zO pWZ+l1*{r_-t˒ -BUQ~e̘y57"Y&@^3/!_'s51roϯΩ'o,CYi!Uٶ(#>VNK*"l7oB!eX6lؘ]jgrŴa[;$M{Ʈv >Yr-F$0}3}$")=8zS{ D`^#~M }$9_D  ( y]m$o8a˞DnaCp]Cs]]Nt.[" '?Z2Όo숡L 4hrˑ*{Lأ B _(&' Q+\6Vt׼ȴVHYiCljJ_OG=5/;p}ga٧T4jUSz("+E@P"k _тQAC~Wj]9&\ku* ckA4&de~R%T;%KtJerSMF ~$N.8IS%դ(ivpAȭ]R]|-i3"$ !ò:}=ALS-n>Or6ߖK>$VΉ.WgZ-+'q'ݻu䚘>!Y%x]Z%tm .㇧4jд¬'[RiR+\W ɞODu s L}?J%TU"&_"JdVE@P :0ayqQ=lj"TIdKT%oX{qrSՃCTյ&!uLA-s]_u"'PEvn7ǟ<& ^|UF*r1 m861 ?GaD"w ?C>Hv CAJ5Z"8]l 5?-T=;Ljv =an $^߰~+~,yO8!v!Ls2^gO3nC#>V;׋\N|%_"J,ܻ[”ztH u ݝyGпdC#@+V/L+"U,.E9-("(Dȼg TƈQcgd%3hU4;_D#Bw2g1y.}i@:)a^?"_Ǘc6D_u0F@('| y2<픓YW1sٰ!l왅Ϲ:k as 6S9&paPZ 3pkp:1.+>9| 9"DW*^$煰,ꜳNOy> vHQy>\$$O aҘki۾l!9ұkrUhC{'*2cMiOu[OPG@5VNK*"(Y#pN<ՙnss:3A4KD6VvC 7ּʅ:o$D3D+A$$>~w\Wqhw[+N;j8b OA|-3iҖ$˅k}Hz[20BI %b"yL\2ϏYag*WF];i|>]uȱ㝟1"te6jP*1eD7 ;dl63S'/T䬩[~3w 4>~f o{b.lBN4>sg]JLլ_#cd%W\@A h8fZ~E5OAEj?yۧ/HG:A{;*:qS+h"$%Υ"MwK{$}f@6*0WW8paDŽ m ]n5L0 yR/N࿄!U1_τ&-P0w#Y}?2.#d;L UMV-JָmmMKܑew=Pdbi|#~}yL$Ygf2p|Wl1̀@|Rh@IsM bng.4z#ӂD^!?@@>MIOK:~w0 [:a 3aQgBB$B&s ɬ+7q):S֬qf"[˔3JVwmȰtpiUn dp~&I5ַZf!#YzOP؄UPvvd*?XkezY=(!Jz8i.E@P<XBϾ NYGkE@P A$٧g3/I(@JDi"( Pb%!4[ h1 "Y @?DY]*M&E`E@nVLPE@?ovh)E@P ĮPK+@#a~ow]"("("("(=&Sbe"("("("_ ^SPE@PE@PE@P+[īԇPE@PE@PE@PF@5Vq"("("("lQ:aE@PE@PE@PE(Pb(k)"("("([b^>"1 @PW$",,4aW( @ @hy]^ @ G*P^JB @ ZMz+E @$  @ @VU$@ @֬% @ @ Vn@W @#`(O/%!@ @X W @!pܱQwș @ @(3q$@ @\Xٶ叽H ڤÁ @` u]L oQ  @ @ ifČyIENDB`simplisafe-python-2024.01.0/docs/images/ss-login-screen.png000066400000000000000000001131661455300150500234140ustar00rootroot00000000000000PNG  IHDR&cNiCCPICC ProfileuKB"`} -! k4"#ZhZC5}ZEp\C5(薙XZ &7W Èh<"wwvx^IRtšaZ -aS>r8SSmlu1[A-#<Ņus/o9.#N9fӛib1(cKA/1ADXaJ8NV<W:YaW5C5՟oδfx^lo~v* (b`T@VeXIfMM*iDŠASCIIScreenshot)TiTXtXML:com.adobe.xmp 641 453 Screenshot Hcϭ@IDATxUߥiPRPLLP;??[@QPi%ܝ{ݽs}wwfNܽϼ&rą%H9{ ! mIII9VdNŜ7+TԟiHH TPSunit~V9 @ȮxtY/(FRd"W$ #  HaJ^D1R|րHH "JIk1| 1JYIcmyNU.HH V +"3+yg%NE1nph#ɏG  oWNn|{١O:vX%u8N\9f'ɃG  #8 ĵJ,bBjXSit$@$@$ ^b-,Qk)S'?)D7' p#`#Xӻŵ \r^:9@8&ke<  &`X[gx8+S)G   hgto¬GA? g-$@$@$F ;Z֭'WQtlk= IHH '=ɮQ:Y,$@$@$ `g CL =~m*# #`u$ߞ'IHH897(zE4X&>$@$@$VM#/Sı Sb'?kpsk| @0nzbw)iTϚI(&  P18J-NN)HHHN MDZ_#O'?{Y:,K$`=7< @$ Xz2qzyv*̴$@$@G \-,YHHB!`y(i'( ^ XÍ$@$@$Nbs;GYְp$$f=7iL$@$@$EIC~s|aN~Dh~s# dU[&OkA"0B~sG  UK&'?c@X禰PL|IHH 5e b8Ƶh/$@$@$@4u%,QtiW# CK[)W=c$4~hqtHHpg<~sı_[әs'dK$0X^ax<'  p xiW[9^iBEWf&MIHH TFC)WSP2 =C0{\^ @$xi5zN)~ '%  +kkp ~,EB;ţ 6Hj(:bѭџHHi 7G+1'?kx nf&P @0vm1ҙp#]Q4H$@$@H (ODl3 7p$ *&3s4oy$  p i7pu(e C  %[Z(JĽ}l9 @NE{BV(f'?$@$@$*!j:x^y8D)3 @"X'uj<}+We*y3#v ޵Oڟծɏ$gИU 5WFhOh?=>C`-Wds7WNi޼t^Z(S}XbL' w=a$eL3@ݣ9sfJSvm2= O'cVlKKW%5nr2tzRg*\@_{ZnW]בjHH@▯X!`X\ziOŎIlޢE1bʰ̞3GLBL_`]N7|ޚXj.&uj(O46-t$HcAi$f,[n3dAP Ο n^ [aӥۿi _N_!=KA 1RxeyYEBsNϛ,'zƳ;PX3U)1іE63r\r[["&7qj)Y\(Y*Na9wVpd;>Mem DD61b4k0qi0!NcYH> 8v 9}[Y4=B#p%=},L*f1 10cgƘiI5 ݇qıj{f:SXNaRZ}O+ -I R2b0n|{z^GIfZhQy\gbF?VK6~l!},|(lkEHSO(nȂJ/CvuY1O6"H_ tQ㪺K؞!o`uZq] =`L|߾z,Nde:"˳O)nH/wXjfŅHdgѸOLOACD0sU'-Mǩpny釙02î6rzɄZ!o\O!n KM2Fݰx "Ayvw <ԭS[ L{ց'ړeQ>E N'M]uTo%tG c9w{{>1:T)8rJTxA Յc!.YxPH0G/T8]J]t ՚YcV+t9O7^ (^%B0VDzjƘҫ]0t#̓FJ84@kS[0c?_=5ZE[9L2òKtƝ5[-c[w)\6:7ur z҄x0(b-6eVϥ/#f,*,GLNq?Z|o0l:޽YZԬt+OXC;ݺmҍSX%).X&qE H׏t{95 +&`~樸ѱ^äGkZQuUNKm$sYDy@,]l~h6w-/Nt/"-"_Q IN ~ F| ,|_u_,aZ5ip5r5nCgG:grnqF`' :XLGLVpξ<-TNt|޺0MyB#i83bE{7HiUPQ C*JY:C\"c1rur}zl'?]&C*3aXr5_Xr6vCknKAAkPIV6Į>&G 75ݧ~|U 'fׅ[& ݃dä7Wa/Pĭ[7nyx{Y ~gXEڎ^k `UmsBXh&}i%kv;%ZM~<&&bw>ĸ+Kp':H"&897D|k5}ńRaU-=VΝ2 񃏏9w1{mR2y DWW=]$JE;1>IG!@QM Ǿ4A7[99I,NqcK!W fb!S;ŸYcWeH6u]y$X'1XLV˒.v `ۯ }rj#VCc(K,ϫt.DY'`9_egn2Iv4ٵK9ִjE5Caյ2V,iEtbyGN9 =)~$2Z!Mi8c="tswKN{ HL.1b[( iФY-F#c}]qrf緞 E9U.M 8;`#?D/gt7ZLo,60rpprA|Ͳ3uC|IggܘF4M< G B0־i8x?dIJJ ~n(]BUMQ$MQdio<& kC$@$E(g$@$@"@Q`mHHHE,HH_( @ p(g%5،H !cܴ;ဲ$}7M   bd82  8 @Q& DE12 @(MdHH"C̅HH P& $@$@!@Q GB$@$(qp  (F#s! 8l @dP#Ñ bD6HH 2(\HHE1n"@$@$pd.$@$@q@7M   bd82  8 @Q& DE12 @(MdHH"C̅HH P& $@$@!@Q GB$@$(qp  (F#s! 8l @dP#Ñ bD6HH 2(\HHE1n"@$@$pd.$@$@q@7M   bd82%pHg_̘9K7D}YI5b¬/ D@dٱs>ls,ÇSu}K(ce0cHd$@$@P3 @"`i"}=[RSSeypiҨ4;ϟ3=2ɲkniڤNsq̛PFϧD("࣏+ q/_N:te%6f˨cO ɑ#GJɒ%wJBeȯ"e;i԰$(qH_~%J_~*իWClG.}|/Xu^q%:dRTInRr@^_*})"UD7joKtJzAbDH3x&'[ʶ@0Iȃd[jyIh~0 CFF]wDSwէV1^< (7 8IZl,-[x`"K6Z:'14n))mh?kzL vhm.[nLhqުժpC|tib)&TR]_ׯW?\r kkHda(RUz$V1 K.%yyJJ8p)l߱џ$@OYb(ZԪUS/X|c+VX ,(kEA'١t$@ @Q}`-b@MtMGcG7Ɍ LiKБ E\|޵Ͼ#'+| wuhq䟣7'zVR4x$DV~%y7\r}:6[K?-ء[ k֮; Wr_,D6/7x$}g}H`Ϟ=گ3㜳L1KTӥSv2H-{ǎ {ab}%pzk8%t$@#4pNqK,ȓ'$9"k֬5nӬsj'kO JLCqE 999O G95~1E#,a:=>}I갡ko:?s (,2@λ2=RJzSG-Ŀ#lI '%DR(:z@ `WL6|DWGac'5³OIb"],#$@QJ@>,[aa?v#H'@QLg3  'MQ#j|  tt<# Hp$@$@(,xF$@$( `IH PYHH P ( @(&' H'@QLg3  '@QL/O$@$N΂g$@$@ N_6HH E1HHE1l> @:b: $8b|  tt<# Hp$@$@(,xF$@$( `IH PYHH P ( @(&/?~:t(Sum.&?RW%K:p@<"L՟,eDg̔-[F8WfGC?2&[cN]ǮKe :Lx1hde=zT^~Mr9.ro'G *~IJJVze%Ȥq^sʓ .ȦM)DX#լQ];%[֭ [t,[B4nd}(?v^̀bE1VuMzZ{RNm)WyF77Stknj'}i}Q~2etyUҾڒټexҦ}sͲy /X(AY6:&N !-7ҥJI-mH?cl%:~BLF[pi#O{V[V~N"|]fϕz]KyJJɒ%t<O;KzLݥRcOvU =Fo+/ȡd m=ٗK.g=A|cS yB> @QMH*TZEyɌ݀pJȦM9a2`7>*wnEg/K!e+; 08?ֲQze䟣tW*TH{o[:w >P&Bڶi-=K?#7xnC>![6o7_}Q4ov͛G_`6_WzsqVX۟E'n<*FX bVREvmGWE8hR9k9G iu;v씯A[/}C%} 5o@ P}|t~Ồ7OmWVJ[G^Xiղ\vҸQCiӪK[_߷wnwiӺ fZ9k5?<''7( F6i,tTU"?k-6n@jT-.XoVur7eɗ/kFuNKӦMdX#ҧj 뮑/)-[4W_zNYkdμasmLs]<+Yp޹g1^tu[]ѢEgV_"7n_g"Y!^@.ȗ8@b1hj8i`}^{I: qڌ+^TP\.ĔC)kUbh\ť^:d29]x(:|<e#HH"2B r5g]N8be}a}:}z xZN`;^ gkt6;> Ǝi\rYF/\ViGJ7^_BdqNٹkytێ0Q eD7o5v" @bjլ8 AwG&Ց9E}V *!yXYOehߓGGM{X ER]eIUR#]PX} ͩވ Q,OرCLqZ+]8Z˸kՃ@Պ8{+0( ݣĘ$>7zϚD J'mZ?7(ԯ|fK6/e˖n!ٴ1OPm8Efko#4GNW^KGx KU#Cc?~O.^"}E0ID&aK{ʌ8޽BC{XիUk±t>VggpXӇr2xpYBYnne` -y>ٷojC cV>@rrr{i8ZA!kk\"E~d)2-usڕ)]ڳfZLMr/H Ps,:ҥKC#zBO99rw#CX XK# $hvri}T  oEo> % H l* 77 $bl6HHEћCIHE1n6J$@$M͇$@$@ D@7M% &@QP  "@QLͦ x(za( @(&fSIH P0HH Pf$@$@(|J$@$@( tT  oEo> % H E샩GБ/(+wHl-coYe8 $ x!/^q2nEf1yVr]:M&K*eu2JŲ]ΐyeʭҵn׼4Q&]-$@$/(Yɇ%ͪmeH]T בh[zJ)Y($c$@$@(p*(,-^Y + fre@ J;.6*$_uxCҹI:l#J pN'wܵht:Y^53SǭLVflVy}@'v>VNM@uޙ6u G= "15PW=$+Ub>|B<;&ʒ\paWcW/*DŽOk{[s ݛIk[DL !EYu/+c~=ky3AB$OVZK le]*dSk0 '8I304/-.#coݾ&Z7%~MSݷ9WuN#^3^ɗG o|ƫFg ).Vcgol+mb*g|3eU[~Tcy7~79qjΆ~Q$ƸqM+L؟q66c1Pczhh\S'φj"|V#zLEް$y}t) @lۗ"D8ƮY(SyCÓ~#XLp]^e1b*fB 1N? F՗_>T sE=LAk2~` =VCg`#c`qSeC+sCdoJyA4L?[wa_h=GPqk_NĢU]RXh^c^$: "H; b0ӑ @nH1̲HHbE1vkJ$@$(9 ٓ b+֔HH Ps0' عW) @(0`fO$@$;(sXS  &@Qa̞HH vPc^$@$@9LÀ= @(f^5j&HHo(~# @P Eow! bг`  (펰>$@$@Q#@QzL$@$7EևHH j(QCςIHF; DE1jY0 PvGX  (F = & C$@$5Ũg$@$@~#@Qa}HHF5,HHo(~# @P~Hͤ$@$@~#@Qa}HHF5,HHo(~# @P Eow! bг`  (펰>$@$@Q#@QzL$@$7EևHH j(QCςIHF; DE1jY0 PvGX  HHQ,?țGKxL$@Myo 3ųTy( "~ۇ@:gIǕ pNqK,o'3uq9|ߪ @D B Z&G2dIJJh=Gy^[ i)ˀ/H♀.Ѿ i)F:' p'@Kѝ CHHH $dieA$@$@1ES% IŜ˼IHbE1n+K$@$(9Iy bL.VHH ' Ps.& )Ř], @N($]M$@$STm#\-;ѣΙّ ͛W*W(+ W|VEQv[H<4}duH"LpYrා7abz5( ItX5+MIXQD)-Ĉ! @9\}V0HH  P$@$@(XI$@$(xf  GEG,$ HDDl3 ##z $"b"uHHE =IHE1:L$@$H舅$@$@Hwm& p$@QtBO  D$@QLĻ6 8(:b' @"(&]gIH P3RL%kmTv̇Hr@=3?d:5K.U~2xHܡTV%ޛ@,_Z{ٷ',Xrrㆺkȑ#2Q2Rٕ, U}IԪ'%CT貕ʕ*jnKU @FFOiR^ux9zٻWF,QBi۪ԣ2(1Fm";#GJ޼yuK/GU_ %hr˯4twrupBʛȁu7*[뮾LTb~&y/uXiá9ʴsbk/\E ?il*_|xe:éIeUJd굺k;ꊋ\M>l: @F/ԪQ- Mjw_G|4/ !@K1g: :sԩ]C[{/ڴJ! ,|CrjV:$ftb >HymKSeRy-Y.G6jPO[qTRI[UqdY,>s\FϧTS._)T7ȯ]қ!#[vڷOb.H@IDATO7_+y=k+[V@ C!yHK?`wWzYݻtɅHs 0~Lxml#t'Kt[le,_.À9ffA 1[V#q-]T<jOE Eo)zjܲ\2rL2CةA?hĄs.KSt)Yx):pa,'1EX`br @F&M.a &N~a_ KQCu:53/z J  'DgX-[JTtaL0.ٞZ Չ8`V(fkP$+? fºVpXrZu}m,VlWg=Bach @f=x~(68 \`c^~DO`K0C1HK3h) אrŤ#ʜ fjTYXh\^%`XBQ^^}eO)o̹rHuU֫[K@}wLd]6it.+XV]PӼ~FJM5?'o0c[q}ifVdcfuKKHDA9:ťWf]fb{t.5E1r,n]dx! U>U Vwgx:|Rj \X7Hs7!>E\5+6iTx2rqe?ÇuW_jڏ۵R:I2xz,8N$N#L`b@MKijY=3p?]N%޷ul(Ĝst[dɜsD5nեmDn&XO7TfbiY'?b[u8@M$0$e_}ZhO>o}NVz^[״A$]!v^WLDa1+:`) @x֩w /cG'D(# YŘu8 @ P#M ,b:VHH (&HHbE1fo+N$@$iHe~$@$@1K' 4b2?  %@Q[NJ DE1D @(cIH"MȉHH f Pcֱ$@$@&@Q4QG$@$(1{Xq  HHXQ̛7t$@$(ѹHXQ\,Z` @ .[NGN )55rͺJE֋HWZT!SMJ *R;#8%Iq%P #]^Wzt.5/%Љ7[ @|a퓤wd)_`Gp$?c8$r3emrXZ=tu(3c  $PpaiפqM]Q@U)aDɩrӣqk2 cjyY]b@P# ;CGʴMdjK3x ~qLhٳW}P9C~N0I(˛ (}REic719!vK@L@U˜$+h3;. X&D8(|4G~^.T7h{Y @?ʟ X41p`FOoRéK叱dɪZLIO˳#@K1X$ $kmBi AiaD3%Ǔ>0RKbeR@Or-ˬIbe$hehEq+LyeT9aE㎧%4qxu\GIbj#v',I)R}lK)un%Xq ϫdʜT9 =TQɫ>v#Qbг` 8rXyLqi r~urd䣲5V JaXbRtiQ|zv9b,DtB-(1j_ b菪\[;}?)5:3* @ ?"VY NKNUdRa)Qh3&l=TV1B T$7. L%:S=Qaz2(!]vիe~`x bXbEV1+AlۯPΘE+=ZׂL *$גo8q-,y3Uo,h,FE1D-[O|rA>,;v]/'tRNǼIl?8eS+iL(TeS*nUfpBҮi%)?OꉼSR˫͒?'lO ݉ՆE gsPAj׮-*UR++V={mHrϯKԔ>'4LWSB!^3N4W)XJ> /!?ԚPuVIvڙ5E1H "-eqI3҅Jx7oڬR4:%%Ux:z 3P QLBN.b\/](gB$c vw/ϙBڵkW)P@zG!MԃXX -C^be$@q@13N:B[*C3CxȱdN$@ DqK͚5'Q ))G! @Xϭ )))ҠA-CJ:pަdHHw ne˖uY7l .C]5:+DQlw!y饗d֭Ƌ0/ԩSɄ"ar("m67L8Q_(,˗۷ D_~EGazJ&MZ m'OvZ^8ZCYFϘpY:f;ebm(^D&؅OXl1\=nS\Go7w,"P>,Y,3-]L?x[d[pa-9st?/WNI{^xqXnܸQjԨ!6mO 6V%piݬZd+*GeΝrJiݺؕBQjU]&~@݄q<=aZP&*[t4nXmZ9J*2[=L|5k,)Z4-ZHXcDny'~(w=҅o`pB})~(>iӤ]vjՌy$cPC`-٦Oe„ ҴiS 8uYZ{=袋LΝ;KM5?pl _~Z|@|Hq}[d(Bew*ۼy y :55xZЭy˝wީ-c_qR5 a>\[}oF~G,!Cy ?e&[&nwL >vW&nrnʥd P#3r,8F',g}V r fֽ;ҦM-\s ^p3r^ziA*WvUuWnf/\0QDٝ:uV*!lݻwG$ŋnZtBp/~R`,a|RtM/S"yǎX)CcxeZS #'nӮR dzNMiN"8>jr3{F&b623#v2/C~bWtq*k?>VKq{3L-Zh"IZM4Fr`ZPZ+N:oѼw>+<'(N(W21bD&F+q=t[`- 5W?:zMƌO[li2c+&z23cDy 詰Έh]IPQ~M? T"ΰT#O.~4:'J0駟#0o߀$3~xBe;!Cbk&`Lݧf޷zH 3Q!vơ~[Ypf5<7x`.M_OLp[:jN 8դ<< |!EOG۶md@^t!{񚘮s4M97G5h蔇cl/yW߾},Q@vꩧ }'|R_cLwmS]!Xp^z` ͌«J/f½ z ΃8|!X=l0x)|0Kl}K`"D,Mw5NQTӋIFy8Gx"5nt9%:c| Efb[QP.ty٭Wzta ~EMp'oUV0t]!^O u4X;[M45no/7͍̕\V֣zmk?_R%+'iz9 1^a ^.Xf֮W Ovr_8-" "b1 @(=eHHHEpLF$@$8&{jf9aV2e,1Zz1֯_b#/AT9R̚[,ABm /_]J(8ɺs[]L'@Q  S%[n8166OQ& x%wݐV^w*և~ݢexa46n`"lx>Q<0B!pC^xشo˱\0.3ܙEљK&_XkXK$[0;a 5PU x>n+RwzCW&9h`=eR7턐M6鼰[qxU>SLїV ch(q “n(u ;Sa(?`u6kH<erQ/cݲY 8;w !ع5b1GG6XXl``]viqD>zp@Q Έ1Hb6X5AaG\=mٲE/,P xI@`QATV,Nٶm~5պuohԨQ@ F~A~,@P?EҚxbEŨg$@E=[0)ɓTRyfJ(l "վ}{186kǏ{jQ .9stbUF 7s6A`vI7p-:I&ikժrիEnҥ:>)wA+&mڴ; 'lh62& 55lEaQ݈AXTm/6lؠ-Btq&Q:n89slB~H YF2`/K{B~„ Z 5Vrabxx":Zʫ(!@K1  >ۼyޥF ]Uݩ薄 AaBf2 `sp[nFX|4aAΚ5Kavc1Dݡ (}17x4mTǁbVO8Q[; ȦxM͇$@q@05;Ǡk0[AhН1-[0 v V!^+eO;41~nGL|lF{r>-BD;'Dk~g_ٲ|wmLܵb>jR|.m{P5_ZU-#wt%^P\ٲva+ɟς DE~UJΣy2|-E;rҶzYG [z)O9w bU;~EyR@WӴKCfHREכt1O&&[aO9"}e-W\cmF9 d @Q̀buR@uu}PˆCG3Gdݲm!0mT,^X?.VmNԣǤ~RDѺdC`y)RP (+GRDJ0{Q>oklr٠qa^"=UkYe3t%ƛO'{SRd=2e;DYҪtoa fnUֈE?7k5ݚWG%U:.] _QηceՎ}Ե3u{ܾ1ŏC)5a Pnɣfe/G Q.iV]nN甥M9AxutU^fĻ:7wsPh7F$XރtBgm3d` i-_!#oe(']qzOYTQ*hCV*YsI=vJ5U[n:>¢+/n_K7kq\ sz0xī[ ƘEe!,Ap92bmjPƺltBTB?C=`Z%v}_*>[Y++HpƁLkVa%sUWoRUBh}|@Y $rjQt@9Gu+V=eyUۤ vQ ĪKM~VߔWoq`9&KMwjO" ٽae-Nt+:>bmo 4AwPQRD@ ; DWrAtPC(߼ev2dnrdfySNXq[9bAm;3C*еM4 $oٲ/Vx_\: dF~y<8[6k.,~ZۻvsqF'X)Z*mĴ'i^JCP@8֩ ET/wp`SH$m:n1Xl8J1S_ VHN[$Vy|^[_ԆvEOܟOӳJgX%jX+GRJ8 5>84Ig&3Ưzb&-ao w0q;ŗHrX576&nNNW?kZU^"Sz\C| g:M<3!:'%)>6iW3w/cߒzXn+WJ) }GO Eq+⌯g;}L=rL/_BJ]@WCm&Źj`+ŐAh)9+E޲-ɫU_qȡw*gǁr8H|6-9}?zd9'H[m^^վ!QՖ6`X)Fྋ ѡc'9^nNM\f(/Ȱ~qcډv_!ʗկ(o09%4ϕS}[!J7gY`U Cq4ot]J.O+g6nb ,\ WA=ʡA!|9 ;7K矼}]$JǢLX0k[]@G !--M,U:[x`+"'WRSM[i)/WA ۗZS&U%\|jW8۪j u |s_*Fx+B|K|F~͟Y@ gHMMc=<yLyHoOz 5eU~ axš]ۖz[ڶ^_Yl 6?SibVW& FB o7gq|ieo_F⌭k8^V KΑBI`@@@,Oq&)`0  $Q%}   W q80X(ƒ>+Ÿz @, @cI}b\= @@  A@D1  KXG  qEW%b,o"QǁĒD17@\(`@@bIK@@ @q`0  $Q%}   W q80X(ƒ>+Ÿz @, @cI}b\= @@  A@D1  K<}|p:4-CA  OWW7~rzE1sW'_}AvW  7sEl@)@$>[QtǂR?(c  VE+A@Hǧ9X @X`#:   `%Qb@@ ~|3DъF?(c  VE+A@Hǧ9X @X`#:   `%Qb@@ ~|sE'Q(OBB6\V"u]!A $z ZίI7JM*< t(8}|32~eKK2\:r}rsІ@l@cA֦6Oo'/ +;:Rӗ'JPo[_6wӂͶ|}2]}prΠhAAT3a9-g*TN.BolNNktz43TԸBIZqeT\ ]+H/shw-ɗ7,oS,i]Oq[_DõN|=۪.}}CDΫFSomO;RU2!Ȁ(@'KǎӢ3U)>mC*E#/fVUҷ7BIk  m{~+zPS4|Zs5[dg(LpS^q}SHRliT޸9-{ f%BD\Nab{*y9,)H=^^:;*J7_G]'u~g*\Ys{8-}Gīj;Ę9L]xT&b^:*^A%Q 停O,ۈ 姳JM{euE~[jg~#_X"Ճ*?=Dwib$0$_OGhL 3.]|%c3eͻ#T(/R V.$nnVBct@l6ۺzh ng|^ʷl3 'O#(_/ 5f?v&{ԶZYquu&߁ ys_Px=3/)PId÷XϺf&& {7L=Tzy>!]%^I+/sտT33ݢ@־&[ L\nb lQK=W'Jm?K^_#7ҘDg~+.D}uF}ZҘ[)7~6 }ѧ?Nf}׵ԾF`{a-Gk!N">X `'vJ`JG|DЏlDN, bvfvOυFjVvԬQډSaN+⎃Gg?N=''*8i׼k'g韖Iq K>_f!m~Ñ6/MOE; Ar ߋAM19 ~74pmOЎ;?w !>yVzkN³Oߛ7ӨF2۲M4+ztݻR g ߧ̓~fѯрo=D6lO?t{Gl}%tRʗ/G i?ΝK5ho je]hKSZZ {hp.J ~Xz_h嗉F@+W6y{QGSe;}_}Bl={Ҁ%Y{mܸ} s~$ze_F:_Ҟx:b0.bQo~e-3v-\-"UB7c֯GEy#ܩcz 9<1y)B1S\1p^UYZm/"Y4}?T8_"} V'->|: ,v#iR8~lElr4۰'iҝغW?6>lyi|}WJ jj|9Ӌ?M-/h!ԩ]vBKX&W l2!Ф_| EVr娀X9֫['[-].޺m}4ӠȧeJSRR<˯`K`-+..9IE_=ܵ/¦Oj" JYᄛZjIr+lT$߸^R>cJ~9,;N_F[9\©[.2~= 눣<.~Uo_w 3T+Uȫ2~XIp|0T>֭_/ oŊJqX! >M>Ch94yTyƨ ;w@|}.}1T^Q^b5˯0>|X\l9D/ۣ**=xje/JO|^<'/ 3tl3Y6_x]yÖۤ3fΦ5kT 7g/$uBսJ4g|^?֭隥wۥþ[Dҭk *oNc%<> ][+,K?ugڍ"ދK,\y3xǬVC(‹WnÆ+:~P(u8=JUTG=+W|1Բm:{W]y9ocQʷO~rSvW&FwPE)6\9p]o!cm.4e=5`3dnkyYc:)zS|jRYQzHdžfxE9{PG ezi ZnZ% ܳ X^umVMl敗hʼ}:W Z>FM*In!i}ZDŽt. łيJG|#ݖ ,yg_ guoMU'f&LcxaqK"yetƸ඲>o3Tf<>q|Ia9ۤ](G&K~e2RnixDZGnp{J w}$&V_#W=N ~ͺȃ@f{ROink^5f+E5xtfk=^mPy8(~\ ILq:M|*_YD!\CN[F|{_d1kWCW, A F 1nA R ʗk_IWUH_Vc?v9rhڑHۧbN  r9  ]Dk  9D1?< @@  @@r0b~x:@t @`0t(F'Z"`:@75&(a9hiUXQsDgQT*⨍ @8Eѱ ڃӚF@@g M1(ya  @ET\C٭=RmlqC vl F"+EdA@@ QDsNc͞m  &Kk<c5`_A@r/pTY"aE1#Q@@ TK{ +9f  BDыՕ>H@fڢ^ۊԟ E#[cʦb_O+}@8 Q*kK;lX/G@@bE 'f+8@@ 05E3;3vٖa;is ne{   J vi^];4 3Q!֑*8---C]@@@k%Nǩ$a(2cnuNqSdUZź,KLL$ʛ7om$@@@@-S%JT>*mƶ2i^ 1$*'/17^ǩUT=36lgZ@n'`9_rWe*>TڌuHl^lA)XʦbTcܞfS^sArWreR>  f˭-[izX9hۧܠUZźmzۜaK'ٞY<V^ϴ2V1UiG˦ޮ޶*7mSPtJXuuTU_\זmV~VArA@O}&M[^٪YM1S@JWv6^6lylNq$Nm 9@$Bkpi[Ƭ]źM= ̫-Sȍb*lYK63c3ː?cz-m]ź-m6[Qd'S@-m(;%/+2e^Y@vE$37"*bgtr5W/[gq=ΕKsz;^9`]iT߭@@ 'yg3m䕯tv3N/M }q;5mz>WPq(ʔOqv :]Ht{VnmL[^/ˎ4[oWqlEnHV4mkWXCٜH|ڀ@r"',3ܞn4wpD`"Wi^f&U6}ݮNT9blmvӦ罤_/~YGq˕";#M/wJm{3q^ eS[ Gp}@Dҧ[Ne-iV}lfA_RdM L[^/fne/9uouL&"L[k,wuQdGS`|8_pl'`=;\iO@@ll9Hnnej\^||pȎ6mz毗io$6`zzS4"[][y\nyds2Shp66?־J]6a@88K]'nOfոlv_E6fclu?P;КȁDF㴓o$v̫>mv_'QJ6"񍤾>pzOh31@`dpm:^mĊGNQrmbN3oC\nV5 j!"$;,kبBiD"X;S" q8=:FQk{E|e@)ByD5ذ~3 a(>=CƁt(>ċxV8/6Cf _ى,[4 S %EC,;>Re3@Ȏ[B'+ iXʾ$W6/U(Diw ~ vU f%ds BBcqbWS$9*{\ЛC.ˏWœTexAwt2|l@[: )GHA&GfpF>A" |2U>Ahl\ r|`x[x5xƃA7 jTG%1B pC<ã36W=9pF;YT$!1 j}-pk2 8 =[Q_CeGv&a 355XӇ?a%;NbF0X=v;C7O6WSQIssgX`zb㱧HfHE<& ~L4[z tEOop]t6Ўp~!O.WpŃ%4N3&|\'A 1 IBΥ`bP V``8@#8 ΂K* ^n!BBh1@L+qE$B$ DĈGJUdREN"V.D"P 1jDQ&Lt*Z.@h%EOЛh 103X e`RlVaX Xօ}ĉ8gpG8x-~?ƻ@%pibBa' KD"A!zB"$.%n"'6[O=$ɀ@'ŐيK!3; +vrEbC$P((;uuusu"BrPlDK}GѬiATZmv֫Aph5jThj\xIִdiN,,n==w$zzÚa032n1> 3&dXͰk>׿ɀijmҠ!nho8pf3]u /~p=#(hvF=&ƧL&A&Y&kLtMLEkLOd1Yf94,LnͬŬ<Ѽ|C EfnKS1,-YVY}N^d]gaoñ)y`K j[i{Îhmm=ja/:x:69 !Q9#ՑX؉TTzԑ+G9y}.E. .o]]y7hnans޸; 7{XSYe붷wR>`>>}=}|ǯc(s0r+Yv`ٱX{YG?}ٳM!XHxHIHKNhbGaaaa3Û"+#ns9F5Anjz̃hhqt] Ĭyk;5ױıc+>sw.?9~O mII>$$Jn7rqR SD)Ԥԝ=Cǯ>cB[m&NxaᤜI&kNN>FHKNۓís7wؼuW ~_J"?cUFGNaL%b6dEdm+?'9gZnZQ8[|zɔSZ%bITߩkvK#;el>O/m +{%M;4]{x3xQVL|&of,Yf=͚m2'}N\ G="UEO߰xAႧ VkKo/[e1XeےKK.:~^[z'_e+++n \{UOWY]d_k'P^ee|][yTyz+ p"bFK6~tms-[J|*zg[Jʲ۟Hqgv,exWݧY^V˫;N{u_ȾǚmK/Iȃ͇:HI-R;NXVRzt#:Ѭޱ)?QpIu2O;u-g"Ϝ?v9ֹ7^pźKj/{\>oGZ<[jx]suTkN^~ƥ7[o%޺s{;;ws~ƒZ=mm<$ɞ}n_鋪׎ΰΫ/ǿl%yU_۾>gПu]}%?z<)ӋiI˿}iAn+0Ќ F  8 Nqg7p6B G 52\\Tx"3iߦ/;`wh* [> w9E_ ݐ"KeXIfMM*>F(iNxTdASCIIScreenshot pHYs%%IR$iTXtXML:com.adobe.xmp 1124 852 Screenshot 3;iDOT2(22u$#@IDATxD{/`AaC@+k +"D қgq!gOM2{s]d2gI˗#1Z}+/ B)  eeʔ)>S$CLIDpr@@J|S=PT)uX6%" 0 %knS-P:|QZӵFi'" CCZ\\\#(zv"  'W?\^CsE T&Cɺ3ubI;ӵ@@>\w<aR:   #ao\֟U2@L֝\#y.>@@(dlI+Zw@e*7O'g>\cq,  @n\YL]zLoNmzKiJ9@@rbLԩ T&Au]_8>_a?  ҅Nz=U! sw@@A/Tyq#zmX+LU6 κ u  *0lkQMm Tq8ꊣw:Sf^Wp   @ _WcxD8ꉣdc+Yg\/ݵ@@?w +']Yl?ɍQ>QG:;7@@ȕ@2,:_G#juDTQCFI$uv@@U (uF96yQLJ=.w}]/=  @ Ǖ} a {\ U$T3z]@@ S JX#8mgcTQBc d=M=  "({l.*PE a svn0Qg@@J]s:KqU@qٖOvXǻ?]oZ\?  +6\uq?L=av9.1U=& =νQ>8yن  PACATl'ۗqAg Ta@6dS6 8Mr-z  @0l˶%c2O ٖUPAʕ+)4NXV7   4s蟿^[֭[kBRIӕ Tنlkي+:ab"H^0   @ Uk׮5k>>_ Tل#=IZN/Rʉ@@#  a4P^*WE^e(y*WBJ   hZje>w(ed;{{z*@@@* VD J]@& ;sש'ZLeBc?  gV\!ׯX;m%h9=4Y6ǣ"M-TFV@@@{*ydJ}-k2K.ǯ!)urݫ\*US'  YV,a)h9;p IK~+Wz )[wL%D@@3z߲eK}+ T~!) ӕQ3$FJ5c@@\܉ )PL I+TfZ|G@@ ,Y8c݁T%U8S9_Vdq>@@@/8_E U*SHJ^Tr   `Z@.QS:a)yg>[   `R  DAk9:<UjJ>[   `R 5P鹼2X CteRYG@@S^Jϕ)4eH CF+nʭw@@0)نt1P C ҕɴv:jD@@ ,Z0mpr" ?p.B%#P @@@K`n_c*zgGA<`ل  FJ+ N~+A߃LRXG@@S@ F~ۓוq@.L {m'P%O@@0-|~o{O;BNJ  /mO^~@B}~*   @.79D-x.٧ԩS؎  *pX^W*6B&0i~%T@@r!Jn{}^uIO^ Tn#  Idsx0۽)H }Aܚ|G@@@1) U@@@L *=_\]@~'P   B+Pya(y^ʦT~!H f[^|"  F,+)l Tل&*F2@@@%J@ͯle/^Za(uLT#  )tJM^o Hzvme TIr>@@@@2Py JٔM7Bi%^ST@@r!Tz>ͯv0=6C  R+@{|W@҃g^Z    `\ h Py$=lT@@r!_W@zmv*m8  *]R,2B4$i%AʺT@@r!@?zm@V;  H*=Gѧ06P IzAeS׽T@@r!Tz a)H'f'2$=   `Z LkJ%ۖT^H+ڞ-u8-S~5@@0&D邑BR˥ TAVZ.u=Y@,    P’:ʕYh?~(_?)Cr@@L h%5-<3P JZAjuwj   @.;5T{5B.iEd[@"  yd3e Tن'8   @.*=jJ]\&5P!(ȶ2*dA@@\/50.&PLzq*U`A@@\*=gjʴwJ FZAme4hձ   ͛k$@酻XYRNݖzJ   @.¬6P'=8u[u   @.4PwJscJ FZ0u[z2cTł  20wH* TI%>@@@@2Py2l THJݖ:JY@@@ q*^8Pe T‚  p*=;'%u[d@~tg궨dW  *=wi@plGaCC,   `^`\}զd HkM^el“xy   @nR+&~[   `@.BYpsC^ѥS"߷nn+j  @K`%O TH ^׭A"  @.Sy{m+6B}`( #TI >@@@@@אm*V>UE2ׄ@@@K t"Pe[& !  Y@gu2 J HZ{{>uݺa!P%%D@@A^GP/|!)f*U`A@@\m*w v*רQ#̂  (xJdDd7@@0/0gΜ"G[SeZ<¯<*)'  X#)PiC!=u_P*9^wN1B   @ z_ЀEAgr @@ȭ@@WU}{JNrcJX@@@ @r#Tǥ'L*wтgޗ8<qM.|C@@^*@BZ0z}zĆŽ@T@@L  Tz ɑAc TT  (ŽN_·  ^~J}=zzR;Pk9nS@@ȅ@ T(ݺ Ts9@@@q*p6t*}HWp  %$4P [oK6].y\[(=Ƚ}>熥>F.|"  y9sf;'a=BWRtߦc+`_EJ+t/]nZo @@@@T;@@@ ^{*5$ֽoqFt_VShE1BUԆ5@@0+6PUC>#*5xev@@(*@g$Pug @@,H T$h-m/u_b=ݤ#MM'=T   PBs   6mS*>Z0+;#T|  f Tz%E֦q_[&oH}M2|C@@\r9@@@J T) F;I@@LJ7ӟ 6XlA(5]w%P+.@@(u;I#@@(Jr//;@i%uwrorN?/?@@@ ^JOG~/閿bTJ‚  6 X6ͥM=i   [@UC@@R,)PEne/nc@@V@em0@@0-m):z=UI)2@@@@Jk [aUӦw-[@@@@iS8*oG}aMF|C@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/#  *;!  YY_jG@@Tw.MC@@*Ԏ   ,\  fTf}@@, PYܹ4 @@ R;  X,@si   Pv@@X@eq4@@0+@2K   `Υi   `V@e֗@@@bŝK@@@ʬ/[.?ȪUdŊΟb Yr_^V"+W*UH绮/_r.f߲|:YfɪJRbφme\? Q@ K`Μ92i2i_իWFU4rKiѢj+jҬiufwA[M@g}Yeʔre}z~l=^G Lf.g.3ȸɼ+_FiҲaM٦qfUb:(Xr7u=/\2RNz^ (RA\^ 9u 2TF|5J̝UXQv~;iҮmD؊$V?=3ƛo:-e˖ u<!`J@Gn=OaGSC>}O?zD?;(%իWwWi仆NMΗ!` ۪AMi߲U#ٹyC\!/* KsًVȬoҸVUUZ^ن. $4x|w[Oc%7s-6__iP~\tt|RNHup0q \S֯_OD/3fk}|-X&/}=Ia\n:9nD:i.m5A/:w9wy) ao-ujpC`T44gQ:#S%(Ю·v˗ 'Y k"!rpcwՍb#]{3Bq}e>@ RO~1>1*eb4^ۛԭ.JhBjTUm&8n>x1yVZ6zҎm6mx {Gzփ@U꺖 I@UR'0|<;ۺ-UQcvn)th- jTI->޶Пő$'aT6A-@rk`6\=HVLxKn "PDq\*s2W4B&^XtG)[I-.{o'+1b{9z+rRMKa;ETE=X+@}NCUR\;k+=/. dz Pm2\0ftt|W\ygw-Jw;ZH TFXBJ ~-yj3(E%u6Lڵ ~;u jn?Wꛫʎ߭U♝$\'7ʆ)D 1(@*^ O|p^Lv]Ex,{TAm PIbD'&~:VmMɵ_JNxHePU&!#A@߄  hiYʖBW\kϬ/qҥrgf}g>)uuo;%.Py_ 3g m#:s)ҿRYvip_9we1K .v{mʎ~,Y"w9W֭[t"e}֭4__:+=g,vϚ5;srUW@W dF)SU+_(yt}ڵkiu+9RJY{ͫ#eӲ:&H+&nk~թVIZ+ +Vɢkdx ե-/}GGiT[Bsұ[A-eҹMsiQfT,˗xނYJō|A {,xcO֪5Kǃ#?Lׯ5k&O?~+,z#Zjf<($r 8{},.CΔe9e3R6z/ ߆= 4v\7[ȃ#+\ՠ^)vEm#`@!xOf,\;/}Ģ\ rBarQI6wo{$Fw N"@(U(67}P|}n^vn6r=*rHzl|u\%:t(XB Tbx#ܾ~ۈ51y,{\9y}Cb/l_9mq !@@a}LH |TG6XR^{}4kL݇*6'o?ī"9H.}8  -P 8SxH=W\YcD')`UWZ8nSc稢,{t9(UpcY2g\Y``yRUyJΟ:6k,[lylhs3f̔yܸh /JL_f-LsSqk޼4jЀD\bL,\hZ]o?׫W7JW^ʕ+gB -PӟRDYjW$_yd*X}ϿȂeEo)][:rg}W睂U*Ο䧾qqjXJΧcgjd(g]#T:Yɂe>׿5w`uVzeL:JxTu) kw@WրƑիW'7lԨk󺴭#G~p;n|VRPyЁ#-7|=A @/fmdKi̞w[F8 T֯q7|N/.٬E]ݙ=o΋cӗwF#ΧNH1oЗEjgexYK绪4@}>a?L o^WO39`-f6mLsj5k"M416!Uh,5R\I^W?Ot:@.Hu뜑O"+0KJ ?g=z9UVNwE`ēd_lfwlU@+>DEq,zN9)d'>RlD(˗/^|E}}燻90?NڵkJC:~wJ[o{ay@e -_s觍''8} y,r‰EG:ToBZ7t T~tPR-.DY}d_99Ĥ$ {nPnRvaV;Krƙu%UTN<^&P%*K:ft5IDAT~L_(^|SI P|n[|~Q K֭#|*qUVSO?#|q6 RFGŽ;(9}'1{mw r::ѣ{oATF_aݺJ:%1T]wi'\SjתW$SϘz ]j |2q9[' ͛/oȭ7; B@EFa[<"'p\r5>5P͜5KtbԲ6WI]y+Ï?>xF˽w ݺ#:8av_|Y^zXClj6kX|LaB T>G.|\k^<+F=W~o\%gI ??Jo 17z;Wd/*%,vFtdJܠA|+{N҉*)='68r;7xmuDV[6uӋ>tnqY? %֏׌ 6~ɉ[KG7oAONr-7KMBShjڂerJ=0Yn/eRwѺNQ2eRym֗O'1E6Y 3P]9lr&=r彜g<{^ ;ߥWz6 zt_o6|<^&nv*?R`觟˽?Q+W$7\w?;y([-PU^]SLޯ{.< 7D^}Ǵף*}1>_wOt*ۥ>3ekU[UJ1srm;7o(N[*>Roj䯳Wm%賁79(8x̿y'C?&[nT9>CU*>`bBKuGY](frR"@*%e[ź]pQl'ё}GNpfvֻ."DEB41C7n$gωԨQ]y~'P UE@1NPZj+@8: q-4#]Vwsfϯ T'=Qr_>sÑ)P} gpLeѿǽo.-^wc"(sb:By5Tyr)'GccOL̄zצ}$@*.4ۧw9'tV-K6mmm70qw}_.e˖vU$sɲ-7ݐ< .@*~_q:s_.N[,r}xv_ke/KJ9sK?Dg;!K y P^lnjAOrʲŀ]͝n" Tdի޾/Mw|TՕ'{$ąV#O,Y}ʖiҪa-'hՖ̙5k%rI92Tz;g~xiT:E},:IJi_4ȾнW#G+Lx*E\!+W'}XtOosԪX/PUv|!6{ry]*- v~ĭS!Η@*7p⼯)%BgV-8RJ]|pEkϩS{})gd'5;~מBvS-@*,Ӡ>Ć\u,o?9DΏ{!P-7;gu{fjZ|=YgU(ڻrDҡUc],Z:1Uk.li/g8x|2~{ChYi#B-[}vnܪ!)>'93E]Z4):mgEJqddΙpz `>,<]Z-a6m!PUu&5kT,X w?~LfUzjҹOU5|.Y&I[ݥtDԃӝ۶-BjתŋȌ3O|*s z]sՕמUȁJ{CKt$#cG ,3jq/qrd_KUy눶]Ze])sQeGf쵝80Gy~bm ;Y{GE|'~; 4]ұ[+ ӥϛ߈>vs hZƎ'%e֭N2Sm cZ!ogS:7;t9Ӷ|T:xnI:u|3|Ïʕ+}˅QF 9 {Bc'dı^rՕ*@uεN9IʗOz܆z϶E桪@uscg.2DCEJ{m {v_e觟*Sg rwAO(7dgrsDY5kxq: =Pw̕K&kJыU3P߮b ?$BUf {ҞF12 2QR[s rD^P`Zwq;_ZA&[-➄k]o~Ţ#VQr$d+PrArqANY̳Ͽ(/9 GY_y# _L!׼2RY|Z+m֯ mu uSӸk'zVY]b>*ZyQroF3zޝvQp,09A$:@A<ģRy>,%/TzH_n{s;f& tx6rRõ <4$m_>VtlTZ(g_9 r ~Mʸͷߕ'\:myؗ8mot!sDzwaK2?W^AϽH}?W(UQF}_˸gE+Zٵ+k'/^Qֳ }PC::驇tzrχߧ^VuS\5wzۡgbX>^CU;qd}MS=˹V3f}̠^{xgy*T]![נ'N$]qu|t?Y:Ŷ>yO>,:'h-&[1G{N’T7|ӟ.wNrNqً_ʗΌaS:KiPA  ]@~ŗoCxO=D9d@ANA[=#{L黣,Qs ^=;l*[ ^w܉FB~vmM{Uq[To/̗Ϯ}~0N{2ֿY^t23ZړĸiBC.K\Ոɳ.Z0fۅYlwWow[::}Tid'*6!9U||3k֚,BŊK3v\jժ9<] |oO{ 4i뻙>[2v2P;eq,^K&MCi&~i/@'Sz?DoٚhyJITڗB T:mINMcv[:V>!T}.9<Ա@*UuR;U}ѣȒ_rHW>o*Q;#Æͱ)ʲd9.Q<>7FޯS\S'xAi#P)S(?cAVŽ64Z,lo_zTgQO4]K"uoʵj߁~y T֫PPYB]ڦ{V|Ba9P֙h3)Y ]; PקȠN.0mt<7Οt;S[J۳*U| T:$2⫑im),\HN;(U4Pux ڈZz Ο}@VWdբ/K09٪t G&iSmBimub4{LD֖{-P+zβEj%}HJV@5kv"h͚=[fϚ#9} 7o~Ξú+d=Hon:Ҷ)NUey6>ywdEo[@L -l?:sNՋ;j,N ?!8'|L/]bu8CG[]R~C}M̷ˇ>`I/'MU/8m3 Tiy|w#wDg_lv|9b~gl)V6$bdF6h쌝> * d33kMT,W6*!֗jzyOԯs| T8Enz8^*=emT4(QUsB,a_ʨ+WF8U8B T=/.98XQ~7Zns[Vԯ?6*;8Kms**:#T:R͢#kfsXͷ@wOS"iNzmZ). 9Q -^,}O~WU8B T]C:rP8n֔٭> -!8FﶫoU*_; 1P]|9Ўi]w=˽@,-*?oѪ"A/6[Hߓ Tͫ*w?&TJA| TG&wJSuѥWȌ8#po*@U#J!R7BSRN>#PҔȎ>:ɷjPS^)c5o;׺meK ;+Y%D@D2={["嘣s:ӷ/M<wq˟dX_6dJfN^qC̈/ uTFt[>mҸ3[jrA3dʖ,vJRs#8n2g\ז$:BIU&^}N]ea(z%stUG,Q7[SXp&%y/FNٽ>6"*-"`P#t_.o*_; 1PvIrI'u 󣏇=gW^zNUO72BKSb;FL%0<5PiZay+η@5rsü"/=p>{fˆrTƉ9e&yKv:D.-]^|UyO-Xa7^e˦ڸNHQjw@!۬mFe:դvJRSje^Bںs^2v[KG.z gv0 {N֭.;n*؎Km#0u49sVbf<$nKVL?3B q]6r7K9~ʐl ZFuyAi PmYv eֲԩZ)qPwBWyS/s_ЍQvm%kZ#G{\9y(@ ǸƎ/G֮]TW\vTZ%‹/?aEʟpܱr^!P5/@ՠ~}8HiJ^v2qҤ4%jڤ_B* S~_~&Ӻ7t:HGWYǠ$y3F PlYzE -P!}ߒE+V{dت#o\9C)v#38iӦW]+˖-Xf],nm2kl9 䟈mѽt>e|i(@ ߝO:MVVsӞ#dM_ѝ>1-3HiQ#2zHlY`:, 4a/c 1PuYߺlVZO PYэ4" W^9>qDN*Ur7ZuO?Ϧeog=F/M2(:-ڿ;'MU.We:Izt_wzV_lly]戝e:.6}wBuک9ttLOj\֩mbbQG<bɂM*zdXj\}u2ҖWNU#G}-T)Η_$ժ@}wj_.t;\_ ;^yuyfA 2{@ըaC䣾d{3,s}eyK&Hb<}VwtZ9OdMw=ߥδ'{LkBWvm(emޢ)_Va:j'v\9x&+?M//:#h$!)CZP{|tb[9a嘣Cʗ/_tg5e!k(z؉+g?!&P5 VJ}> R)7W_/-ەf uc.޾ҪUK:Pਣ#ka]Z%fkX3ۛ.Y)%GFXˎ|q]vduWFq׮@1PiNqNՙB=RRyeZa<.D%9;Yz3ϲlD NUUj^ ~ m]CҮ]T)[g7#G}#osS.3:/BJ㿯t949c<|2d{ȷOO? W|>{%W]yYRJ2PMkPYrMUdUiZ_v f?->L]vZ89~EWNc2>8G]5P=2t rBf'~#׎Tpj壱SԥtnrȎMlgTqR_6|uKmXŊ[KƍDߠzVZpB;w̙3W9_z u R2-LB =Pq%Zx\7ӻմ.:sg .ڰ:v)' *)rG Sq<ڎk8Q*Ҩfğ~_nZ\f-JY!],g-tW5$n:E,ʽr@YtTӟE~p+=}1*ouדD#gՇxߘ~!*E(: tݍ7˚5-]taxA"Pe$,@R~=9͚6+&v_Ӝd7 C_"D{f WܹvO&+Vtg?s_k֬)Ml)5Ɩ[m@)WouT-=\:lر3qS&.f.lyžRlv/uz;2{ 989wB*K;ҙ4NMEoLL'M.C{0*W[m]Y4LSJg'{~N דn:SՙAsqFEsfscY~֛s/?_F䱱UnOrv޽z~jw ^ TھeY )ʝwrw\y)-'BAӵȆ0u\"-EY.vgB Bb" Skmt?yHZl*WlBrf/}o?rbAuʰۆ˾}Ry)PN=՜ffZ`qY61 uWطV?;&JRMf=;.6ڪ*l ljRW\,[\N.dgwѣm!Pc5CzzLh`lw#Uג'DRE}Y|@KvDZX,ݥ_W=f'g[j'-ֿԮUK+7 A23_UWܩ/YW,,©رc)2op6k`'v 39xl?dxE{WCó{w>'[ghߪ'bOSrX={gۖc5>:.GMW,ukFAjhNq[ک.c p RV]gw;urL 7 W%2M֭[ d]mt<O^{SySuo& U_wСrrPѩWKRRx27AYvCKJ_It-=i~r iU|(̕;ܟ.PylܸnƊDF~K|޾GXڙ(ET3,:pek5;.*]+zVLqZj#;' 2RkNOo^gm;26[yuh p}a1gm{"4P?жm\_tʟ ie~oάuМZa,tgԿvhPvni~ZHNŜ[ !EÜkowZ4e%&4:- YAǛh ]+ 6dJz"ҩ1S@qCϾ}y5M~mXjJj $$3SY1Z]g8VJPRR"&$HqIqygBu6J H1##]lӪm\]UIrrbN|q3uUHk;0?QAX'aN%WT^ ځX@eT @ vh8 pzm  6T65 #A3@^@CL@*?:}F**T o@ T 8t @*O B" PE +@ W@8 P`T@ Px@ * " `[@e[@<04@*_ 3D 6@ VXs\@ (R5 UTU0x$*QF~ x]@}  4vA@ ^ Py}h @e$ PE@U DQ@E\Fb%@#  F*A@@? 8@@0R   Q@Q   `E@eJ@@@*?:}F@@+*+T  ~ Pq3  X PYa@@ʏN@@@ #   T~u  VTV@@(@g@@"@H%   GG>#  F*A@@? 8@@0R   Q@Q   `E@eJ@@@5D @IDATEgΞ Q99bDr D%A( AD@ETPө`d8Tt^kvN >5~3ۯz ?)/"̧e3  S ,~+xdo |.Xfwqcn1 i(oi7*Y!@@v_~U*VATS䅗r%2b`Ѫ5yYjM~n23(ce]v'悲lb-䳕xuRnZ-Grx=kX*Gy#~_|JYA-AՄ{c9N"G E-MEN@a PC@'wg=we`RZvDc%W|]~fCSE5,G-^>6#g%p]OdT[q$j7~!C>H''m 7 6>[2^-kh#Aiؔ$R,@@U[/ߩmFyr4ׁfo?{ y1*eXһky/kqx$ZN+j@Y,wXZ5=e?V!o*/}!޽KfCA c{$0^JbrF@N#nXM%M7m}&}]hm:t^߯l7:CTĬ]MKkȆ Tb=b?̀Ԩ(#`})@J*ˇK@Y+pǘ{dS|SN:Q&MwXwRV}?6sd9#\ƛ6/YŲ?TX`!֯JUjO8^UIu]i@ t Rt}tL\kܟV[yz I<0!λ}̜6Ł~P s4^{MiӾSh˗>Sh3: رm_8(6[J@UleH*CЬ+6Qji}EK<ف[/#Zf>:G>3ƼovMQ:jÏ?Wunyxk_zɪSz{@=Z6]~Y3{h'=2 ] |RpPȺu9ɻ/ /MeGʖW+ԨVE$mO痉:ig6"{n{/Sx\Rb9%Cs&̝ ivrQK}?Z͞;O1VPI{YCD%_oV6f7}V-˙qrIes~D9&*.G?>gtЁ>wʕ6u*K=w_Fk7m6.h۴ӎ;UBttҽ݋w/B<|Dzne빯<]K*eoi&C*3A4a1Q6bڵ3 EX=-M[o,~r^(ln wm(i`\u)55zLw_/xI/(FښTfG~H46-]tqM*sM'ւ}cd_"4W޽%/>ASx Rf |/ 2?(cs^3z@;X}w2LVnwaٳ{Bmw~α2u#1#ZKs"6,;Lg7E= ]{=cexa>B6̿٢?y7/ H,{Q8=G݋΋kkEN&ײ4!U2geJUi:l!wݵ F"}̩rl gII⥷~{%j\zt$hbWۀ4oګm~&[*:sy}tW1B'w]{ˋ5mVҥcJ*zw5ׇcR9,aۣX0.9IŇ&icKr ~/ZrZx߸1ITڰ$G2y!k7)]Oב=k˄':7`{ի^lq.*!53HY*eB@k%-?"/Y|7T}7VZL}l$vmW9樣~O?,eڲԒ_oGCy m0cBkOS ?L*m~yD1D}e }^y^p\j]lzM<<;/ꪏ,NypP\pa]Gы`ҠվvWb}SH?CK0͚=WG,mC/-޲^>ꖛ'X~^}nփК!5i>bݱo'w!8bG3xz!] sFdŪ6F CZJ]w{/G&P/⥢vJv6h|YOk,m m>7i|TGM "W Z 4/e:LiwysN\Vz`ZoR{֫S\deB6߁t#ݿ{zܴZY}7)3/ǵmG7MD:m]LTHgU)3N%UkK)L0O(t?Gݻ0b@N#~{m{VV"c˼@w /߯ydZƻC "ًw!E&~O8X4ƧTEriU\I{e}Zĝv)4wMeB6߁t#{z|ݔqO3{G`>[GfbΚe4Sn#=R"@@-G9}NPIhO}TWCn<@0SN[oi%=~^{)o^ݥE:"@?zexI_H{Glzg }^;Ћ-S&?4a'1Y(K |ڎI;VZdvȢۧml:7gz;LGWv^/řɧ{vkN=cT\0ч3u!@J+׎yڦ;TNwɄt.ۮ(:u "l9RlgN hWڥMn:n8ˎ4_O}u{y]^\$w}sX>ۑLޢ\${`0Ij6~jlD.sk"zA>mrP5lkho7iվv4<+aS:n]zRHWe^’7-Ž|%^|{Vp L]f;udwOSӎأ3j=Źl΢F9P#E-TrΜޭS \~p}Kk6֤,x쑘i\Pose/]\ Tpki(zGզ{Z+bےI%_BXhWQh_6NӒeΚ.^I. i,JZCb?^~ksh`+2yUZ߁t#S{i&snUku)nsٺvK$YEЗZVYzz=SO&?\v]vN0E{^kTyM.__4-5Jb h M 5( zӋM*ps.}1}|Uc)z~it=~&:N=$Lg@Z?#josK/Uu;-ݟo&soG\f:Z9KQXf PF u Y) hm'pԯUe8ڳ\7[xKZit&.^ۦ&;ɦ Vg 5r]R=IT/v/mkK*i-C`9'=*{*z\H:2g~ z γ6Rgy\xyIsٺmT#P<TR(vӭۤ4 &}Bm#wX)?VL;R--5TO-^"^w-[y&>]I(Q;4(j*|N;z!LH*=7 ͻ/xLTazJ[yב߽`܄Gj;WryͰDusT0 PnHI_Ujz٧  oף7J_ࡇʹY&ԕSz[Zxr~~À7۷Z>r뮻nզh;LԖ/ꅗKfxFjUF=mۼnxWitZXkR0EŽ}:L:H:2qr^\z5Vy=^:Ӆt,0u#&@@Ei"^hۤSh6Eߧ3Qr6oֲ?ṛDHTSirEb4(jզCbKʗT ]`j3'ŝ ^vQ6TcʗN$]SQYUL,GuTϯP1ڼ.Y>x<@\}f$*߁t#S{Q踶y|tHƛo> \*Z6%GxǑ +./# 6 V/5vkr: z[Z6x]`fE=Tֻm5OThPITqIŕݟ?I*JtLd;~?f^0& xyYhрc>h?RhyQr{_37ptFZ߁t#S{>d*%=/?0YЧ:k-; Ms)겋#JyhnӭyPeˑb;sZ@ KaOzQ/Ջ3X/k&_vz0 .,?Y۹nڻgJT%Bv!Z#I7IڛeSITVPaMF*vӒ,\$}jplɢP UWӣ?$Gqxpp e &׎@f͞+7 a?.L|ҽL7o]z6s֙rw".J@PY l ʖ#v]w+MufMIwϚ1E:<ހvm*Wg^𖖀J/d}RL6eW 7ڧ(GZ<)ロ_>[n5 uT@8`*W ޒ_˒h?hukאA`K*JZڴ-{res>12iJ~(#CN-VJ&uD@ddK tfXG&~|" D{:6T/WMMT˲PeiL>D9Ç ۓc>JfN,ʛQy#ӓ2z͚orGkV4i[iq/ PEp]r8乥fS-ʕ^vWO'(ǟ~2+w\ ),jش|g- $sL^Zh>48:>;vZ\ۺh6T[74G])+ɧGz$ujF֮i˾%ɴ`rYg'jo\ u{/劶Dm Z5V/awgŲgϦsv%yG@ l ʦŶ漀^d~\UZ6ojG{4cY7zʟvw_S\W|2ўekuI}*Qv U5'7\? *:}Qrh?#֨Sk tu?KJjen4؟8~0;6Y4I{[`^6I^J0t5:c7COD3禾֦Xyu;g?,[&TV{^l*^͎fJ߬^-Wu!`4PEGɦ<0`:Y*:v mw!W64ݎ]N] 86$ږ$R=P6jMH>բYtUP-n^苜c=2)v^ 2\fQ 5'ΎKkBS4W[ ߨJ4S!=R"@@-G"g*;}ʘc[r={K_M?jT8Mo?Vyi})Z_Ruߕ^&{n.8xhPX#.|9ڂ7zۻyM.ҲE3;zq&=M[s9S{G,YF}n,Xw,4nѬL~hi3[n/kV^̬w·nB.VCGͿЋU/5##M_ZVjU&OaSW*sؼ0zᢧ^}%҉*]x:vӽt>́N;轧O,s_}BϿ^S`5o̟3S*ҹlf<$G<[H[@k%>_`GI.~d@48҆mQR =c~4 ^w݀^gˀ cg1fH{r=rM6IMe1E'xXipt㐡EqבhFU^XJ_ t[5jwUPmC}Hf{MX|y_p*׍*^G:><9+-t*Xi[sa!R c͖t.[WXRl&C*&eU6-uxeҽWоjK-{*cD/,G=옓tW^~h2J^\z]׽&8O{K&nǷ&',z#cFr:sm[宋L NGkbT*L}M{LJkt[vTϯtTZH~8{*@[!PJ\:wmMmC lUҨy+'5W&,LJPdÏr҉'ȥTyR v_1G%3M>Ui?S>  *܈d5}<+;l~dZGt,nج^{f~PժXk_ ẘ'C*.pӦMKϔv):abil8SF P%a.Y-v:Ӱwsʘo? .yL/HdUl?7JP P4)F`Y2|ho{ouTжk;: ɺSN&-[mU #dU6TpF P%a.Y/_eW?P?kq>6#zm!80xeλw߯wIT\I֮=&hɴ3ev[3eo}|'r^sD{bK6nskuP_6Α/*q޶kVL2M>3Ͽ6&}4m j7%_Z~wpqrIC.'ـ.z#bóׇ{裎0ֿUoFs'e o?Lr;zхK :'2f 2}cU>rءx:h@_[\λiyͷ{O {vXÏK}x /y6׬C9D:S~)vQ<ǰdo6j?xue ;XwΊ_?NڷB7d*sɧˣs=/9|HLc=o /P5)ӣ~ }vȠRv}c. B@Y f-[^r*oM_PnxSpRhX/&MWfL_Xiݾ@y˻<_pV^Z.s^/iP wӃZ6o*Z`^0kR`mӆ Q*U،niw~eeqk.xb}ԑ >/xlwQ^4mҷO/iԠ^t7^czU|+^0+m׀*hbhpڕ _wl`'~qp?7\]7OӁdXؕru7uPߍnqh}U>˘I~eƜ~fG'z>gf :^N] 37w-tЬޠV yPh7|(sA?/4ݎɬWI:GVm=ӵv YgH4&b~cDfji޲яyߥU&hK_QQ /o[s GWjAS lÏ̖*z..~r~L@|| i[oKL.mw!?Z'}?lsI]z\+r r֙ZkM@1t3P_UZ4kb>ѻ;?zG kը& ]p^Y-3QruK^ouɷ}'콷\֢{u ўbK/M߫-reeLy`W  ΋+WڶE.Y`n`@5PmfM0;2vvgRSQi3ă<@4 &Ȭ\X 6XFkܛpޣRq8t~wi[ݼ~'/._!K}\#NZjg*אͿt׻W79ce ^}M ֫,]׹mjw_yMfmzB&/,iMGwTk4pѴn{ްg*=ll1R^Sksͻ4;FyDȝcyߏmFj k/vOO<-&#v<]_ՑGnn|ejr.js6=~kjx릌&WG}_kjK+Vȓ{=EXݦsKO?ت=Ӭs妡#I+[䢧5M7e d*2w%ׯ˳֭[vyw~m^5f; blKޥtz۱KG>o^kM1ԐEg \-?zLu1m~uISV0I+a& z[pr_~%s/>~X˯lv&NKPj12516ooTcΓx^Ti<{l@ 6@Yig'{y@Do4hP1mBIu$_m@=yW_]pw3FMxIoq4J5m.?v=YWF`2tݶX7 eFǼ]L#gz>Glcs˟-uWR~e.JzMͣhOkW Pz~k:mIw789C핂e?un1tTb _m"'sWv^JE lJH{3z^SχM[sL:oMK U@mZ_Q=5oy vFV U種p!M;^Zm6nZKm?/@*ؒNkȣsbvLx1'cOL3YCM]KAKZۢeb݉HUj[4Plc1ۤӃY?<`ow֋j}S߶YHJr_Xj)4YygƢx5Taۣ58Z;='FǫI-lt4\D.5TZC(=kcbjv /.k>}08#8퇭v Dkd]xe5Tuonǐa#ך/}VM{+7.VۛI @ S5TJp@& t{ciWamVTO.z:ܝ.*֪6!^,wri+XPş^<|-Ic:M5הJ@J2Emdo;Ў6l@oN䇦"IBOҎ#^@+/ z\5NHcZS^~`{jg}>?m?[|2T*3k λs__f`s>Pi*8oAd;TZ8Q^>o;6jm`qV޸_j.#W4n1 !/@@S0!l@`wY5~LW^h rdTjlqzq4ݞ7=V@mG ?W^{;Ю)}eƪS]FG8ma+3b$ۻgKҠ^jy)Q@UǼ/+/V1;va)^G)P<&mhh/ϿtTڞh}FF8G֒;lbӎlg,0$ J,rQ ـ> ge5liS? \ZPO~ִKz/Қ,o7`7]4AwTn>Tq<}hнqFO{o2T&nk?#EꓧNNH4)Q@Uv=r 0m|d̼V/\ӑl@[dMh>]Np>NGW}+қHJ2^ 5q󾃦m[BݣA>ӵ;3fl>zl;EH6=Ax^Px酒~Vkek#.&\延һHn״3cn߃/ҋ*ۻzԭ()} R=lY [`@D7~1Q9=Z f aS,xU*U {mnsltsqm; m@/lWoYk\f{\ϗx5@*=GEq>V E6fJg!ς tӳPh@9jWhZezOmn4ڝ}ն'GM@`H6RmdÏ ~9Ov]Gt./vJE t}BG/?NZ@UvTZ޾:g]vJzLu.=u|ycR){^{*=j}!V$ L*Zh׫/vMz~ۻxjTX.Ok*4w&8c]vmM>J~/tT\۾nv5|q 4QaDo7O<PΦrZ\].^{};ϵdh t4QYZD0**M"sȨMԔx߷4_7 )/wM"ɛ; w&Zn߱o^vAv}71m|KL RV <7=]t.ݯۜNgoY[o\ciptδu߷1cGdYtT.cA(WN7.M-ZƷfY"wW_KemֲۮF'VMMejb[nQG!&p/L79t߬qC:?B7y=L7.sOKf`We?03s?t"ӍrwӣE6(21O7C⫯cxO[o"-]ey%mFoet[VEǮ=K.Nޫe͛<8ݦet4dPU7KkeIZ6:7o,&81T*U؟[@M'4T8ŃMzyyD/%0}}1m].&RPyTͻh֭.αT;d%QRqTSX>&K>sR*U%7lSN>I&?@ y乥;TWT4d -2* S P=PVYQq]j/@2nD16>i}[.kD6j ;S'n: '{&J7t!S}wiߌ:P.TAnFv[oy;}sv=tT^{)j'֖ƎKPrGf{ߑ%]4ߑA4㏕ x^ g-YjK>;G}ݺw{wXPm@aҀ>35TK9Yʖ z1=rw]RwޕK/(#qhZ˦zLӺqq/5Љ1?Ë.U ݮC6W5˵F*Ѻt~GvPL' Vwن#?TvqLoBYM2e15sP؆zdy^ ?Sl #v#@VƛҦ}'o[<0AN:1~m^V !# @ h۟z??,7[8KB@R!@@U*$0޴b햶1^\O@D8 @b6b̘1G%7xh $@=;y׌ץvځE o:G@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@@(  X*+A  8 P9Q@@TV@@p r8   `9   (@@Fq@@@ PY r@@@Q       #@@@+@@e%@@@G*G0#  VJ#  T`G@@ G@m4vvaa}l%,RLb[z$C$%@A0 @HU'@ @@5 @I@J @ @ @ TIž @ r @$*I @ @`P@SN @$ P% { @ T`  @ $aO @AjL9 @@$  @ 0( P )'@ @@= @A0 @HU'@ @@5 @I@J @ @ @ TIž @ r @$*I @ @`PU\yX6c @TqӀu# @' P]J @<2-D'?}syӧKSc~6dɕ'Tw' @\I`4P!).1?Ϗg}Օ!@ @jը-D @c_ @ p5j"@ @ T/~ @ Tq_;_ڿ  @ !p T ?yu-?,Pu @ pս \ @@-P͟B.ˏb?ƟPG͋_^ @ pi{ TqSд~TT{/ @\C@5 @x Tzs};ʻ>F>l= @ |OjY*< @dU @#"Pˏ}ۛyBuqD @5k([ @G)pT{˿}-jו糾PiS~<VSQcO @K  @G+U=mE%P_eG/Ս @xeEN\ Tdǧ *|oj @ |p*8/*ߡ? @ pX T٧Pqt.GfOb1*l @ܷׯv;_Q8iuCl  @@oZzWǩ:f/.f @曉C1`ַbiS~<V TIƞ @K lj*T1A:jj @ jN~2=fN @xDp4MP"*tl @\COu!)?.ZU @xT=}w?^֖P @:P)WU@#ޞ @K @OZ/߼o)bή@y*>*tl @ܧ@-Pa*-TqٷtyP–'T!c#@ @5A @X T>JXi&Ob$p93 @.'*JySQԟ5UL2 I/#P @k*S~YׯI)PQ8r>[ TIÞ @K םPE<Vt/_ @\RoL?Q|@cU @TqyH*ϗz$aO @@?uGHߡCRc1a= @ THc-U*؟@u2'@ @ <@7B@ @\ZfO֞@=͟:kT+ @HCwT\,*桨zOBF @5z,髧 T1g*O= @_WO7wi T,ɏ˾ù@6 @#x4^Q@CS;yB;&@ @kj4PJkfi?ӧO'7 @[ 7^裛O?, @ p1_~ϟOZlyYLc#@ @߿?ŶX T1/??ݏB @t*}8PŠ* @[ ~c^Y{V֔m@Ka(c[)1O< U{I/ @l!aLm-j?+R95xJGSP  @L{ͯ:ySrLm U *P)kcx'iT6 @**FߎORyWM}Ϙ(,f*0Tjjm0&^Oۼfm_8_s?ּHtl˹k!@o32ZRh_~6 =QhO6S睎ʏEc{O95p9̋5nRZ_5~66 @KT}=cjZ}#iՔ)kSSo(絚Z[ϸ2T(lZ[ mO ƥKi4ޞ @+P c髍bVj5ka*&(Ǖ֤qo߾ٽ??cXl+5zj5 Uj}9GksK[ͳ> @/P W=2GOmfi{--Tgܩm@A<>ݾ4tUtܺoO @`k23f7־pzTony~nM*jom;\Ӿ6jOCZiѺT޹1  @, R}TwN_}z.UmzHEk\bV? Zk}=QuR~9O a Ԃ_mbjsa*uy;.{ TX{3n۹ӭ6HsYKWSsNmӳ @<,ڛ^=K5Va*uZ͹m TqA7m1tUmZRyMg8m @Fjbm|{2X< @7ז.]hjiCޟדzw  @,ϑkKy|̯=vN}Tq0}[4jk*߫vr_Zui|I4fi|Kk#@xpΕSTZC Sk T1.mcsFt}kYK+/O5jsi#@ +P (kc{,-bSjcbk],PńPkK[m|xnL CZ.՗/sN ^ޱ=u횾7LE}mͭۚ}>jokm1q}::jkj9SGjӘ~˹Zkh'@0jAaNF_pTX^}Bkm9jm9u8U]/Xl5  @\Z^0Tkm}{ UtBJ֚Vv3^[mS݂U̳` objects correspond to SimpliSafe™ locks (only available for V3 systems) and allow users to retrieve information on them and alter their state by locking/unlocking them. ## Core Properties All {meth}`Lock ` objects come with a standard set of properties: ```python for serial, lock in system.locks.items(): # Return the lock's name: lock.name # >>> Kitchen Window # Return the lock's serial number through the index: serial # >>> 1234ABCD # ...or through the property: lock.serial # >>> 1234ABCD # Return the state of the lock: lock.state # >>> simplipy.lock.LockStates.LOCKED # Return whether the lock is in an error state: lock.error # >>> False # Return whether the lock has a low battery: lock.low_battery # >>> False # Return whether the lock is offline: lock.offline # >>> False # Return a settings dictionary for the lock: lock.settings # >>> {"autoLock": 3, "away": 1, "home": 1} # Return whether the lock is disabled: lock.disabled # >>> False # Return whether the lock's battery is low: lock.lock_low_battery # >>> False # Return whether the pin pad's battery is low: lock.pin_pad_low_battery # >>> False # Return whether the pin pad is offline: lock.pin_pad_offline # >>> False ``` ## Locking/Unlocking Locking and unlocking a lock is accomplished via two coroutines: ```python for serial, lock in system.locks.items(): await lock.async_lock() await lock.async_unlock() ``` ## Updating the Lock To retrieve the sensor's latest state/properties/etc., simply: ```python await lock.async_update(cached=True) ``` simplisafe-python-2024.01.0/docs/make.bat000066400000000000000000000013701455300150500200270ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd simplisafe-python-2024.01.0/docs/sensor.md000066400000000000000000000040701455300150500202550ustar00rootroot00000000000000# Sensors Sensor objects provide information about the SimpliSafe™ sensors to which they relate. Like their {meth}`System ` cousins, two types of objects can be returned: - {meth}`SensorV2 `: an object to view V2 (classic) SimpliSafe™ sensors - {meth}`SensorV3 `: an object to view V3 (new, released in 2018\) SimpliSafe™ sensors Once again, `simplipy` provides a common interface to these objects; however, there are some properties that are either (a) specific to one version or (b) return a different meaning based on the version. These differences are outlined below. ## Core Properties All `Sensor` objects come with a standard set of properties ```python for serial, sensor in system.sensors.items(): # Return the sensor's name: sensor.name # >>> Kitchen Window # Return the sensor's serial number through the index: serial # >>> 1234ABCD # ...or through the property: sensor.serial # >>> 1234ABCD # Return the sensor's type: sensor.type # >>> simplipy.DeviceTypes.GLASS_BREAK # Return whether the sensor is in an error state: sensor.error # >>> False # Return whether the sensor has a low battery: sensor.low_battery # >>> False # Return whether the sensor has been triggered # (open/closed, etc.): sensor.triggered # >>> False ``` ## V2 Properties ```python # Return the sensor's data as a currently # non-understood integer: sensor.data # >>> 0 # Return the sensor's settings as a currently # non-understood integer: sensor.settings # >>> 1 ``` ## V3 Properties ```python # Return whether the sensor is offline: sensor.offline # >>> False # Return a settings dictionary for the sensor: sensor.settings # >>> {"instantTrigger": False, "away2": 1, "away": 1, ...} # For temperature sensors, return the current temperature: sensor.temperature # >>> 67 ``` ## Updating the Sensor To retrieve the sensor's latest state/properties/etc., simply: ```python await sensor.async_update(cached=True) ``` simplisafe-python-2024.01.0/docs/system.md000066400000000000000000000205301455300150500202670ustar00rootroot00000000000000# Systems {meth}`System ` objects are used to retrieve data on and control the state of SimpliSafe™ systems. Two types of objects can be returned: - {meth}`SystemV2 `: an object to control V2 (classic) SimpliSafe™ systems - {meth}`SystemV3 `: an object to control V3 (new, released in 2018) SimpliSafe™ systems Despite the differences, `simplipy` provides a common interface to these objects, meaning many of the same properties and methods are available to both. To get all SimpliSafe™ systems associated with an account: ```python import asyncio from aiohttp import ClientSession import simplipy async def main() -> None: """Create the aiohttp session and run.""" async with ClientSession() as session: api = await simplipy.API.async_from_auth( "", "", session=session, ) # Get a dict of systems with the system ID as the key: systems = await api.async_get_systems() # >>> {"1234abc": , ...} asyncio.run(main()) ``` ## Core Properties All {meth}`System ` objects come with a standard set of properties: ```python # Return the street address of the system: system.address # >>> 1234 Main Street # Return whether the alarm is currently going off: system.alarm_going_off # >>> False # Return the type of connection the system is using: system.connection_type # >>> "cell" # Return a list of active notifications: system.notifications # >>> [, ...] # Return a list of sensors attached to this system # (detailed later): system.sensors # >>> [, ...] # Return the system's serial number: system.serial # >>> xxxxxxxxxxxxxx # Return the current state of the system: system.state # >>> simplipy.system.SystemStates.AWAY # Return the SimpliSafe™ identifier for this system # from the key: system_id # >>> 1234abc # ...or as a property of the system itself: system.system_id # >>> 1234abc # Return the average of all temperature sensors # (if they exist): system.temperature # >>> 67 # Return the SimpliSafe™ version: system.version # >>> 2 ``` ## V3 Properties If a {meth}`System ` object should be a V3 system, it will automatically come with additional properties: ```python # Return the number of seconds an activated alarm # will sound for: system.alarm_duration # >>> 240 # Return the loudness of the alarm volume: system.alarm_volume # >>> 3 # Return the power rating of the battery backup: system.battery_backup_power_level # >>> 5239 # Return the number of seconds to delay when returning # to an "away" alarm: system.entry_delay_away # >>> 30 # Return the number of seconds to delay when returning # to an "home" alarm: system.entry_delay_home # >>> 30 # Return the number of seconds to delay when exiting # an "away" alarm: system.exit_delay_away # >>> 60 # Return the number of seconds to delay when exiting # an "home" alarm: system.exit_delay_home # >>> 0 # Return the signal strength of the cell antenna: system.gsm_strength # >>> -73 # Return whether the base station light is on: system.light # >>> True # Return any active system messages/notifications system.notifications # >>> [Message(...)] # Return whether the system is offline: system.offline # >>> False # Return whether the system is experiencing a power # outage: system.power_outage # >>> False # Return whether the base station is noticing RF jamming: system.rf_jamming # >>> False # Return the loudness of the voice prompt: system.voice_prompt_volume # >>> 2 # Return the power rating of the A/C outlet: system.wall_power_level # >>> 5239 # Return the ssid of the base station: system.wifi_ssid # >>> "My_SSID" # Return the signal strength of the wifi antenna: system.wifi_strength # >>> -43 ``` V3 systems also come with a {meth}`async_set_properties ` method to update the following system properties: - `alarm_duration` (in seconds): 30-480 - `alarm_volume`: Volume.OFF, Volume.LOW, Volume.MEDIUM, Volume.HIGH - `chime_volume`: Volume.OFF, Volume.LOW, Volume.MEDIUM, Volume.HIGH - `entry_delay_away` (in seconds): 30-255 - `entry_delay_home` (in seconds): 0-255 - `exit_delay_away` (in seconds): 45-255 - `exit_delay_home` (in seconds): 0-255 - `light`: True or False - `voice_prompt_volume`: Volume.OFF, Volume.LOW, Volume.MEDIUM, Volume.HIGH Note that the `simplipy.system.v3.Volume` enum class should be used for volume properties. ```python from simplipy.system.v3 import Volume await system.async_set_properties( { "alarm_duration": 240, "alarm_volume": Volume.HIGH, "chime_volume": Volume.MEDIUM, "entry_delay_away": 30, "entry_delay_home": 30, "exit_delay_away": 60, "exit_delay_home": 0, "light": True, "voice_prompt_volume": Volume.MEDIUM, } ) ``` Attempting to call these coroutines with a value beyond these limits will raise a {meth}`SimplipyError `. ## Updating the System Refreshing the {meth}`System ` object is done via the {meth}`update() ` coroutine: ```python await system.async_update() ``` Note that this method can be supplied with four optional parameters (all of which default to `True`): - `include_system`: update the system state and properties - `include_settings`: update system settings (like PINs) - `include_entities`: update all sensors/locks/etc. associated with a system - `cached`: use the last values provides by the base station For instance, if a user only wanted to update sensors and wanted to force a new data refresh: ```python await system.async_update(include_system=False, include_settings=False, cached=False) ``` There are two crucial differences between V2 and V3 systems when updating: - V2 systems, which use only 2G cell connectivity, will be slower to update than V3 systems when those V3 systems are connected to WiFi. - V2 systems will audibly announce, "Your settings have been synchronized." when the update completes; V3 systems will not. Unfortunately, this cannot currently be worked around. ## Arming/Disarming Arming the system in home/away mode and disarming the system are done via a set of three coroutines: ```python await system.async_set_away() await system.async_set_home() await system.async_set_off() ``` ## Events The {meth}`System ` object allows users to view events that have occurred with their system: ```python from datetime import datetime, timedelta yesterday = datetime.now() - timedelta(days=1) await system.async_get_events(from_timestamp=yesterday, num_events=2) # >>> [{"eventId": 123, ...}, {"eventId": 456, ...}] await system.async_get_latest_event() # >>> {"eventId": 987, ...} ``` ## System Notifications The `notifications` property of the {meth}`System ` object contains any active system notifications (in the form of {meth}`SystemNotification ` objects). Notifications remain within `system.notifications` until cleared, which can be accomplished by: 1. Manually clearing them in the SimpliSafe™ web and mobile applications 2. Using the {meth}`system.clear_notifications ` coroutine. ## PINs `simplipy` allows users to easily retrieve, set, reset, and remove PINs associated with a SimpliSafe™ account: ```python # Get all PINs (retrieving fresh or from the cache): await system.async_get_pins(cached=False) # >>> {"master": "1234", "duress": "9876"} # Set a new user PIN: await system.async_set_pin("My New User", "1122") await system.async_get_pins(cached=False) # >>> {"master": "1234", "duress": "9876", "My New User": "1122"} # Remove a PIN (by value or by label) await system.async_remove_pin("My New User") await system.async_get_pins(cached=False) # >>> {"master": "1234", "duress": "9876"} # Set the master PIN (works for the duress PIN, too): await system.async_set_pin("master", "9865") await system.async_get_pins(cached=False) # >>> {"master": "9865", "duress": "9876"} ``` Remember that with V2 systems, many operations – including setting PINs – will cause the base station to audibly announce "Your settings have been synchronized." simplisafe-python-2024.01.0/docs/usage.md000066400000000000000000000153311455300150500200520ustar00rootroot00000000000000# Usage ## Installation ```bash pip install simplisafe-python ``` ## Python Versions `simplisafe-python` is currently supported on: - Python 3.10 - Python 3.11 - Python 3.12 ## SimpliSafe™ Plans SimpliSafe™ offers several [monitoring plans][simplisafe-plans]. To date, `simplisafe-python` is known to work with all plans; if you should find differently, please consider submitting an [issue][simplisafe-python-issues]. ## Accessing the API Starting in 2021, SimpliSafe™ began to implement an OAuth-based form of authentication. To use this library, you must handshake with the SimpliSafe™ API; although this process cannot be fully accomplished programmatically, the procedure is relatively straightforward. ### Authentication `simplipy` comes with a helper script to get you started. To use it, follow these steps from a command line: 1. Clone the `simplipy` Git repo and `cd` into it: ```sh $ git clone https://github.com/bachya/simplisafe-python.git $ cd simplisafe-python/ ``` 2. Set up and activate a Python virtual environment: ```sh $ python3 -m virtualenv .venv $ source .venv/bin/activate ``` 3. Initialize the dev environment for `simplipy`: ```sh $ script/setup ``` 4. Run the `auth` script: ```sh $ script/auth ``` 5. Hit the Enter key to open a web browser to the SimpliSafe login page: ![The SimpliSafe™ login screen](images/ss-login-screen.png) 6. Once you enter your username/password and click "Continue", you will receive a two-factor authentication request. Depending on your account settings, this will arrive as either (1) an SMS text message or (2) an email. Follow the provided instructions regardless of which form you receive. Once you complete the verification, return to the browser and open its Dev Tools window. Look for an error (in either the Console or Network tab) that contains a URL starting with `com.simplisafe.mobile`: ``` com.simplisafe.mobile://auth.simplisafe.com/ios/com.simplisafe.mobile/callback?code= ``` ![The code in the Console Tab](images/ss-auth-code-in-console.png) ![The code in the Network Tab](images/ss-auth-code-in-network.png) **NOTE:** This process is very inconsistent with non-Chromium browsers (Chrome, Edge, Brave, etc.); if you are unsuccessful at finding the code, try a Chromium-based browser. **NOTE:** if you have already logged into SimpliSafe via the browser, you may be sent straight to the end of the process. This can present a challenge, since opening Dev Tools in that window won't show the previously logged activity. In this case, open a new tab, open its Dev Tools window, then copy/paste the URL from the tab opened by `script/auth` into the new tab to see the Console/Network output. 7. Copy the `code` parameter at the end of the `com.simplisafe.mobile` URL, return to your terminal, and paste it into the prompt. You should now see this message: ```sh You are now ready to use the SimpliSafe API! Authorization Code: Code Verifier: ``` These values can now be used to instantiate an {meth}`API ` object. Remember that this Authorization Code and Code Verifier pair (a) can only be used once and (b) will expire after a relatively short amount of time. ### Creating an API Object Once you have an Authorization Code and Code Verifier, you can create an API object like this: ```python import asyncio from aiohttp import ClientSession from simplipy import API async def main() -> None: """Create the aiohttp session and run.""" async with ClientSession() as session: simplisafe = await API.async_from_auth( "", "", session=session, ) # ... asyncio.run(main()) ``` ### Key API Object Properties The {meth}`API ` object contains several sensitive properties to be aware of: ```python # Return the current access token: api.access_token # >>> 7s9yasdh9aeu21211add # Return the current refresh token: api.refresh_token # >>> 896sad86gudas87d6asd # Return the SimpliSafe™ user ID associated with this account: api.user_id # >>> 1234567 ``` Remember three essential characteristics of refresh tokens: 1. Refresh tokens can only be used once. 2. SimpliSafe™ will invalidate active tokens if you change your password. 3. Given the unofficial nature of the SimpliSafe™ API, we do not know how long refresh tokens are valid – we assume they'll last indefinitely, but that information may change. ### Creating a New API Object with the Refresh Token It is cumbersome to call {meth}`API.async_from_auth ` every time you want a new {meth}`API ` object. Therefore, _after_ initial authentication, call {meth}`API.async_from_refresh_token `, passing the {meth}`refresh_token ` from the previous {meth}`API ` object. A common practice is to save a valid refresh token to a filesystem/database/etc. and retrieve it later. ```python import asyncio from aiohttp import ClientSession import simplipy async def async_get_refresh_token() -> str: """Get a refresh token from storage.""" # ... async def main() -> None: """Create the aiohttp session and run.""" async with ClientSession() as session: refresh_token = await async_get_refresh_token() api = await simplipy.API.async_from_refresh_token( refresh_token, session=session ) # ... asyncio.run(main()) ``` After a new {meth}`API ` object is created via {meth}`API.async_from_refresh_token `, it comes with its own, new refresh token; this can be used to follow the same re-authentication process as often as needed. ### Refreshing an Access Token During Runtime In general, you do not need to worry about refreshing the access token within an {meth}`API ` object's normal operations; if an {meth}`API ` object encounters an error that indicates an expired access token, it will automatically attempt to use the refresh token it has. However, should you need to refresh an access token manually at runtime, you can use the {meth}`async_refresh_access_token ` method. ### A VERY IMPORTANT NOTE ABOUT TOKENS **It is vitally important not to let these tokens leave your control.** If exposed, savvy attackers could use them to view and alter your system's state. **You have been warned; proper storage/usage of tokens is solely your responsibility.** [simplisafe-plans]: https://support.simplisafe.com/hc/en-us/articles/360023809972-What-are-the-service-plan-options- [simplisafe-python-issues]: https://github.com/bachya/simplisafe-python/issues simplisafe-python-2024.01.0/docs/websocket.md000066400000000000000000000107451455300150500207400ustar00rootroot00000000000000# Websocket `simplipy` provides a websocket that allows for near-real-time detection of certain events from a user's SimpliSafe™ system. This websocket can be accessed via the `websocket` property of the {meth}`API ` object: ```python api.websocket # >>> ``` ## Connecting ```python await api.websocket.async_connect() ``` Then, once you are connected to the websocket, you can start listening for events: ```python await api.websocket.async_listen() ``` ## Disconnecting ```python await api.websocket.async_disconnect() ``` ## Responding to Events Users respond to events by defining callbacks (synchronous functions _or_ coroutines). The following events exist: - `connect`: occurs when the websocket connection is established - `disconnect`: occurs when the websocket connection is terminated - `event`: occurs when any data is transmitted from the SimpliSafe™ cloud Note that you can register as many callbacks as you'd like. ### `connect` ```python async def async_connect_handler(): await asyncio.sleep(1) print("I connected to the websocket") def connect_handler(): print("I connected to the websocket") remove_1 = api.websocket.add_connect_callback(async_connect_handler) remove_2 = api.websocket.add_connect_callback(connect_handler) # remove_1 and remove_2 are functions that, when called, remove the callback. ``` ### `disconnect` ```python async def async_connect_handler(): await asyncio.sleep(1) print("I disconnected from the websocket") def connect_handler(): print("I disconnected from the websocket") remove_1 = api.websocket.add_disconnect_callback(async_connect_handler) remove_2 = api.websocket.add_disconnect_callback(connect_handler) # remove_1 and remove_2 are functions that, when called, remove the callback. ``` ### `event` ```python async def async_connect_handler(event): await asyncio.sleep(1) print(f"I received a SimpliSafe™ event: {event}") def connect_handler(): print(f"I received a SimpliSafe™ event: {event}") remove_1 = api.websocket.add_event_callback(async_connect_handler) remove_2 = api.websocket.add_event_callback(connect_handler) # remove_1 and remove_2 are functions that, when called, remove the callback. ``` #### Response Format The `event` argument provided to event callbacks is a {meth}`simplipy.websocket.WebsocketEvent` object, which comes with several properties: - `changed_by`: the PIN that caused the event (in the case of arming/disarming/etc.) - `event_type`: the type of event (see below) - `info`: a longer string describing the event - `sensor_name`: the name of the entity that triggered the event - `sensor_serial`: the serial number of the entity that triggered the event - `sensor_type`: the type of the entity that triggered the event - `system_id`: the SimpliSafe™ system ID - `timestamp`: the UTC timestamp that the event occurred - `media_urls`: a dict containing media URLs if the `event_type` is "camera_motion_detected" (see below) The `event_type` property will be one of the following values: - `alarm_canceled` - `alarm_triggered` - `armed_away_by_keypad` - `armed_away_by_remote` - `armed_away` - `armed_home` - `automatic_test` - `away_exit_delay_by_keypad` - `away_exit_delay_by_remote` - `camera_motion_detected` - `connection_lost` - `connection_restored` - `disarmed_by_keypad` - `disarmed_by_remote` - `doorbell_detected` - `entity_test` - `entry_detected` - `home_exit_delay` - `lock_error` - `lock_locked` - `lock_unlocked` - `motion_detected` - `power_outage` - `power_restored` - `sensor_not_responding` - `sensor_paired_and_named` - `sensor_restored` - `user_initiated_test` If the `event_type` is `camera_motion_detected`, then the `event` attribute `media_urls` will be a dictionary that looks like this: ```python { "image_url": "https://xxx.us-east-1.prd.cam.simplisafe.com/xxx", "clip_url": "https://xxx.us-east-1.prd.cam.simplisafe.com/xxx", } ``` The `image_url` is an absolute URL to a JPEG file. The `clip_url` is an absolute URL to a short MPEG4 video clip. Both refer to the motion detected by the camera. You can retrieve the raw bytes of the media files at these URLs with the following method: ```python bytes = await api.async_media(url) ``` If the `event_type` is not `camera_motion_detected`, then `media_urls` will be set to None. If you should come across an event type that the library does not know about (and see a log message about it), please open an issue at . simplisafe-python-2024.01.0/examples/000077500000000000000000000000001455300150500173075ustar00rootroot00000000000000simplisafe-python-2024.01.0/examples/__init__.py000066400000000000000000000000271455300150500214170ustar00rootroot00000000000000"""Define examples.""" simplisafe-python-2024.01.0/examples/test_client_by_auth.py000066400000000000000000000033541455300150500237160ustar00rootroot00000000000000"""Test system functionality with an Auth0 code/verifier.""" import asyncio import logging import os from aiohttp import ClientSession from simplipy import API from simplipy.errors import SimplipyError _LOGGER = logging.getLogger() SIMPLISAFE_AUTHORIZATION_CODE = os.getenv("SIMPLISAFE_AUTHORIZATION_CODE", "") SIMPLISAFE_CODE_VERIFIER = os.getenv("SIMPLISAFE_CODE_VERIFIER") async def main() -> None: """Create the aiohttp session and run the example.""" async with ClientSession() as session: logging.basicConfig(level=logging.INFO) if not SIMPLISAFE_AUTHORIZATION_CODE or not SIMPLISAFE_CODE_VERIFIER: _LOGGER.error("Missing authentication info") return try: simplisafe = await API.async_from_auth( SIMPLISAFE_AUTHORIZATION_CODE, SIMPLISAFE_CODE_VERIFIER, session=session, ) systems = await simplisafe.async_get_systems() for system in systems.values(): # Print system state: _LOGGER.info("System state: %s", system.state) # Print sensor info: for serial, sensor in system.sensors.items(): _LOGGER.info( "Sensor %s: (name: %s, type: %s, triggered: %s)", serial, sensor.name, sensor.type, sensor.triggered, ) # Arm/disarm the system: # await system.async_set_away() # await system.async_set_home() # await system.async_set_off() except SimplipyError as err: _LOGGER.error(err) asyncio.run(main()) simplisafe-python-2024.01.0/examples/test_client_by_refresh_token.py000066400000000000000000000030771455300150500256150ustar00rootroot00000000000000"""Test system functionality with an Auth0 code/verifier.""" import asyncio import logging import os from aiohttp import ClientSession from simplipy import API from simplipy.errors import SimplipyError _LOGGER = logging.getLogger() SIMPLISAFE_REFRESH_TOKEN = os.getenv("SIMPLISAFE_REFRESH_TOKEN", "") async def main() -> None: """Create the aiohttp session and run the example.""" async with ClientSession() as session: logging.basicConfig(level=logging.INFO) if not SIMPLISAFE_REFRESH_TOKEN: _LOGGER.error("Missing refresh token") return try: simplisafe = await API.async_from_refresh_token( SIMPLISAFE_REFRESH_TOKEN, session=session ) systems = await simplisafe.async_get_systems() for system in systems.values(): # Print system state: _LOGGER.info("System state: %s", system.state) # Print sensor info: for serial, sensor in system.sensors.items(): _LOGGER.info( "Sensor %s: (name: %s, type: %s, triggered: %s)", serial, sensor.name, sensor.type, sensor.triggered, ) # Arm/disarm the system: # await system.async_set_away() # await system.async_set_home() # await system.async_set_off() except SimplipyError as err: _LOGGER.error(err) asyncio.run(main()) simplisafe-python-2024.01.0/examples/test_websocket.py000066400000000000000000000025021455300150500227050ustar00rootroot00000000000000"""Test system functionality with an Auth0 code/verifier.""" import asyncio import logging import os from aiohttp import ClientSession from simplipy import API from simplipy.errors import CannotConnectError, SimplipyError _LOGGER = logging.getLogger() SIMPLISAFE_REFRESH_TOKEN = os.getenv("SIMPLISAFE_REFRESH_TOKEN", "") async def main() -> None: """Create the aiohttp session and run the example.""" async with ClientSession() as session: logging.basicConfig(level=logging.DEBUG) if not SIMPLISAFE_REFRESH_TOKEN: _LOGGER.error( "You must specify a SIMPLISAFE_REFRESH_TOKEN in the environment." ) return try: simplisafe = await API.async_from_refresh_token( SIMPLISAFE_REFRESH_TOKEN, session=session ) if simplisafe.websocket: try: await simplisafe.websocket.async_connect() except CannotConnectError as err: _LOGGER.error( "There was a error while connecting to the server: %s", err ) await simplisafe.websocket.async_listen() except SimplipyError as err: _LOGGER.error(err) except KeyboardInterrupt: pass asyncio.run(main()) simplisafe-python-2024.01.0/poetry.lock000066400000000000000000004624411455300150500177000ustar00rootroot00000000000000# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "aiohttp" version = "3.9.1" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" files = [ {file = "aiohttp-3.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1f80197f8b0b846a8d5cf7b7ec6084493950d0882cc5537fb7b96a69e3c8590"}, {file = "aiohttp-3.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c72444d17777865734aa1a4d167794c34b63e5883abb90356a0364a28904e6c0"}, {file = "aiohttp-3.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b05d5cbe9dafcdc733262c3a99ccf63d2f7ce02543620d2bd8db4d4f7a22f83"}, {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c4fa235d534b3547184831c624c0b7c1e262cd1de847d95085ec94c16fddcd5"}, {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:289ba9ae8e88d0ba16062ecf02dd730b34186ea3b1e7489046fc338bdc3361c4"}, {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bff7e2811814fa2271be95ab6e84c9436d027a0e59665de60edf44e529a42c1f"}, {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81b77f868814346662c96ab36b875d7814ebf82340d3284a31681085c051320f"}, {file = "aiohttp-3.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b9c7426923bb7bd66d409da46c41e3fb40f5caf679da624439b9eba92043fa6"}, {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8d44e7bf06b0c0a70a20f9100af9fcfd7f6d9d3913e37754c12d424179b4e48f"}, {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22698f01ff5653fe66d16ffb7658f582a0ac084d7da1323e39fd9eab326a1f26"}, {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ca7ca5abfbfe8d39e653870fbe8d7710be7a857f8a8386fc9de1aae2e02ce7e4"}, {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8d7f98fde213f74561be1d6d3fa353656197f75d4edfbb3d94c9eb9b0fc47f5d"}, {file = "aiohttp-3.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5216b6082c624b55cfe79af5d538e499cd5f5b976820eac31951fb4325974501"}, {file = "aiohttp-3.9.1-cp310-cp310-win32.whl", hash = "sha256:0e7ba7ff228c0d9a2cd66194e90f2bca6e0abca810b786901a569c0de082f489"}, {file = "aiohttp-3.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:c7e939f1ae428a86e4abbb9a7c4732bf4706048818dfd979e5e2839ce0159f23"}, {file = "aiohttp-3.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:df9cf74b9bc03d586fc53ba470828d7b77ce51b0582d1d0b5b2fb673c0baa32d"}, {file = "aiohttp-3.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ecca113f19d5e74048c001934045a2b9368d77b0b17691d905af18bd1c21275e"}, {file = "aiohttp-3.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8cef8710fb849d97c533f259103f09bac167a008d7131d7b2b0e3a33269185c0"}, {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bea94403a21eb94c93386d559bce297381609153e418a3ffc7d6bf772f59cc35"}, {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91c742ca59045dce7ba76cab6e223e41d2c70d79e82c284a96411f8645e2afff"}, {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c93b7c2e52061f0925c3382d5cb8980e40f91c989563d3d32ca280069fd6a87"}, {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee2527134f95e106cc1653e9ac78846f3a2ec1004cf20ef4e02038035a74544d"}, {file = "aiohttp-3.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11ff168d752cb41e8492817e10fb4f85828f6a0142b9726a30c27c35a1835f01"}, {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b8c3a67eb87394386847d188996920f33b01b32155f0a94f36ca0e0c635bf3e3"}, {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c7b5d5d64e2a14e35a9240b33b89389e0035e6de8dbb7ffa50d10d8b65c57449"}, {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:69985d50a2b6f709412d944ffb2e97d0be154ea90600b7a921f95a87d6f108a2"}, {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:c9110c06eaaac7e1f5562caf481f18ccf8f6fdf4c3323feab28a93d34cc646bd"}, {file = "aiohttp-3.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d737e69d193dac7296365a6dcb73bbbf53bb760ab25a3727716bbd42022e8d7a"}, {file = "aiohttp-3.9.1-cp311-cp311-win32.whl", hash = "sha256:4ee8caa925aebc1e64e98432d78ea8de67b2272252b0a931d2ac3bd876ad5544"}, {file = "aiohttp-3.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:a34086c5cc285be878622e0a6ab897a986a6e8bf5b67ecb377015f06ed316587"}, {file = "aiohttp-3.9.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f800164276eec54e0af5c99feb9494c295118fc10a11b997bbb1348ba1a52065"}, {file = "aiohttp-3.9.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:500f1c59906cd142d452074f3811614be04819a38ae2b3239a48b82649c08821"}, {file = "aiohttp-3.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0b0a6a36ed7e164c6df1e18ee47afbd1990ce47cb428739d6c99aaabfaf1b3af"}, {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69da0f3ed3496808e8cbc5123a866c41c12c15baaaead96d256477edf168eb57"}, {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:176df045597e674fa950bf5ae536be85699e04cea68fa3a616cf75e413737eb5"}, {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b796b44111f0cab6bbf66214186e44734b5baab949cb5fb56154142a92989aeb"}, {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f27fdaadce22f2ef950fc10dcdf8048407c3b42b73779e48a4e76b3c35bca26c"}, {file = "aiohttp-3.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcb6532b9814ea7c5a6a3299747c49de30e84472fa72821b07f5a9818bce0f66"}, {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:54631fb69a6e44b2ba522f7c22a6fb2667a02fd97d636048478db2fd8c4e98fe"}, {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4b4c452d0190c5a820d3f5c0f3cd8a28ace48c54053e24da9d6041bf81113183"}, {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:cae4c0c2ca800c793cae07ef3d40794625471040a87e1ba392039639ad61ab5b"}, {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:565760d6812b8d78d416c3c7cfdf5362fbe0d0d25b82fed75d0d29e18d7fc30f"}, {file = "aiohttp-3.9.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:54311eb54f3a0c45efb9ed0d0a8f43d1bc6060d773f6973efd90037a51cd0a3f"}, {file = "aiohttp-3.9.1-cp312-cp312-win32.whl", hash = "sha256:85c3e3c9cb1d480e0b9a64c658cd66b3cfb8e721636ab8b0e746e2d79a7a9eed"}, {file = "aiohttp-3.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:11cb254e397a82efb1805d12561e80124928e04e9c4483587ce7390b3866d213"}, {file = "aiohttp-3.9.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8a22a34bc594d9d24621091d1b91511001a7eea91d6652ea495ce06e27381f70"}, {file = "aiohttp-3.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:598db66eaf2e04aa0c8900a63b0101fdc5e6b8a7ddd805c56d86efb54eb66672"}, {file = "aiohttp-3.9.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c9376e2b09895c8ca8b95362283365eb5c03bdc8428ade80a864160605715f1"}, {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41473de252e1797c2d2293804e389a6d6986ef37cbb4a25208de537ae32141dd"}, {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c5857612c9813796960c00767645cb5da815af16dafb32d70c72a8390bbf690"}, {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffcd828e37dc219a72c9012ec44ad2e7e3066bec6ff3aaa19e7d435dbf4032ca"}, {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:219a16763dc0294842188ac8a12262b5671817042b35d45e44fd0a697d8c8361"}, {file = "aiohttp-3.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f694dc8a6a3112059258a725a4ebe9acac5fe62f11c77ac4dcf896edfa78ca28"}, {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bcc0ea8d5b74a41b621ad4a13d96c36079c81628ccc0b30cfb1603e3dfa3a014"}, {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:90ec72d231169b4b8d6085be13023ece8fa9b1bb495e4398d847e25218e0f431"}, {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:cf2a0ac0615842b849f40c4d7f304986a242f1e68286dbf3bd7a835e4f83acfd"}, {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:0e49b08eafa4f5707ecfb321ab9592717a319e37938e301d462f79b4e860c32a"}, {file = "aiohttp-3.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2c59e0076ea31c08553e868cec02d22191c086f00b44610f8ab7363a11a5d9d8"}, {file = "aiohttp-3.9.1-cp38-cp38-win32.whl", hash = "sha256:4831df72b053b1eed31eb00a2e1aff6896fb4485301d4ccb208cac264b648db4"}, {file = "aiohttp-3.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:3135713c5562731ee18f58d3ad1bf41e1d8883eb68b363f2ffde5b2ea4b84cc7"}, {file = "aiohttp-3.9.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cfeadf42840c1e870dc2042a232a8748e75a36b52d78968cda6736de55582766"}, {file = "aiohttp-3.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:70907533db712f7aa791effb38efa96f044ce3d4e850e2d7691abd759f4f0ae0"}, {file = "aiohttp-3.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdefe289681507187e375a5064c7599f52c40343a8701761c802c1853a504558"}, {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7481f581251bb5558ba9f635db70908819caa221fc79ee52a7f58392778c636"}, {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49f0c1b3c2842556e5de35f122fc0f0b721334ceb6e78c3719693364d4af8499"}, {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d406b01a9f5a7e232d1b0d161b40c05275ffbcbd772dc18c1d5a570961a1ca4"}, {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d8e4450e7fe24d86e86b23cc209e0023177b6d59502e33807b732d2deb6975f"}, {file = "aiohttp-3.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c0266cd6f005e99f3f51e583012de2778e65af6b73860038b968a0a8888487a"}, {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab221850108a4a063c5b8a70f00dd7a1975e5a1713f87f4ab26a46e5feac5a0e"}, {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c88a15f272a0ad3d7773cf3a37cc7b7d077cbfc8e331675cf1346e849d97a4e5"}, {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:237533179d9747080bcaad4d02083ce295c0d2eab3e9e8ce103411a4312991a0"}, {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:02ab6006ec3c3463b528374c4cdce86434e7b89ad355e7bf29e2f16b46c7dd6f"}, {file = "aiohttp-3.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04fa38875e53eb7e354ece1607b1d2fdee2d175ea4e4d745f6ec9f751fe20c7c"}, {file = "aiohttp-3.9.1-cp39-cp39-win32.whl", hash = "sha256:82eefaf1a996060602f3cc1112d93ba8b201dbf5d8fd9611227de2003dddb3b7"}, {file = "aiohttp-3.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:9b05d33ff8e6b269e30a7957bd3244ffbce2a7a35a81b81c382629b80af1a8bf"}, {file = "aiohttp-3.9.1.tar.gz", hash = "sha256:8fc49a87ac269d4529da45871e2ffb6874e87779c3d0e2ccd813c0899221239d"}, ] [package.dependencies] aiosignal = ">=1.1.2" async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} attrs = ">=17.3.0" frozenlist = ">=1.1.1" multidict = ">=4.5,<7.0" yarl = ">=1.0,<2.0" [package.extras] speedups = ["Brotli", "aiodns", "brotlicffi"] [[package]] name = "aiosignal" version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.7" files = [ {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, ] [package.dependencies] frozenlist = ">=1.1.0" [[package]] name = "alabaster" version = "0.7.13" description = "A configurable sidebar-enabled Sphinx theme" optional = false python-versions = ">=3.6" files = [ {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, ] [[package]] name = "aresponses" version = "3.0.0" description = "Asyncio response mocking. Similar to the responses library used for 'requests'" optional = false python-versions = ">=3.7" files = [ {file = "aresponses-3.0.0-py3-none-any.whl", hash = "sha256:8093ab4758eb4aba91c765a50295b269ecfc0a9e7c7158954760bc0c23503970"}, {file = "aresponses-3.0.0.tar.gz", hash = "sha256:8731d0609fe4c954e21f17753dc868dca9e2e002b020a33dc9212004599b11e7"}, ] [package.dependencies] aiohttp = [ {version = ">=3.7.0", markers = "python_version >= \"3.10\" and python_version < \"3.12\""}, {version = ">=3.7.0,<3.8.dev0 || >=3.9.dev0", markers = "python_version >= \"3.12\""}, ] pytest-asyncio = {version = ">=0.17.0", markers = "python_version >= \"3.7\""} [[package]] name = "astroid" version = "3.0.1" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.8.0" files = [ {file = "astroid-3.0.1-py3-none-any.whl", hash = "sha256:7d5895c9825e18079c5aeac0572bc2e4c83205c95d416e0b4fee8bc361d2d9ca"}, {file = "astroid-3.0.1.tar.gz", hash = "sha256:86b0bb7d7da0be1a7c4aedb7974e391b32d4ed89e33de6ed6902b4b15c97577e"}, ] [package.dependencies] typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} [[package]] name = "async-timeout" version = "4.0.3" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.7" files = [ {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, ] [[package]] name = "asynctest" version = "0.13.0" description = "Enhance the standard unittest package with features for testing asyncio libraries" optional = false python-versions = ">=3.5" files = [ {file = "asynctest-0.13.0-py3-none-any.whl", hash = "sha256:5da6118a7e6d6b54d83a8f7197769d046922a44d2a99c21382f0a6e4fadae676"}, {file = "asynctest-0.13.0.tar.gz", hash = "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac"}, ] [[package]] name = "attrs" version = "23.1.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.7" files = [ {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, ] [package.extras] cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] dev = ["attrs[docs,tests]", "pre-commit"] docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] tests = ["attrs[tests-no-zope]", "zope-interface"] tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] [[package]] name = "babel" version = "2.12.1" description = "Internationalization utilities" optional = false python-versions = ">=3.7" files = [ {file = "Babel-2.12.1-py3-none-any.whl", hash = "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610"}, {file = "Babel-2.12.1.tar.gz", hash = "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455"}, ] [[package]] name = "backoff" version = "2.2.1" description = "Function decoration for backoff and retry" optional = false python-versions = ">=3.7,<4.0" files = [ {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, ] [[package]] name = "black" version = "23.11.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ {file = "black-23.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dbea0bb8575c6b6303cc65017b46351dc5953eea5c0a59d7b7e3a2d2f433a911"}, {file = "black-23.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:412f56bab20ac85927f3a959230331de5614aecda1ede14b373083f62ec24e6f"}, {file = "black-23.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d136ef5b418c81660ad847efe0e55c58c8208b77a57a28a503a5f345ccf01394"}, {file = "black-23.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:6c1cac07e64433f646a9a838cdc00c9768b3c362805afc3fce341af0e6a9ae9f"}, {file = "black-23.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479"}, {file = "black-23.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244"}, {file = "black-23.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221"}, {file = "black-23.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5"}, {file = "black-23.11.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:45aa1d4675964946e53ab81aeec7a37613c1cb71647b5394779e6efb79d6d187"}, {file = "black-23.11.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c44b7211a3a0570cc097e81135faa5f261264f4dfaa22bd5ee2875a4e773bd6"}, {file = "black-23.11.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a9acad1451632021ee0d146c8765782a0c3846e0e0ea46659d7c4f89d9b212b"}, {file = "black-23.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:fc7f6a44d52747e65a02558e1d807c82df1d66ffa80a601862040a43ec2e3142"}, {file = "black-23.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7f622b6822f02bfaf2a5cd31fdb7cd86fcf33dab6ced5185c35f5db98260b055"}, {file = "black-23.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:250d7e60f323fcfc8ea6c800d5eba12f7967400eb6c2d21ae85ad31c204fb1f4"}, {file = "black-23.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5133f5507007ba08d8b7b263c7aa0f931af5ba88a29beacc4b2dc23fcefe9c06"}, {file = "black-23.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:421f3e44aa67138ab1b9bfbc22ee3780b22fa5b291e4db8ab7eee95200726b07"}, {file = "black-23.11.0-py3-none-any.whl", hash = "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e"}, {file = "black-23.11.0.tar.gz", hash = "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05"}, ] [package.dependencies] click = ">=8.0.0" mypy-extensions = ">=0.4.3" packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] d = ["aiohttp (>=3.7.4)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "blacken-docs" version = "1.16.0" description = "Run Black on Python code blocks in documentation files." optional = false python-versions = ">=3.8" files = [ {file = "blacken_docs-1.16.0-py3-none-any.whl", hash = "sha256:b0dcb84b28ebfb352a2539202d396f50e15a54211e204a8005798f1d1edb7df8"}, {file = "blacken_docs-1.16.0.tar.gz", hash = "sha256:b4bdc3f3d73898dfbf0166f292c6ccfe343e65fc22ddef5319c95d1a8dcc6c1c"}, ] [package.dependencies] black = ">=22.1.0" [[package]] name = "certifi" version = "2023.11.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, ] [[package]] name = "cfgv" version = "3.4.0" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.8" files = [ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, ] [[package]] name = "charset-normalizer" version = "3.2.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710"}, {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed"}, {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9"}, {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623"}, {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a"}, {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8"}, {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad"}, {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c"}, {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3"}, {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029"}, {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f"}, {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a"}, {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd"}, {file = "charset_normalizer-3.2.0-cp310-cp310-win32.whl", hash = "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96"}, {file = "charset_normalizer-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea"}, {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"}, {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"}, {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"}, {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"}, {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"}, {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"}, {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"}, {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"}, {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"}, {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"}, {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"}, {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"}, {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"}, {file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"}, {file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"}, {file = "charset_normalizer-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346"}, {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982"}, {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c"}, {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4"}, {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449"}, {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3"}, {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a"}, {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7"}, {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd"}, {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3"}, {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592"}, {file = "charset_normalizer-3.2.0-cp37-cp37m-win32.whl", hash = "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1"}, {file = "charset_normalizer-3.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959"}, {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669"}, {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329"}, {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149"}, {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94"}, {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f"}, {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa"}, {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a"}, {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037"}, {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46"}, {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2"}, {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d"}, {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c"}, {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10"}, {file = "charset_normalizer-3.2.0-cp38-cp38-win32.whl", hash = "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706"}, {file = "charset_normalizer-3.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e"}, {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c"}, {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f"}, {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858"}, {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5"}, {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952"}, {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4"}, {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200"}, {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252"}, {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22"}, {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c"}, {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e"}, {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299"}, {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020"}, {file = "charset_normalizer-3.2.0-cp39-cp39-win32.whl", hash = "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9"}, {file = "charset_normalizer-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80"}, {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, ] [[package]] name = "click" version = "8.1.7" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" files = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, ] [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} [[package]] name = "codespell" version = "2.2.6" description = "Codespell" optional = false python-versions = ">=3.8" files = [ {file = "codespell-2.2.6-py3-none-any.whl", hash = "sha256:9ee9a3e5df0990604013ac2a9f22fa8e57669c827124a2e961fe8a1da4cacc07"}, {file = "codespell-2.2.6.tar.gz", hash = "sha256:a8c65d8eb3faa03deabab6b3bbe798bea72e1799c7e9e955d57eca4096abcff9"}, ] [package.extras] dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"] hard-encoding-detection = ["chardet"] toml = ["tomli"] types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"] [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] [[package]] name = "coverage" version = "7.4.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ {file = "coverage-7.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a"}, {file = "coverage-7.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471"}, {file = "coverage-7.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9"}, {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516"}, {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5"}, {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566"}, {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae"}, {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43"}, {file = "coverage-7.4.0-cp310-cp310-win32.whl", hash = "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451"}, {file = "coverage-7.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137"}, {file = "coverage-7.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca"}, {file = "coverage-7.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06"}, {file = "coverage-7.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505"}, {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc"}, {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25"}, {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70"}, {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09"}, {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26"}, {file = "coverage-7.4.0-cp311-cp311-win32.whl", hash = "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614"}, {file = "coverage-7.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590"}, {file = "coverage-7.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143"}, {file = "coverage-7.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2"}, {file = "coverage-7.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a"}, {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446"}, {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9"}, {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd"}, {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a"}, {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa"}, {file = "coverage-7.4.0-cp312-cp312-win32.whl", hash = "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450"}, {file = "coverage-7.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0"}, {file = "coverage-7.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e"}, {file = "coverage-7.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85"}, {file = "coverage-7.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac"}, {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1"}, {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba"}, {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952"}, {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e"}, {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105"}, {file = "coverage-7.4.0-cp38-cp38-win32.whl", hash = "sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2"}, {file = "coverage-7.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555"}, {file = "coverage-7.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42"}, {file = "coverage-7.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7"}, {file = "coverage-7.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9"}, {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed"}, {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c"}, {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870"}, {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058"}, {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f"}, {file = "coverage-7.4.0-cp39-cp39-win32.whl", hash = "sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932"}, {file = "coverage-7.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e"}, {file = "coverage-7.4.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6"}, {file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"}, ] [package.dependencies] tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] toml = ["tomli"] [[package]] name = "darglint" version = "1.8.1" description = "A utility for ensuring Google-style docstrings stay up to date with the source code." optional = false python-versions = ">=3.6,<4.0" files = [ {file = "darglint-1.8.1-py3-none-any.whl", hash = "sha256:5ae11c259c17b0701618a20c3da343a3eb98b3bc4b5a83d31cdd94f5ebdced8d"}, {file = "darglint-1.8.1.tar.gz", hash = "sha256:080d5106df149b199822e7ee7deb9c012b49891538f14a11be681044f0bb20da"}, ] [[package]] name = "dill" version = "0.3.7" description = "serialize all of Python" optional = false python-versions = ">=3.7" files = [ {file = "dill-0.3.7-py3-none-any.whl", hash = "sha256:76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e"}, {file = "dill-0.3.7.tar.gz", hash = "sha256:cc1c8b182eb3013e24bd475ff2e9295af86c1a38eb1aff128dac8962a9ce3c03"}, ] [package.extras] graph = ["objgraph (>=1.7.2)"] [[package]] name = "distlib" version = "0.3.7" description = "Distribution utilities" optional = false python-versions = "*" files = [ {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, ] [[package]] name = "docutils" version = "0.18.1" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ {file = "docutils-0.18.1-py2.py3-none-any.whl", hash = "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c"}, {file = "docutils-0.18.1.tar.gz", hash = "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"}, ] [[package]] name = "exceptiongroup" version = "1.1.3" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, ] [package.extras] test = ["pytest (>=6)"] [[package]] name = "filelock" version = "3.12.2" description = "A platform independent file lock." optional = false python-versions = ">=3.7" files = [ {file = "filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec"}, {file = "filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81"}, ] [package.extras] docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] [[package]] name = "frozenlist" version = "1.4.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.8" files = [ {file = "frozenlist-1.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:764226ceef3125e53ea2cb275000e309c0aa5464d43bd72abd661e27fffc26ab"}, {file = "frozenlist-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d6484756b12f40003c6128bfcc3fa9f0d49a687e171186c2d85ec82e3758c559"}, {file = "frozenlist-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9ac08e601308e41eb533f232dbf6b7e4cea762f9f84f6357136eed926c15d12c"}, {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d081f13b095d74b67d550de04df1c756831f3b83dc9881c38985834387487f1b"}, {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71932b597f9895f011f47f17d6428252fc728ba2ae6024e13c3398a087c2cdea"}, {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:981b9ab5a0a3178ff413bca62526bb784249421c24ad7381e39d67981be2c326"}, {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e41f3de4df3e80de75845d3e743b3f1c4c8613c3997a912dbf0229fc61a8b963"}, {file = "frozenlist-1.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6918d49b1f90821e93069682c06ffde41829c346c66b721e65a5c62b4bab0300"}, {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0e5c8764c7829343d919cc2dfc587a8db01c4f70a4ebbc49abde5d4b158b007b"}, {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8d0edd6b1c7fb94922bf569c9b092ee187a83f03fb1a63076e7774b60f9481a8"}, {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e29cda763f752553fa14c68fb2195150bfab22b352572cb36c43c47bedba70eb"}, {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:0c7c1b47859ee2cac3846fde1c1dc0f15da6cec5a0e5c72d101e0f83dcb67ff9"}, {file = "frozenlist-1.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:901289d524fdd571be1c7be054f48b1f88ce8dddcbdf1ec698b27d4b8b9e5d62"}, {file = "frozenlist-1.4.0-cp310-cp310-win32.whl", hash = "sha256:1a0848b52815006ea6596c395f87449f693dc419061cc21e970f139d466dc0a0"}, {file = "frozenlist-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:b206646d176a007466358aa21d85cd8600a415c67c9bd15403336c331a10d956"}, {file = "frozenlist-1.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:de343e75f40e972bae1ef6090267f8260c1446a1695e77096db6cfa25e759a95"}, {file = "frozenlist-1.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad2a9eb6d9839ae241701d0918f54c51365a51407fd80f6b8289e2dfca977cc3"}, {file = "frozenlist-1.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bd7bd3b3830247580de99c99ea2a01416dfc3c34471ca1298bccabf86d0ff4dc"}, {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bdf1847068c362f16b353163391210269e4f0569a3c166bc6a9f74ccbfc7e839"}, {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38461d02d66de17455072c9ba981d35f1d2a73024bee7790ac2f9e361ef1cd0c"}, {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5a32087d720c608f42caed0ef36d2b3ea61a9d09ee59a5142d6070da9041b8f"}, {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd65632acaf0d47608190a71bfe46b209719bf2beb59507db08ccdbe712f969b"}, {file = "frozenlist-1.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:261b9f5d17cac914531331ff1b1d452125bf5daa05faf73b71d935485b0c510b"}, {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b89ac9768b82205936771f8d2eb3ce88503b1556324c9f903e7156669f521472"}, {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:008eb8b31b3ea6896da16c38c1b136cb9fec9e249e77f6211d479db79a4eaf01"}, {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e74b0506fa5aa5598ac6a975a12aa8928cbb58e1f5ac8360792ef15de1aa848f"}, {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:490132667476f6781b4c9458298b0c1cddf237488abd228b0b3650e5ecba7467"}, {file = "frozenlist-1.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:76d4711f6f6d08551a7e9ef28c722f4a50dd0fc204c56b4bcd95c6cc05ce6fbb"}, {file = "frozenlist-1.4.0-cp311-cp311-win32.whl", hash = "sha256:a02eb8ab2b8f200179b5f62b59757685ae9987996ae549ccf30f983f40602431"}, {file = "frozenlist-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:515e1abc578dd3b275d6a5114030b1330ba044ffba03f94091842852f806f1c1"}, {file = "frozenlist-1.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f0ed05f5079c708fe74bf9027e95125334b6978bf07fd5ab923e9e55e5fbb9d3"}, {file = "frozenlist-1.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ca265542ca427bf97aed183c1676e2a9c66942e822b14dc6e5f42e038f92a503"}, {file = "frozenlist-1.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:491e014f5c43656da08958808588cc6c016847b4360e327a62cb308c791bd2d9"}, {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17ae5cd0f333f94f2e03aaf140bb762c64783935cc764ff9c82dff626089bebf"}, {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e78fb68cf9c1a6aa4a9a12e960a5c9dfbdb89b3695197aa7064705662515de2"}, {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5655a942f5f5d2c9ed93d72148226d75369b4f6952680211972a33e59b1dfdc"}, {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c11b0746f5d946fecf750428a95f3e9ebe792c1ee3b1e96eeba145dc631a9672"}, {file = "frozenlist-1.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e66d2a64d44d50d2543405fb183a21f76b3b5fd16f130f5c99187c3fb4e64919"}, {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:88f7bc0fcca81f985f78dd0fa68d2c75abf8272b1f5c323ea4a01a4d7a614efc"}, {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5833593c25ac59ede40ed4de6d67eb42928cca97f26feea219f21d0ed0959b79"}, {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:fec520865f42e5c7f050c2a79038897b1c7d1595e907a9e08e3353293ffc948e"}, {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:b826d97e4276750beca7c8f0f1a4938892697a6bcd8ec8217b3312dad6982781"}, {file = "frozenlist-1.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ceb6ec0a10c65540421e20ebd29083c50e6d1143278746a4ef6bcf6153171eb8"}, {file = "frozenlist-1.4.0-cp38-cp38-win32.whl", hash = "sha256:2b8bcf994563466db019fab287ff390fffbfdb4f905fc77bc1c1d604b1c689cc"}, {file = "frozenlist-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:a6c8097e01886188e5be3e6b14e94ab365f384736aa1fca6a0b9e35bd4a30bc7"}, {file = "frozenlist-1.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6c38721585f285203e4b4132a352eb3daa19121a035f3182e08e437cface44bf"}, {file = "frozenlist-1.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a0c6da9aee33ff0b1a451e867da0c1f47408112b3391dd43133838339e410963"}, {file = "frozenlist-1.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93ea75c050c5bb3d98016b4ba2497851eadf0ac154d88a67d7a6816206f6fa7f"}, {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f61e2dc5ad442c52b4887f1fdc112f97caeff4d9e6ebe78879364ac59f1663e1"}, {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa384489fefeb62321b238e64c07ef48398fe80f9e1e6afeff22e140e0850eef"}, {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10ff5faaa22786315ef57097a279b833ecab1a0bfb07d604c9cbb1c4cdc2ed87"}, {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:007df07a6e3eb3e33e9a1fe6a9db7af152bbd8a185f9aaa6ece10a3529e3e1c6"}, {file = "frozenlist-1.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4f399d28478d1f604c2ff9119907af9726aed73680e5ed1ca634d377abb087"}, {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c5374b80521d3d3f2ec5572e05adc94601985cc526fb276d0c8574a6d749f1b3"}, {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ce31ae3e19f3c902de379cf1323d90c649425b86de7bbdf82871b8a2a0615f3d"}, {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7211ef110a9194b6042449431e08c4d80c0481e5891e58d429df5899690511c2"}, {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:556de4430ce324c836789fa4560ca62d1591d2538b8ceb0b4f68fb7b2384a27a"}, {file = "frozenlist-1.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7645a8e814a3ee34a89c4a372011dcd817964ce8cb273c8ed6119d706e9613e3"}, {file = "frozenlist-1.4.0-cp39-cp39-win32.whl", hash = "sha256:19488c57c12d4e8095a922f328df3f179c820c212940a498623ed39160bc3c2f"}, {file = "frozenlist-1.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:6221d84d463fb110bdd7619b69cb43878a11d51cbb9394ae3105d082d5199167"}, {file = "frozenlist-1.4.0.tar.gz", hash = "sha256:09163bdf0b2907454042edb19f887c6d33806adc71fbd54afc14908bfdc22251"}, ] [[package]] name = "gitdb" version = "4.0.10" description = "Git Object Database" optional = false python-versions = ">=3.7" files = [ {file = "gitdb-4.0.10-py3-none-any.whl", hash = "sha256:c286cf298426064079ed96a9e4a9d39e7f3e9bf15ba60701e95f5492f28415c7"}, {file = "gitdb-4.0.10.tar.gz", hash = "sha256:6eb990b69df4e15bad899ea868dc46572c3f75339735663b81de79b06f17eb9a"}, ] [package.dependencies] smmap = ">=3.0.1,<6" [[package]] name = "gitpython" version = "3.1.41" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" files = [ {file = "GitPython-3.1.41-py3-none-any.whl", hash = "sha256:c36b6634d069b3f719610175020a9aed919421c87552185b085e04fbbdb10b7c"}, {file = "GitPython-3.1.41.tar.gz", hash = "sha256:ed66e624884f76df22c8e16066d567aaa5a37d5b5fa19db2c6df6f7156db9048"}, ] [package.dependencies] gitdb = ">=4.0.1,<5" [package.extras] test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "sumtypes"] [[package]] name = "identify" version = "2.5.26" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ {file = "identify-2.5.26-py2.py3-none-any.whl", hash = "sha256:c22a8ead0d4ca11f1edd6c9418c3220669b3b7533ada0a0ffa6cc0ef85cf9b54"}, {file = "identify-2.5.26.tar.gz", hash = "sha256:7243800bce2f58404ed41b7c002e53d4d22bcf3ae1b7900c2d7aefd95394bf7f"}, ] [package.extras] license = ["ukkonen"] [[package]] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] [[package]] name = "imagesize" version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, ] [[package]] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] [[package]] name = "isort" version = "5.13.2" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.8.0" files = [ {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, ] [package.extras] colors = ["colorama (>=0.4.6)"] [[package]] name = "jinja2" version = "3.1.3" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" files = [ {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, ] [package.dependencies] MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] [[package]] name = "markdown-it-py" version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, ] [package.dependencies] mdurl = ">=0.1,<1.0" [package.extras] benchmarking = ["psutil", "pytest", "pytest-benchmark"] code-style = ["pre-commit (>=3.0,<4.0)"] compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] linkify = ["linkify-it-py (>=1,<3)"] plugins = ["mdit-py-plugins"] profiling = ["gprof2dot"] rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "markupsafe" version = "2.1.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" files = [ {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, ] [[package]] name = "mccabe" version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] [[package]] name = "mdit-py-plugins" version = "0.4.0" description = "Collection of plugins for markdown-it-py" optional = false python-versions = ">=3.8" files = [ {file = "mdit_py_plugins-0.4.0-py3-none-any.whl", hash = "sha256:b51b3bb70691f57f974e257e367107857a93b36f322a9e6d44ca5bf28ec2def9"}, {file = "mdit_py_plugins-0.4.0.tar.gz", hash = "sha256:d8ab27e9aed6c38aa716819fedfde15ca275715955f8a185a8e1cf90fb1d2c1b"}, ] [package.dependencies] markdown-it-py = ">=1.0.0,<4.0.0" [package.extras] code-style = ["pre-commit"] rtd = ["myst-parser", "sphinx-book-theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] [[package]] name = "multidict" version = "6.0.4" description = "multidict implementation" optional = false python-versions = ">=3.7" files = [ {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8"}, {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171"}, {file = "multidict-6.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7"}, {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b"}, {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547"}, {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569"}, {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93"}, {file = "multidict-6.0.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98"}, {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0"}, {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988"}, {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc"}, {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0"}, {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5"}, {file = "multidict-6.0.4-cp310-cp310-win32.whl", hash = "sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8"}, {file = "multidict-6.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc"}, {file = "multidict-6.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03"}, {file = "multidict-6.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3"}, {file = "multidict-6.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba"}, {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9"}, {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982"}, {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe"}, {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710"}, {file = "multidict-6.0.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c"}, {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4"}, {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a"}, {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c"}, {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed"}, {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461"}, {file = "multidict-6.0.4-cp311-cp311-win32.whl", hash = "sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636"}, {file = "multidict-6.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0"}, {file = "multidict-6.0.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78"}, {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f"}, {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603"}, {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac"}, {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9"}, {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2"}, {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde"}, {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe"}, {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067"}, {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87"}, {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d"}, {file = "multidict-6.0.4-cp37-cp37m-win32.whl", hash = "sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775"}, {file = "multidict-6.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e"}, {file = "multidict-6.0.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c"}, {file = "multidict-6.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161"}, {file = "multidict-6.0.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11"}, {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e"}, {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d"}, {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2"}, {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258"}, {file = "multidict-6.0.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52"}, {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660"}, {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951"}, {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60"}, {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d"}, {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1"}, {file = "multidict-6.0.4-cp38-cp38-win32.whl", hash = "sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779"}, {file = "multidict-6.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480"}, {file = "multidict-6.0.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664"}, {file = "multidict-6.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35"}, {file = "multidict-6.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60"}, {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706"}, {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d"}, {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca"}, {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1"}, {file = "multidict-6.0.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449"}, {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf"}, {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063"}, {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a"}, {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176"}, {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95"}, {file = "multidict-6.0.4-cp39-cp39-win32.whl", hash = "sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313"}, {file = "multidict-6.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2"}, {file = "multidict-6.0.4.tar.gz", hash = "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49"}, ] [[package]] name = "mypy" version = "1.8.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, ] [package.dependencies] mypy-extensions = ">=1.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = ">=4.1.0" [package.extras] dmypy = ["psutil (>=4.0)"] install-types = ["pip"] mypyc = ["setuptools (>=50)"] reports = ["lxml"] [[package]] name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] [[package]] name = "myst-parser" version = "2.0.0" description = "An extended [CommonMark](https://spec.commonmark.org/) compliant parser," optional = false python-versions = ">=3.8" files = [ {file = "myst_parser-2.0.0-py3-none-any.whl", hash = "sha256:7c36344ae39c8e740dad7fdabf5aa6fc4897a813083c6cc9990044eb93656b14"}, {file = "myst_parser-2.0.0.tar.gz", hash = "sha256:ea929a67a6a0b1683cdbe19b8d2e724cd7643f8aa3e7bb18dd65beac3483bead"}, ] [package.dependencies] docutils = ">=0.16,<0.21" jinja2 = "*" markdown-it-py = ">=3.0,<4.0" mdit-py-plugins = ">=0.4,<1.0" pyyaml = "*" sphinx = ">=6,<8" [package.extras] code-style = ["pre-commit (>=3.0,<4.0)"] linkify = ["linkify-it-py (>=2.0,<3.0)"] rtd = ["ipython", "pydata-sphinx-theme (==v0.13.0rc4)", "sphinx-autodoc2 (>=0.4.2,<0.5.0)", "sphinx-book-theme (==1.0.0rc2)", "sphinx-copybutton", "sphinx-design2", "sphinx-pyscript", "sphinx-tippy (>=0.3.1)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.8.2,<0.9.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=7,<8)", "pytest-cov", "pytest-param-files (>=0.3.4,<0.4.0)", "pytest-regressions", "sphinx-pytest"] testing-docutils = ["pygments", "pytest (>=7,<8)", "pytest-param-files (>=0.3.4,<0.4.0)"] [[package]] name = "nodeenv" version = "1.8.0" description = "Node.js virtual environment builder" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, ] [package.dependencies] setuptools = "*" [[package]] name = "packaging" version = "23.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" files = [ {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, ] [[package]] name = "pathspec" version = "0.11.2" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.7" files = [ {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, ] [[package]] name = "platformdirs" version = "3.10.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" files = [ {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, ] [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] [[package]] name = "pluggy" version = "1.2.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.7" files = [ {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" version = "3.6.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ {file = "pre_commit-3.6.0-py2.py3-none-any.whl", hash = "sha256:c255039ef399049a5544b6ce13d135caba8f2c28c3b4033277a788f434308376"}, {file = "pre_commit-3.6.0.tar.gz", hash = "sha256:d30bad9abf165f7785c15a21a1f46da7d0677cb00ee7ff4c579fd38922efe15d"}, ] [package.dependencies] cfgv = ">=2.0.0" identify = ">=1.0.0" nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" [[package]] name = "pre-commit-hooks" version = "4.5.0" description = "Some out-of-the-box hooks for pre-commit." optional = false python-versions = ">=3.8" files = [ {file = "pre_commit_hooks-4.5.0-py2.py3-none-any.whl", hash = "sha256:b779d5c44ede9b1fda48e2d96b08e9aa5b1d2fdb8903ca09f0dbaca22d529edb"}, {file = "pre_commit_hooks-4.5.0.tar.gz", hash = "sha256:ffbe2af1c85ac9a7695866955680b4dee98822638b748a6f3debefad79748c8a"}, ] [package.dependencies] "ruamel.yaml" = ">=0.15" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} [[package]] name = "pygments" version = "2.17.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.7" files = [ {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, ] [package.extras] plugins = ["importlib-metadata"] windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pylint" version = "3.0.3" description = "python code static checker" optional = false python-versions = ">=3.8.0" files = [ {file = "pylint-3.0.3-py3-none-any.whl", hash = "sha256:7a1585285aefc5165db81083c3e06363a27448f6b467b3b0f30dbd0ac1f73810"}, {file = "pylint-3.0.3.tar.gz", hash = "sha256:58c2398b0301e049609a8429789ec6edf3aabe9b6c5fec916acd18639c16de8b"}, ] [package.dependencies] astroid = ">=3.0.1,<=3.1.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, ] isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" mccabe = ">=0.6,<0.8" platformdirs = ">=2.2.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} tomlkit = ">=0.10.1" [package.extras] spelling = ["pyenchant (>=3.2,<4.0)"] testutils = ["gitpython (>3)"] [[package]] name = "pytest" version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, ] [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-aiohttp" version = "1.0.5" description = "Pytest plugin for aiohttp support" optional = false python-versions = ">=3.7" files = [ {file = "pytest-aiohttp-1.0.5.tar.gz", hash = "sha256:880262bc5951e934463b15e3af8bb298f11f7d4d3ebac970aab425aff10a780a"}, {file = "pytest_aiohttp-1.0.5-py3-none-any.whl", hash = "sha256:63a5360fd2f34dda4ab8e6baee4c5f5be4cd186a403cabd498fced82ac9c561e"}, ] [package.dependencies] aiohttp = ">=3.8.1" pytest = ">=6.1.0" pytest-asyncio = ">=0.17.2" [package.extras] testing = ["coverage (==6.2)", "mypy (==0.931)"] [[package]] name = "pytest-asyncio" version = "0.21.1" description = "Pytest support for asyncio" optional = false python-versions = ">=3.7" files = [ {file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"}, {file = "pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b"}, ] [package.dependencies] pytest = ">=7.0.0" [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] [[package]] name = "pytest-cov" version = "4.1.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.7" files = [ {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, ] [package.dependencies] coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] [[package]] name = "pyupgrade" version = "3.15.0" description = "A tool to automatically upgrade syntax for newer versions." optional = false python-versions = ">=3.8.1" files = [ {file = "pyupgrade-3.15.0-py2.py3-none-any.whl", hash = "sha256:8dc8ebfaed43566e2c65994162795017c7db11f531558a74bc8aa077907bc305"}, {file = "pyupgrade-3.15.0.tar.gz", hash = "sha256:a7fde381060d7c224f55aef7a30fae5ac93bbc428367d27e70a603bc2acd4f00"}, ] [package.dependencies] tokenize-rt = ">=5.2.0" [[package]] name = "pyyaml" version = "6.0.1" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.6" files = [ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] [[package]] name = "requests" version = "2.31.0" description = "Python HTTP for Humans." optional = false python-versions = ">=3.7" files = [ {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, ] [package.dependencies] certifi = ">=2017.4.17" charset-normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "ruamel-yaml" version = "0.17.32" description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" optional = false python-versions = ">=3" files = [ {file = "ruamel.yaml-0.17.32-py3-none-any.whl", hash = "sha256:23cd2ed620231677564646b0c6a89d138b6822a0d78656df7abda5879ec4f447"}, {file = "ruamel.yaml-0.17.32.tar.gz", hash = "sha256:ec939063761914e14542972a5cba6d33c23b0859ab6342f61cf070cfc600efc2"}, ] [package.dependencies] "ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.12\""} [package.extras] docs = ["ryd"] jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] [[package]] name = "ruamel-yaml-clib" version = "0.2.7" description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" optional = false python-versions = ">=3.5" files = [ {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5859983f26d8cd7bb5c287ef452e8aacc86501487634573d260968f753e1d71"}, {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:debc87a9516b237d0466a711b18b6ebeb17ba9f391eb7f91c649c5c4ec5006c7"}, {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:df5828871e6648db72d1c19b4bd24819b80a755c4541d3409f0f7acd0f335c80"}, {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:efa08d63ef03d079dcae1dfe334f6c8847ba8b645d08df286358b1f5293d24ab"}, {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-win32.whl", hash = "sha256:763d65baa3b952479c4e972669f679fe490eee058d5aa85da483ebae2009d231"}, {file = "ruamel.yaml.clib-0.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:d000f258cf42fec2b1bbf2863c61d7b8918d31ffee905da62dede869254d3b8a"}, {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:045e0626baf1c52e5527bd5db361bc83180faaba2ff586e763d3d5982a876a9e"}, {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:1a6391a7cabb7641c32517539ca42cf84b87b667bad38b78d4d42dd23e957c81"}, {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:9c7617df90c1365638916b98cdd9be833d31d337dbcd722485597b43c4a215bf"}, {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:41d0f1fa4c6830176eef5b276af04c89320ea616655d01327d5ce65e50575c94"}, {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-win32.whl", hash = "sha256:f6d3d39611ac2e4f62c3128a9eed45f19a6608670c5a2f4f07f24e8de3441d38"}, {file = "ruamel.yaml.clib-0.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:da538167284de58a52109a9b89b8f6a53ff8437dd6dc26d33b57bf6699153122"}, {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4b3a93bb9bc662fc1f99c5c3ea8e623d8b23ad22f861eb6fce9377ac07ad6072"}, {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-macosx_12_0_arm64.whl", hash = "sha256:a234a20ae07e8469da311e182e70ef6b199d0fbeb6c6cc2901204dd87fb867e8"}, {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:15910ef4f3e537eea7fe45f8a5d19997479940d9196f357152a09031c5be59f3"}, {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:370445fd795706fd291ab00c9df38a0caed0f17a6fb46b0f607668ecb16ce763"}, {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-win32.whl", hash = "sha256:ecdf1a604009bd35c674b9225a8fa609e0282d9b896c03dd441a91e5f53b534e"}, {file = "ruamel.yaml.clib-0.2.7-cp36-cp36m-win_amd64.whl", hash = "sha256:f34019dced51047d6f70cb9383b2ae2853b7fc4dce65129a5acd49f4f9256646"}, {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2aa261c29a5545adfef9296b7e33941f46aa5bbd21164228e833412af4c9c75f"}, {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f01da5790e95815eb5a8a138508c01c758e5f5bc0ce4286c4f7028b8dd7ac3d0"}, {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:40d030e2329ce5286d6b231b8726959ebbe0404c92f0a578c0e2482182e38282"}, {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c3ca1fbba4ae962521e5eb66d72998b51f0f4d0f608d3c0347a48e1af262efa7"}, {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-win32.whl", hash = "sha256:7bdb4c06b063f6fd55e472e201317a3bb6cdeeee5d5a38512ea5c01e1acbdd93"}, {file = "ruamel.yaml.clib-0.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:be2a7ad8fd8f7442b24323d24ba0b56c51219513cfa45b9ada3b87b76c374d4b"}, {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:91a789b4aa0097b78c93e3dc4b40040ba55bef518f84a40d4442f713b4094acb"}, {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:99e77daab5d13a48a4054803d052ff40780278240a902b880dd37a51ba01a307"}, {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:3243f48ecd450eddadc2d11b5feb08aca941b5cd98c9b1db14b2fd128be8c697"}, {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8831a2cedcd0f0927f788c5bdf6567d9dc9cc235646a434986a852af1cb54b4b"}, {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-win32.whl", hash = "sha256:3110a99e0f94a4a3470ff67fc20d3f96c25b13d24c6980ff841e82bafe827cac"}, {file = "ruamel.yaml.clib-0.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:92460ce908546ab69770b2e576e4f99fbb4ce6ab4b245345a3869a0a0410488f"}, {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5bc0667c1eb8f83a3752b71b9c4ba55ef7c7058ae57022dd9b29065186a113d9"}, {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:4a4d8d417868d68b979076a9be6a38c676eca060785abaa6709c7b31593c35d1"}, {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bf9a6bc4a0221538b1a7de3ed7bca4c93c02346853f44e1cd764be0023cd3640"}, {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a7b301ff08055d73223058b5c46c55638917f04d21577c95e00e0c4d79201a6b"}, {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-win32.whl", hash = "sha256:d5e51e2901ec2366b79f16c2299a03e74ba4531ddcfacc1416639c557aef0ad8"}, {file = "ruamel.yaml.clib-0.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:184faeaec61dbaa3cace407cffc5819f7b977e75360e8d5ca19461cd851a5fc5"}, {file = "ruamel.yaml.clib-0.2.7.tar.gz", hash = "sha256:1f08fd5a2bea9c4180db71678e850b995d2a5f4537be0e94557668cf0f5f9497"}, ] [[package]] name = "ruff" version = "0.1.13" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ {file = "ruff-0.1.13-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e3fd36e0d48aeac672aa850045e784673449ce619afc12823ea7868fcc41d8ba"}, {file = "ruff-0.1.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9fb6b3b86450d4ec6a6732f9f60c4406061b6851c4b29f944f8c9d91c3611c7a"}, {file = "ruff-0.1.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b13ba5d7156daaf3fd08b6b993360a96060500aca7e307d95ecbc5bb47a69296"}, {file = "ruff-0.1.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ebb40442f7b531e136d334ef0851412410061e65d61ca8ce90d894a094feb22"}, {file = "ruff-0.1.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:226b517f42d59a543d6383cfe03cccf0091e3e0ed1b856c6824be03d2a75d3b6"}, {file = "ruff-0.1.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5f0312ba1061e9b8c724e9a702d3c8621e3c6e6c2c9bd862550ab2951ac75c16"}, {file = "ruff-0.1.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f59bcf5217c661254bd6bc42d65a6fd1a8b80c48763cb5c2293295babd945dd"}, {file = "ruff-0.1.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6894b00495e00c27b6ba61af1fc666f17de6140345e5ef27dd6e08fb987259d"}, {file = "ruff-0.1.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1600942485c6e66119da294c6294856b5c86fd6df591ce293e4a4cc8e72989"}, {file = "ruff-0.1.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ee3febce7863e231a467f90e681d3d89210b900d49ce88723ce052c8761be8c7"}, {file = "ruff-0.1.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dcaab50e278ff497ee4d1fe69b29ca0a9a47cd954bb17963628fa417933c6eb1"}, {file = "ruff-0.1.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f57de973de4edef3ad3044d6a50c02ad9fc2dff0d88587f25f1a48e3f72edf5e"}, {file = "ruff-0.1.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7a36fa90eb12208272a858475ec43ac811ac37e91ef868759770b71bdabe27b6"}, {file = "ruff-0.1.13-py3-none-win32.whl", hash = "sha256:a623349a505ff768dad6bd57087e2461be8db58305ebd5577bd0e98631f9ae69"}, {file = "ruff-0.1.13-py3-none-win_amd64.whl", hash = "sha256:f988746e3c3982bea7f824c8fa318ce7f538c4dfefec99cd09c8770bd33e6539"}, {file = "ruff-0.1.13-py3-none-win_arm64.whl", hash = "sha256:6bbbc3042075871ec17f28864808540a26f0f79a4478c357d3e3d2284e832998"}, {file = "ruff-0.1.13.tar.gz", hash = "sha256:e261f1baed6291f434ffb1d5c6bd8051d1c2a26958072d38dfbec39b3dda7352"}, ] [[package]] name = "setuptools" version = "68.1.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ {file = "setuptools-68.1.2-py3-none-any.whl", hash = "sha256:3d8083eed2d13afc9426f227b24fd1659489ec107c0e86cec2ffdde5c92e790b"}, {file = "setuptools-68.1.2.tar.gz", hash = "sha256:3d4dfa6d95f1b101d695a6160a7626e15583af71a5f52176efa5d39a054d475d"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5,<=7.1.2)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "smmap" version = "5.0.0" description = "A pure Python implementation of a sliding window memory map manager" optional = false python-versions = ">=3.6" files = [ {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, ] [[package]] name = "snowballstemmer" version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." optional = false python-versions = "*" files = [ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, ] [[package]] name = "sphinx" version = "7.2.6" description = "Python documentation generator" optional = false python-versions = ">=3.9" files = [ {file = "sphinx-7.2.6-py3-none-any.whl", hash = "sha256:1e09160a40b956dc623c910118fa636da93bd3ca0b9876a7b3df90f07d691560"}, {file = "sphinx-7.2.6.tar.gz", hash = "sha256:9a5160e1ea90688d5963ba09a2dcd8bdd526620edbb65c328728f1b2228d5ab5"}, ] [package.dependencies] alabaster = ">=0.7,<0.8" babel = ">=2.9" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} docutils = ">=0.18.1,<0.21" imagesize = ">=1.3" Jinja2 = ">=3.0" packaging = ">=21.0" Pygments = ">=2.14" requests = ">=2.25.0" snowballstemmer = ">=2.0" sphinxcontrib-applehelp = "*" sphinxcontrib-devhelp = "*" sphinxcontrib-htmlhelp = ">=2.0.0" sphinxcontrib-jsmath = "*" sphinxcontrib-qthelp = "*" sphinxcontrib-serializinghtml = ">=1.1.9" [package.extras] docs = ["sphinxcontrib-websupport"] lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy (>=0.990)", "ruff", "sphinx-lint", "types-requests"] test = ["cython (>=3.0)", "filelock", "html5lib", "pytest (>=4.6)", "setuptools (>=67.0)"] [[package]] name = "sphinx-rtd-theme" version = "2.0.0" description = "Read the Docs theme for Sphinx" optional = false python-versions = ">=3.6" files = [ {file = "sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586"}, {file = "sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b"}, ] [package.dependencies] docutils = "<0.21" sphinx = ">=5,<8" sphinxcontrib-jquery = ">=4,<5" [package.extras] dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] [[package]] name = "sphinxcontrib-applehelp" version = "1.0.7" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = false python-versions = ">=3.9" files = [ {file = "sphinxcontrib_applehelp-1.0.7-py3-none-any.whl", hash = "sha256:094c4d56209d1734e7d252f6e0b3ccc090bd52ee56807a5d9315b19c122ab15d"}, {file = "sphinxcontrib_applehelp-1.0.7.tar.gz", hash = "sha256:39fdc8d762d33b01a7d8f026a3b7d71563ea3b72787d5f00ad8465bd9d6dfbfa"}, ] [package.dependencies] Sphinx = ">=5" [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] test = ["pytest"] [[package]] name = "sphinxcontrib-devhelp" version = "1.0.5" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" optional = false python-versions = ">=3.9" files = [ {file = "sphinxcontrib_devhelp-1.0.5-py3-none-any.whl", hash = "sha256:fe8009aed765188f08fcaadbb3ea0d90ce8ae2d76710b7e29ea7d047177dae2f"}, {file = "sphinxcontrib_devhelp-1.0.5.tar.gz", hash = "sha256:63b41e0d38207ca40ebbeabcf4d8e51f76c03e78cd61abe118cf4435c73d4212"}, ] [package.dependencies] Sphinx = ">=5" [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] test = ["pytest"] [[package]] name = "sphinxcontrib-htmlhelp" version = "2.0.4" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = false python-versions = ">=3.9" files = [ {file = "sphinxcontrib_htmlhelp-2.0.4-py3-none-any.whl", hash = "sha256:8001661c077a73c29beaf4a79968d0726103c5605e27db92b9ebed8bab1359e9"}, {file = "sphinxcontrib_htmlhelp-2.0.4.tar.gz", hash = "sha256:6c26a118a05b76000738429b724a0568dbde5b72391a688577da08f11891092a"}, ] [package.dependencies] Sphinx = ">=5" [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] test = ["html5lib", "pytest"] [[package]] name = "sphinxcontrib-jquery" version = "4.1" description = "Extension to include jQuery on newer Sphinx releases" optional = false python-versions = ">=2.7" files = [ {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, ] [package.dependencies] Sphinx = ">=1.8" [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" optional = false python-versions = ">=3.5" files = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, ] [package.extras] test = ["flake8", "mypy", "pytest"] [[package]] name = "sphinxcontrib-qthelp" version = "1.0.6" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" optional = false python-versions = ">=3.9" files = [ {file = "sphinxcontrib_qthelp-1.0.6-py3-none-any.whl", hash = "sha256:bf76886ee7470b934e363da7a954ea2825650013d367728588732c7350f49ea4"}, {file = "sphinxcontrib_qthelp-1.0.6.tar.gz", hash = "sha256:62b9d1a186ab7f5ee3356d906f648cacb7a6bdb94d201ee7adf26db55092982d"}, ] [package.dependencies] Sphinx = ">=5" [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] test = ["pytest"] [[package]] name = "sphinxcontrib-serializinghtml" version = "1.1.9" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" optional = false python-versions = ">=3.9" files = [ {file = "sphinxcontrib_serializinghtml-1.1.9-py3-none-any.whl", hash = "sha256:9b36e503703ff04f20e9675771df105e58aa029cfcbc23b8ed716019b7416ae1"}, {file = "sphinxcontrib_serializinghtml-1.1.9.tar.gz", hash = "sha256:0c64ff898339e1fac29abd2bf5f11078f3ec413cfe9c046d3120d7ca65530b54"}, ] [package.dependencies] Sphinx = ">=5" [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] test = ["pytest"] [[package]] name = "tokenize-rt" version = "5.2.0" description = "A wrapper around the stdlib `tokenize` which roundtrips." optional = false python-versions = ">=3.8" files = [ {file = "tokenize_rt-5.2.0-py2.py3-none-any.whl", hash = "sha256:b79d41a65cfec71285433511b50271b05da3584a1da144a0752e9c621a285289"}, {file = "tokenize_rt-5.2.0.tar.gz", hash = "sha256:9fe80f8a5c1edad2d3ede0f37481cc0cc1538a2f442c9c2f9e4feacd2792d054"}, ] [[package]] name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] [[package]] name = "tomli" version = "2.0.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.7" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] [[package]] name = "tomlkit" version = "0.12.1" description = "Style preserving TOML library" optional = false python-versions = ">=3.7" files = [ {file = "tomlkit-0.12.1-py3-none-any.whl", hash = "sha256:712cbd236609acc6a3e2e97253dfc52d4c2082982a88f61b640ecf0817eab899"}, {file = "tomlkit-0.12.1.tar.gz", hash = "sha256:38e1ff8edb991273ec9f6181244a6a391ac30e9f5098e7535640ea6be97a7c86"}, ] [[package]] name = "typing-extensions" version = "4.9.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, ] [[package]] name = "urllib3" version = "2.0.7" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.7" files = [ {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"}, {file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"}, ] [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" version = "20.24.3" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ {file = "virtualenv-20.24.3-py3-none-any.whl", hash = "sha256:95a6e9398b4967fbcb5fef2acec5efaf9aa4972049d9ae41f95e0972a683fd02"}, {file = "virtualenv-20.24.3.tar.gz", hash = "sha256:e5c3b4ce817b0b328af041506a2a299418c98747c4b1e68cb7527e74ced23efc"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = ">=3.12.2,<4" platformdirs = ">=3.9.1,<4" [package.extras] docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [[package]] name = "voluptuous" version = "0.14.1" description = "Python data validation library" optional = false python-versions = ">=3.7" files = [ {file = "voluptuous-0.14.1-py3-none-any.whl", hash = "sha256:ab202b5164b4bbd2c9bf2d4f264efef6f0f30fc0f570be27f1332be4514eefe0"}, {file = "voluptuous-0.14.1.tar.gz", hash = "sha256:7b6e5f7553ce02461cce17fedb0e3603195496eb260ece9aca86cc4cc6625218"}, ] [[package]] name = "vulture" version = "2.10" description = "Find dead code" optional = false python-versions = ">=3.8" files = [ {file = "vulture-2.10-py2.py3-none-any.whl", hash = "sha256:568a4176db7468d0157817ae3bb1847a19f1ddc629849af487f9d3b279bff77d"}, {file = "vulture-2.10.tar.gz", hash = "sha256:2a5c3160bffba77595b6e6dfcc412016bd2a09cd4b66cdf7fbba913684899f6f"}, ] [package.dependencies] toml = "*" [[package]] name = "websockets" version = "12.0" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.8" files = [ {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"}, {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"}, {file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"}, {file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"}, {file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"}, {file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"}, {file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"}, {file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"}, {file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"}, {file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"}, {file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"}, {file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"}, {file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"}, {file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"}, {file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"}, {file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"}, {file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"}, {file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"}, {file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"}, {file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"}, {file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"}, {file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"}, {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, {file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"}, {file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"}, {file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"}, {file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"}, {file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"}, {file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"}, {file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"}, {file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"}, {file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"}, {file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"}, {file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"}, {file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"}, {file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"}, {file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"}, {file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"}, {file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"}, {file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"}, {file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"}, {file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"}, {file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"}, {file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"}, {file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"}, {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, ] [[package]] name = "yamllint" version = "1.33.0" description = "A linter for YAML files." optional = false python-versions = ">=3.8" files = [ {file = "yamllint-1.33.0-py3-none-any.whl", hash = "sha256:28a19f5d68d28d8fec538a1db21bb2d84c7dc2e2ea36266da8d4d1c5a683814d"}, {file = "yamllint-1.33.0.tar.gz", hash = "sha256:2dceab9ef2d99518a2fcf4ffc964d44250ac4459be1ba3ca315118e4a1a81f7d"}, ] [package.dependencies] pathspec = ">=0.5.3" pyyaml = "*" [package.extras] dev = ["doc8", "flake8", "flake8-import-order", "rstcheck[sphinx]", "sphinx"] [[package]] name = "yarl" version = "1.9.4" description = "Yet another URL library" optional = false python-versions = ">=3.7" files = [ {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, {file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"}, {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"}, {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"}, {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"}, {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"}, {file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"}, {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"}, {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"}, {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"}, {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"}, {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"}, {file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"}, {file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"}, {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"}, {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"}, {file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"}, {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"}, {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"}, {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"}, {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"}, {file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"}, {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"}, {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"}, {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"}, {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"}, {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"}, {file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"}, {file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"}, {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"}, {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"}, {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"}, {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"}, {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"}, {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"}, {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"}, {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"}, {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"}, {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"}, {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"}, {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"}, {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"}, {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"}, {file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"}, {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"}, {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"}, {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"}, {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"}, {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"}, {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"}, {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"}, {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"}, {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"}, {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"}, {file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"}, {file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"}, {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"}, {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"}, {file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"}, {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"}, {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"}, {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"}, {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"}, {file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"}, {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"}, {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"}, {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"}, {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"}, {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"}, {file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"}, {file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"}, {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"}, {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"}, {file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"}, {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"}, {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"}, {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"}, {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"}, {file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"}, {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"}, {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"}, {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"}, {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"}, {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"}, {file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"}, {file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"}, {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"}, {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"}, ] [package.dependencies] idna = ">=2.0" multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" content-hash = "30fd0847cf67f879664595a38e8bfe156f32043353f94b68a0779490562c28f1" simplisafe-python-2024.01.0/pyproject.toml000066400000000000000000000074431455300150500204150ustar00rootroot00000000000000[build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.black] target-version = ["py39"] [tool.coverage.report] exclude_lines = ["raise NotImplementedError", "TYPE_CHECKING", "ImportError"] fail_under = 100 show_missing = true [tool.coverage.run] omit = ["simplipy/util/auth.py"] source = ["simplipy"] [tool.isort] known_first_party = "simplipy,examples,tests" multi_line_output = 3 profile = "black" [tool.mypy] check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true disallow_untyped_calls = true disallow_untyped_decorators = true disallow_untyped_defs = true follow_imports = "silent" ignore_missing_imports = true no_implicit_optional = true platform = "linux" python_version = "3.12" show_error_codes = true strict_equality = true warn_incomplete_stub = true warn_redundant_casts = true warn_return_any = true warn_unreachable = true warn_unused_configs = true warn_unused_ignores = true [tool.poetry] name = "simplisafe-python" version = "2024.01.0" description = "A Python3, async interface to the SimpliSafe API" readme = "README.md" authors = ["Aaron Bach "] license = "MIT" repository = "https://github.com/bachya/simplisafe-python" packages = [ { include = "simplipy" }, ] classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] [tool.poetry.dependencies] aiohttp = ">=3.9.0b0" backoff = ">=1.11.1" certifi = ">=2023.07.22" python = "^3.10" voluptuous = ">=0.11.7" websockets = ">=8.1" yarl = ">=1.9.2" [tool.poetry.group.dev.dependencies] GitPython = ">=3.1.35" Pygments = ">=2.15.0" aresponses = ">=2.1.6,<4.0.0" asynctest = "^0.13.0" blacken-docs = "^1.12.1" codespell = "^2.2.2" coverage = {version = ">=6.5,<8.0", extras = ["toml"]} darglint = "^1.8.1" isort = "^5.10.1" mypy = "^1.2.0" pre-commit = ">=2.20,<4.0" pre-commit-hooks = "^4.3.0" pylint = ">=2.15.5,<4.0.0" pytest = "^7.2.0" pytest-aiohttp = "^1.0.0" pytest-cov = "^4.0.0" pyupgrade = "^3.1.0" pyyaml = "^6.0.1" requests = ">=2.31.0" ruff = ">=0.0.261,<0.1.14" sphinx-rtd-theme = ">=1,<3" typing-extensions = "^4.8.0" vulture = "^2.6" yamllint = "^1.28.0" [tool.poetry.group.docs.dependencies] Sphinx = ">=5.0.1,<8.0.0" myst-parser = ">=0.18,<2.1" [tool.poetry.urls] "Bug Tracker" = "https://github.com/bachya/simplipy/issues" Changelog = "https://github.com/bachya/simplipy/releases" [tool.pylint.BASIC] expected-line-ending-format = "LF" [tool.pylint.DESIGN] max-attributes = 20 [tool.pylint.FORMAT] max-line-length = 88 [tool.pylint.MASTER] ignore = [ "tests", ] load-plugins = [ "pylint.extensions.bad_builtin", "pylint.extensions.code_style", "pylint.extensions.docparams", "pylint.extensions.docstyle", "pylint.extensions.empty_comment", "pylint.extensions.overlapping_exceptions", "pylint.extensions.typing", ] [tool.pylint."MESSAGES CONTROL"] # Reasons disabled: # unnecessary-pass - This can hurt readability disable = [ "unnecessary-pass" ] [tool.pylint.REPORTS] score = false [tool.pylint.SIMILARITIES] # Minimum lines number of a similarity. # We set this higher because of some cases where V2 and V3 functionality are # similar, but abstracting them isn't feasible. min-similarity-lines = 15 # Ignore comments when computing similarities. ignore-comments = true # Ignore docstrings when computing similarities. ignore-docstrings = true # Ignore imports when computing similarities. ignore-imports = true [tool.vulture] min_confidence = 80 paths = ["simplipy", "tests"] verbose = false simplisafe-python-2024.01.0/script/000077500000000000000000000000001455300150500167755ustar00rootroot00000000000000simplisafe-python-2024.01.0/script/auth000077500000000000000000000015351455300150500176700ustar00rootroot00000000000000#!/usr/bin/env python """Initiate the SimpliSafe authorization process.""" import asyncio import sys import webbrowser from simplipy.util.auth import ( get_auth0_code_challenge, get_auth0_code_verifier, get_auth_url, ) async def main() -> None: """Run.""" code_verifier = get_auth0_code_verifier() code_challenge = get_auth0_code_challenge(code_verifier) auth_url = get_auth_url(code_challenge) try: input("Press to be taken to the SimpliSafe login page... ") except KeyboardInterrupt: sys.exit(1) webbrowser.open(auth_url) auth_code = input("Enter the code received from the SimpliSafe auth webpage: ") print() print("You are now ready to use the SimpliSafe API!") print(f"Authorization Code: {auth_code}") print(f"Code Verifier: {code_verifier}") asyncio.run(main()) simplisafe-python-2024.01.0/script/docs000077500000000000000000000000771455300150500176570ustar00rootroot00000000000000#!/bin/sh set -e poetry run sphinx-build ./docs ./docs/_build simplisafe-python-2024.01.0/script/release000077500000000000000000000026731455300150500203530ustar00rootroot00000000000000#!/usr/bin/env bash set -e REPO_PATH="$( dirname "$( cd "$(dirname "$0")" ; pwd -P )" )" if [ "$(git rev-parse --abbrev-ref HEAD)" != "dev" ]; then echo "Refusing to publish a release from a branch other than dev" exit 1 fi if [ -z "$(command -v poetry)" ]; then echo "Poetry needs to be installed to run this script: pip3 install poetry" exit 1 fi function generate_version { latest_tag="$(git tag --sort=committerdate | tail -1)" month="$(date +'%Y.%m')" if [[ "$latest_tag" =~ "$month".* ]]; then patch="$(echo "$latest_tag" | cut -d . -f 3)" ((patch=patch+1)) echo "$month.$patch" else echo "$month.0" fi } # Temporarily uninstall pre-commit hooks so that we can push to dev and main: pre-commit uninstall # Pull the latest dev: git pull origin dev # Generate the next version (in the format YEAR.MONTH.RELEASE_NUMER): new_version=$(generate_version) # Update the PyPI package version: sed -i "" "s/^version = \".*\"/version = \"$new_version\"/g" "$REPO_PATH/pyproject.toml" git add pyproject.toml # Update the docs version: sed -i "" "s/^release = \".*\"/release = \"$new_version\"/g" "$REPO_PATH/docs/conf.py" git add docs/conf.py # Commit, tag, and push: git commit -m "Bump version to $new_version" git tag "$new_version" git push && git push --tags # Merge dev into main: git checkout main git merge dev git push git checkout dev # Re-initialize pre-commit: pre-commit install simplisafe-python-2024.01.0/script/setup000077500000000000000000000002571455300150500200670ustar00rootroot00000000000000#!/bin/sh set -e if command -v "mise"; then mise install fi # Install all dependencies: pip3 install poetry poetry install # Install pre-commit hooks: pre-commit install simplisafe-python-2024.01.0/simplipy/000077500000000000000000000000001455300150500173375ustar00rootroot00000000000000simplisafe-python-2024.01.0/simplipy/__init__.py000066400000000000000000000001101455300150500214400ustar00rootroot00000000000000"""Define the simplipy package.""" from simplipy.api import API # noqa simplisafe-python-2024.01.0/simplipy/api.py000066400000000000000000000443031455300150500204660ustar00rootroot00000000000000"""Define functionality for interacting with the SimpliSafe API.""" from __future__ import annotations import asyncio import sys from collections.abc import Awaitable, Callable from datetime import datetime from json.decoder import JSONDecodeError from typing import Any, cast import backoff from aiohttp import ClientSession from aiohttp.client_exceptions import ClientResponseError from simplipy.const import DEFAULT_USER_AGENT, LOGGER from simplipy.errors import ( InvalidCredentialsError, RequestError, SimplipyError, raise_on_data_error, ) from simplipy.system.v2 import SystemV2 from simplipy.system.v3 import SystemV3 from simplipy.util import execute_callback from simplipy.util.auth import ( AUTH_URL_BASE, AUTH_URL_HOSTNAME, DEFAULT_CLIENT_ID, DEFAULT_REDIRECT_URI, ) from simplipy.util.dt import utcnow from simplipy.websocket import WebsocketClient API_URL_HOSTNAME = "api.simplisafe.com" API_URL_BASE = f"https://{API_URL_HOSTNAME}/v1" DEFAULT_REQUEST_RETRIES = 4 DEFAULT_MEDIA_RETRIES = 4 DEFAULT_TIMEOUT = 10 DEFAULT_TOKEN_EXPIRATION_WINDOW = 5 class API: # pylint: disable=too-many-instance-attributes """An API object to interact with the SimpliSafe cloud. Note that this class shouldn't be instantiated directly; instead, the :meth:`simplipy.api.API.async_from_auth` and :meth:`simplipy.api.API.async_from_refresh_token` methods should be used. Args: session: session: An optional ``aiohttp`` ``ClientSession``. request_retries: The default number of request retries to use. media_retries: The default number of request retries to use to fetch media files. """ def __init__( self, *, request_retries: int = DEFAULT_REQUEST_RETRIES, media_retries: int = DEFAULT_MEDIA_RETRIES, session: ClientSession, ) -> None: """Initialize. Args: session: An optional ``aiohttp`` ``ClientSession``. request_retries: The default number of request retries to use. """ self._refresh_token_callbacks: list[ Callable[[str], Awaitable[None] | None] ] = [] self._request_retries = request_retries self._media_retries = media_retries self.session: ClientSession = session # These will get filled in after initial authentication: self._backoff_refresh_lock = asyncio.Lock() self._token_last_refreshed: datetime | None = None self.access_token: str | None = None self.refresh_token: str | None = None self.subscription_data: dict[int, Any] = {} self.user_id: int | None = None self.websocket: WebsocketClient | None = None self.async_request = self._wrap_request_method( request_retries=self._request_retries, retry_codes=[401, 409], request_func=self._async_api_request, ) self._async_media_data = self._wrap_request_method( request_retries=self._media_retries, retry_codes=[401, 404, 409], request_func=self._async_media_request, ) @classmethod async def async_from_auth( cls, authorization_code: str, code_verifier: str, *, request_retries: int = DEFAULT_REQUEST_RETRIES, session: ClientSession, ) -> API: """Get an authenticated API object from an Authorization Code and Code Verifier. Args: authorization_code: The Authorization Code. code_verifier: The Code Verifier. request_retries: The default number of request retries to use. session: An optional ``aiohttp`` ``ClientSession``. Returns: An authenticated API object. Raises: InvalidCredentialsError: Raised on invalid username/password. RequestError: Raised on general HTTP error. SimplipyError: Raised on an unknown error. """ api = cls(session=session, request_retries=request_retries) try: token_data = await api._async_api_request( "post", "oauth/token", url_base=AUTH_URL_BASE, headers={"Host": AUTH_URL_HOSTNAME}, json={ "grant_type": "authorization_code", "client_id": DEFAULT_CLIENT_ID, "code_verifier": code_verifier, "code": authorization_code, "redirect_uri": DEFAULT_REDIRECT_URI, }, ) except ClientResponseError as err: if err.status in (401, 403): raise InvalidCredentialsError("Invalid credentials") from err raise RequestError(err) from err except Exception as err: # pylint: disable=broad-except raise SimplipyError(err) from err api._save_token_data_from_response(token_data) await api._async_post_init() return api @classmethod async def async_from_refresh_token( cls, refresh_token: str, *, request_retries: int = DEFAULT_REQUEST_RETRIES, session: ClientSession, ) -> API: """Get an authenticated API object from a refresh token. Args: refresh_token: A refresh token. request_retries: The default number of request retries to use. session: An optional ``aiohttp`` ``ClientSession``. Returns: An authenticated API object. """ api = cls(session=session, request_retries=request_retries) api.refresh_token = refresh_token await api.async_refresh_access_token() await api._async_post_init() return api async def _async_handle_on_backoff(self, _: dict[str, Any]) -> None: """Handle a backoff retry.""" err_info = sys.exc_info() err: ClientResponseError = err_info[1].with_traceback( # type: ignore err_info[2] ) LOGGER.debug("Error during request attempt: %s", err) if err.status == 401 and self._token_last_refreshed: # Calculate the window between now and the last time the token was # refreshed: window = (utcnow() - self._token_last_refreshed).total_seconds() # Since we might have multiple requests (each running their own retry # sequence) land here, we only refresh the access token if it hasn't # been refreshed within the window (and we lock the attempt so other # requests can't try it at the same time): async with self._backoff_refresh_lock: if window < DEFAULT_TOKEN_EXPIRATION_WINDOW: LOGGER.debug("Skipping refresh attempt since window hasn't busted") return LOGGER.info("401 detected; attempting refresh token") await self.async_refresh_access_token() async def _async_post_init(self) -> None: """Perform some post-init actions.""" auth_check_resp = await self._async_api_request("get", "api/authCheck") self.user_id = auth_check_resp["userId"] self.websocket = WebsocketClient(self) async def _async_api_request( self, method: str, endpoint: str, url_base: str = API_URL_BASE, **kwargs: Any ) -> dict[str, Any]: """Make an API request. Args: method: An HTTP method. endpoint: A relative API endpoint. url_base: The base URL of the API. **kwargs: Additional kwargs to send with the request. Returns: An API response payload. """ kwargs.setdefault("headers", {}) kwargs["headers"].setdefault("Host", API_URL_HOSTNAME) kwargs["headers"]["Content-Type"] = "application/json; charset=utf-8" kwargs["headers"]["User-Agent"] = DEFAULT_USER_AGENT if self.access_token: kwargs["headers"]["Authorization"] = f"Bearer {self.access_token}" data: dict[str, Any] | str = {} async with self.session.request( method, f"{url_base}/{endpoint}", **kwargs ) as resp: try: data = await resp.json(content_type=None) except JSONDecodeError: message = await resp.text() data = {"type": "DataParsingError", "message": message} if isinstance(data, str): # In some cases, the SimpliSafe API will return a quoted string # in its response body (e.g., "\"Unauthorized\""), which is # technically valid JSON. Additionally, SimpliSafe sets that # response's Content-Type header to application/json (#smh). # Together, these factors will allow a non-true-JSON payload to # escape the try/except above. So, if we get here, we use the # string value (with quotes removed) to raise an error: message = data.replace('"', "") data = {"error": message} LOGGER.debug("Data received from /%s: %s", endpoint, data) raise_on_data_error(data) resp.raise_for_status() return data async def async_media(self, url: str) -> bytes | None: """Fetch a media file and return raw bytes to caller. Args: url: An absolute url for the media file. Returns: The raw bytes of the media file. """ data = await self._async_media_data(url) return cast(bytes, data["bytes"]) async def _async_media_request(self, url: str) -> dict[str, Any]: """Fetch a media file. Args: url: An absolute url for the media file. Returns: A dict that looks like { "bytes": }. """ async with self.session.request( "get", url, headers={ "User-Agent": DEFAULT_USER_AGENT, "Authorization": f"Bearer {self.access_token}", }, ) as resp: resp.raise_for_status() return {"bytes": await resp.read()} @staticmethod def _handle_on_giveup(_: dict[str, Any]) -> None: """Handle a give up after retries are exhausted. Raises: RequestError: Raised upon an underlying HTTP error. """ err_info = sys.exc_info() err = err_info[1].with_traceback(err_info[2]) # type: ignore raise RequestError(err) from err def _save_token_data_from_response(self, token_data: dict[str, Any]) -> None: """Save token data from a token response. Args: token_data: An API response payload. """ self._token_last_refreshed = utcnow() self.access_token = token_data["access_token"] if refresh_token := token_data.get("refresh_token"): self.refresh_token = refresh_token @staticmethod def is_fatal_error( retriable_error_codes: list[int], ) -> Callable[[ClientResponseError], bool]: """Determine whether a ClientResponseError is fatal and shouldn't be retried. When sending general API requests: 1. 401: We catch this, refresh the access token, and retry the original request. 2. 409: SimpliSafe base stations regular synchronize themselves with the API, which is where this error can occur; we can't control when/how that happens (e.g., we might query the API in the middle of a base station update), so it should be viewed as retryable. But when fetching media files: 3. 404: When fetching media files, you may get a 404 if the media file is not yet available to read. Keep trying however, and it will eventually return a 200. Args: retriable_error_codes: A list of retriable error status codes. Returns: A callable function used by backoff to check for errors. """ def check(err: ClientResponseError) -> bool: """Perform the check. Args: err: An ``aiohttp`` ``ClientResponseError`` Returns: Whether the error is a fatal one. """ if err.status in retriable_error_codes: return False return 400 <= err.status < 500 return check def _wrap_request_method( self, request_retries: int, retry_codes: list[int], request_func: Callable[..., Awaitable[dict[str, Any]]], ) -> Callable[..., Awaitable[dict[str, Any]]]: """Wrap a request method in backoff/retry logic. Args: request_retries: The number of retries to give a failed request. retry_codes: A list of HTTP status codes that cause the retry loop to continue. request_func: A function that performs the request. Returns: A version of the request callable that can do retries. """ return backoff.on_exception( backoff.expo, ClientResponseError, giveup=self.is_fatal_error(retry_codes), # type: ignore[arg-type] jitter=backoff.random_jitter, logger=LOGGER, max_tries=request_retries, on_backoff=self._async_handle_on_backoff, # type: ignore[arg-type] on_giveup=self._handle_on_giveup, # type: ignore[arg-type] )(request_func) def disable_request_retries(self) -> None: """Disable the request retry mechanism.""" self.async_request = self._wrap_request_method( request_retries=1, retry_codes=[401, 409], request_func=self._async_api_request, ) self._async_media_data = self._wrap_request_method( request_retries=1, retry_codes=[401, 404, 409], request_func=self._async_media_request, ) def enable_request_retries(self) -> None: """Enable the request retry mechanism.""" self.async_request = self._wrap_request_method( request_retries=self._request_retries, retry_codes=[401, 409], request_func=self._async_api_request, ) self._async_media_data = self._wrap_request_method( request_retries=self._media_retries, retry_codes=[401, 404, 409], request_func=self._async_media_request, ) def add_refresh_token_callback( self, callback: Callable[[str], Awaitable[None] | None] ) -> Callable[[], None]: """Add a callback that should be triggered when tokens are refreshed. Note that callbacks should expect to receive a refresh token as a parameter. Args: callback: The callback to execute. Returns: A callable to cancel the callback. """ self._refresh_token_callbacks.append(callback) def remove() -> None: """Remove the callback.""" self._refresh_token_callbacks.remove(callback) return remove async def async_get_systems(self) -> dict[int, SystemV2 | SystemV3]: """Get systems associated to the associated SimpliSafe account. In the dict that is returned, the keys are the subscription ID and the values are actual ``System`` objects. Returns: A dictionary of system IDs to System objects. """ systems: dict[int, SystemV2 | SystemV3] = {} await self.async_update_subscription_data() for sid, subscription in self.subscription_data.items(): if not subscription["status"]["hasBaseStation"]: LOGGER.info("Skipping inactive subscription: %s", sid) continue if not subscription["location"].get("system"): LOGGER.error("Skipping subscription with missing system data: %s", sid) continue system: SystemV2 | SystemV3 if subscription["location"]["system"]["version"] == 2: system = SystemV2(self, sid) else: system = SystemV3(self, sid) # Update the system, but don't include subscription data itself, since it # will already have been fetched when the API was first queried: await system.async_update(include_subscription=False) system.generate_device_objects() systems[sid] = system return systems async def async_refresh_access_token(self) -> None: """Initiate a refresh of the access/refresh tokens. Note that this will execute any callbacks added via add_refresh_token_callback. Raises: InvalidCredentialsError: Raised on invalid username/password. RequestError: Raised on general HTTP error. SimplipyError: Raised on an unknown error. """ try: token_data = await self._async_api_request( "post", "oauth/token", url_base=AUTH_URL_BASE, headers={"Host": AUTH_URL_HOSTNAME}, json={ "grant_type": "refresh_token", "client_id": DEFAULT_CLIENT_ID, "refresh_token": self.refresh_token, }, ) except ClientResponseError as err: if err.status in (401, 403): raise InvalidCredentialsError("Invalid refresh token") from err raise RequestError( f"Request error while attempting to refresh access token: {err}" ) from err except Exception as err: # pylint: disable-broad-except raise SimplipyError( f"Error while attempting to refresh access token: {err}" ) from err self._save_token_data_from_response(token_data) for callback in self._refresh_token_callbacks: execute_callback(callback, self.refresh_token) async def async_update_subscription_data(self) -> None: """Get the latest subscription data.""" subscription_resp = await self.async_request( "get", f"users/{self.user_id}/subscriptions", params={"activeOnly": "true"} ) self.subscription_data = { subscription["sid"]: subscription for subscription in subscription_resp["subscriptions"] } simplisafe-python-2024.01.0/simplipy/const.py000066400000000000000000000003651455300150500210430ustar00rootroot00000000000000"""Define package constants.""" import logging LOGGER = logging.getLogger(__package__) DEFAULT_USER_AGENT = ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15" ) simplisafe-python-2024.01.0/simplipy/device/000077500000000000000000000000001455300150500205765ustar00rootroot00000000000000simplisafe-python-2024.01.0/simplipy/device/__init__.py000066400000000000000000000120751455300150500227140ustar00rootroot00000000000000"""Define a base SimpliSafe device.""" from __future__ import annotations from enum import Enum from typing import TYPE_CHECKING, Any, cast from simplipy.const import LOGGER if TYPE_CHECKING: from simplipy.system import System class DeviceTypes(Enum): """Device types based on internal SimpliSafe ID number.""" REMOTE = 0 KEYPAD = 1 KEYCHAIN = 2 PANIC_BUTTON = 3 MOTION = 4 ENTRY = 5 GLASS_BREAK = 6 CARBON_MONOXIDE = 7 SMOKE = 8 LEAK = 9 TEMPERATURE = 10 CAMERA = 12 SIREN = 13 SMOKE_AND_CARBON_MONOXIDE = 14 DOORBELL = 15 LOCK = 16 OUTDOOR_CAMERA = 17 MOTION_V2 = 20 OUTDOOR_ALARM_SECURITY_BELL_BOX = 22 LOCK_KEYPAD = 253 UNKNOWN = 99 def get_device_type_from_data(device_data: dict[str, Any]) -> DeviceTypes: """Get the device type of a raw data payload. Args: device_data: An API response payload. Returns: The device type. """ try: return DeviceTypes(device_data["type"]) except ValueError: LOGGER.error("Unknown device type: %s", device_data["type"]) return DeviceTypes.UNKNOWN class Device: """A base SimpliSafe device. Note that this class shouldn't be instantiated directly; it will be instantiated as appropriate via :meth:`simplipy.API.async_get_systems`. Args: system: A :meth:`simplipy.system.System` object (or one of its subclasses). device_type: The type of device represented. serial: The serial number of the device. """ def __init__(self, system: System, device_type: DeviceTypes, serial: str) -> None: """Initialize. Args: system: A :meth:`simplipy.system.System` object (or one of its subclasses). device_type: The type of device represented. serial: The serial number of the device. """ self._device_type = device_type self._serial = serial self._system = system @property def name(self) -> str: """Return the device name. Returns: The device name. """ return cast(str, self._system.sensor_data[self._serial]["name"]) @property def serial(self) -> str: """Return the device's serial number. Returns: The device serial number. """ return cast(str, self._system.sensor_data[self._serial]["serial"]) @property def type(self) -> DeviceTypes: """Return the device type. Returns: The device type. """ return self._device_type def as_dict(self) -> dict[str, Any]: """Return dictionary version of this device. Returns: Returns a dict representation of this device. """ return { "name": self.name, "serial": self.serial, "type": self.type.value, } async def async_update(self, cached: bool = True) -> None: """Retrieve the latest state/properties for the device. The ``cached`` parameter determines whether the SimpliSafe Cloud uses the last known values retrieved from the base station (``True``) or retrieves new data. Args: cached: Whether to used cached data. """ await self._system.async_update( include_subscription=False, include_settings=False, cached=cached ) class DeviceV3(Device): """A base device for V3 systems. Note that this class shouldn't be instantiated directly; it will be instantiated as appropriate via :meth:`simplipy.API.async_get_systems`. """ @property def error(self) -> bool: """Return the device's error status. Returns: The device's error status. """ return cast( bool, self._system.sensor_data[self._serial]["status"].get("malfunction", False), ) @property def low_battery(self) -> bool: """Return whether the device's battery is low. Returns: The device's low battery status. """ return cast(bool, self._system.sensor_data[self._serial]["flags"]["lowBattery"]) @property def offline(self) -> bool: """Return whether the device is offline. Returns: The device's offline status. """ return cast(bool, self._system.sensor_data[self._serial]["flags"]["offline"]) @property def settings(self) -> dict[str, Any]: """Return the device's settings. Note that these can change based on what device type the device is. Returns: A settings dictionary. """ return cast(dict[str, Any], self._system.sensor_data[self._serial]["setting"]) def as_dict(self) -> dict[str, Any]: """Return dictionary version of this device. Returns: A dict representation of this device. """ return { **super().as_dict(), "error": self.error, "low_battery": self.low_battery, "offline": self.offline, "settings": self.settings, } simplisafe-python-2024.01.0/simplipy/device/camera.py000066400000000000000000000113611455300150500224020ustar00rootroot00000000000000"""Define SimpliSafe cameras (SimpliCams).""" from __future__ import annotations from enum import Enum from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlencode from simplipy.const import LOGGER from simplipy.device import DeviceV3 if TYPE_CHECKING: from simplipy.system.v3 import SystemV3 DEFAULT_AUDIO_ENCODING = "AAC" DEFAULT_MEDIA_URL_BASE = "https://media.simplisafe.com/v1" DEFAULT_VIDEO_WIDTH = 1280 class CameraTypes(Enum): """Define camera types based on internal SimpliSafe ID number.""" CAMERA = 0 DOORBELL = 1 OUTDOOR_CAMERA = 2 UNKNOWN = 99 MODEL_TO_TYPE = { "SS001": CameraTypes.CAMERA, "SS002": CameraTypes.DOORBELL, "SS003": CameraTypes.CAMERA, "SSOBCM4": CameraTypes.OUTDOOR_CAMERA, } class Camera(DeviceV3): """Define a SimpliCam.""" _system: SystemV3 @property def camera_settings(self) -> dict[str, Any]: """Return the camera settings. Returns: A dictionary of camera settings. """ return cast( dict[str, Any], self._system.camera_data[self._serial]["cameraSettings"] ) @property def camera_type(self) -> CameraTypes: """Return the type of camera. Returns: The camera type. """ try: return MODEL_TO_TYPE[self._system.camera_data[self._serial]["model"]] except KeyError: LOGGER.error( "Unknown camera type: %s", self._system.camera_data[self._serial]["model"], ) return CameraTypes.UNKNOWN @property def name(self) -> str: """Return the camera name. Returns: The camera name. """ return cast( str, self._system.camera_data[self._serial]["cameraSettings"]["cameraName"] ) @property def serial(self) -> str: """Return the camera's serial number. Returns: The camera serial number. """ return self._serial @property def shutter_open_when_away(self) -> bool: """Return whether the privacy shutter is open in away mode. Returns: The camera's "shutter open when away" status. """ val = self._system.camera_data[self._serial]["cameraSettings"]["shutterAway"] return cast(bool, val == "open") @property def shutter_open_when_home(self) -> bool: """Return whether the privacy shutter is open in home mode. Returns: The camera's "shutter open when home" status. """ val = self._system.camera_data[self._serial]["cameraSettings"]["shutterHome"] return cast(bool, val == "open") @property def shutter_open_when_off(self) -> bool: """Return whether the privacy shutter is open when the alarm is disarmed. Returns: The camera's "shutter open when off" status. """ val = self._system.camera_data[self._serial]["cameraSettings"]["shutterOff"] return cast(bool, val == "open") @property def status(self) -> str: """Return the camera status. Returns: The camera status. """ return cast(str, self._system.camera_data[self._serial]["status"]) @property def subscription_enabled(self) -> bool: """Return the camera subscription status. Returns: The camera subscription status. """ return cast( bool, self._system.camera_data[self._serial]["subscription"]["enabled"] ) def as_dict(self) -> dict[str, Any]: """Return dictionary version of this device. Returns: A dict representation of this device. """ return { "camera_settings": self.camera_settings, "camera_type": self.camera_type.value, "name": self.name, "serial": self.serial, "shutter_open_when_away": self.shutter_open_when_away, "shutter_open_when_home": self.shutter_open_when_home, "shutter_open_when_off": self.shutter_open_when_off, "status": self.status, "subscription_enabled": self.subscription_enabled, } def video_url( self, width: int = DEFAULT_VIDEO_WIDTH, audio_encoding: str = DEFAULT_AUDIO_ENCODING, **kwargs: Any, ) -> str: """Return the camera video URL. Args: width: The video width. audio_encoding: The audio encoding. kwargs: Additional parameters. Returns: The camera video URL. """ url_params = {"x": width, "audioEncoding": audio_encoding, **kwargs} return f"{DEFAULT_MEDIA_URL_BASE}/{self.serial}/flv?{urlencode(url_params)}" simplisafe-python-2024.01.0/simplipy/device/lock.py000066400000000000000000000114431455300150500221030ustar00rootroot00000000000000"""Define a SimpliSafe lock.""" from __future__ import annotations from collections.abc import Awaitable, Callable from enum import Enum from typing import TYPE_CHECKING, Any, cast from simplipy.const import LOGGER from simplipy.device import DeviceTypes, DeviceV3 if TYPE_CHECKING: from simplipy.system import System class LockStates(Enum): """States that a lock can be in.""" UNLOCKED = 0 LOCKED = 1 JAMMED = 2 UNKNOWN = 99 class Lock(DeviceV3): """A lock that works with V3 systems. Note that this class shouldn't be instantiated directly; it will be instantiated as appropriate via :meth:`simplipy.API.async_get_systems`. Args: request: The request method from the :meth:`simplipy.API` object. system: A :meth:`simplipy.system.System` object (or one of its subclasses). device_type: The type of device represented. serial: The serial number of the device. """ class _InternalStates(Enum): """Define an enum to map internal lock states to values we understand.""" LOCKED = 1 UNLOCKED = 2 def __init__( self, request: Callable[..., Awaitable[dict[str, Any]]], system: System, device_type: DeviceTypes, serial: str, ) -> None: """Initialize. Args: request: The request method from the :meth:`simplipy.API` object. system: A :meth:`simplipy.system.System` object (or one of its subclasses). device_type: The type of device represented. serial: The serial number of the device. """ super().__init__(system, device_type, serial) self._request = request @property def disabled(self) -> bool: """Return whether the lock is disabled. Returns: The lock's disable status. """ return cast( bool, self._system.sensor_data[self._serial]["status"]["lockDisabled"] ) @property def lock_low_battery(self) -> bool: """Return whether the lock's battery is low. Returns: The lock's low battery status. """ return cast( bool, self._system.sensor_data[self._serial]["status"]["lockLowBattery"] ) @property def pin_pad_low_battery(self) -> bool: """Return whether the pin pad's battery is low. Returns: The pinpad's low battery status. """ return cast( bool, self._system.sensor_data[self._serial]["status"]["pinPadLowBattery"] ) @property def pin_pad_offline(self) -> bool: """Return whether the pin pad is offline. Returns: The pinpad's offline status. """ return cast( bool, self._system.sensor_data[self._serial]["status"]["pinPadOffline"] ) @property def state(self) -> LockStates: """Return the current state of the lock. Returns: The lock's state. """ if bool(self._system.sensor_data[self._serial]["status"]["lockJamState"]): return LockStates.JAMMED raw_state = self._system.sensor_data[self._serial]["status"]["lockState"] try: internal_state = self._InternalStates(raw_state) except ValueError: LOGGER.error("Unknown raw lock state: %s", raw_state) return LockStates.UNKNOWN if internal_state == self._InternalStates.LOCKED: return LockStates.LOCKED return LockStates.UNLOCKED def as_dict(self) -> dict[str, Any]: """Return dictionary version of this device. Returns: A dict representation of this device. """ return { **super().as_dict(), "disabled": self.disabled, "lock_low_battery": self.lock_low_battery, "pin_pad_low_battery": self.pin_pad_low_battery, "pin_pad_offline": self.pin_pad_offline, "state": self.state.value, } async def async_lock(self) -> None: """Lock the lock.""" await self._request( "post", f"doorlock/{self._system.system_id}/{self.serial}/state", json={"state": "lock"}, ) # Update the internal state representation: self._system.sensor_data[self._serial]["status"][ "lockState" ] = self._InternalStates.LOCKED.value async def async_unlock(self) -> None: """Unlock the lock.""" await self._request( "post", f"doorlock/{self._system.system_id}/{self.serial}/state", json={"state": "unlock"}, ) # Update the internal state representation: self._system.sensor_data[self._serial]["status"][ "lockState" ] = self._InternalStates.UNLOCKED.value simplisafe-python-2024.01.0/simplipy/device/sensor/000077500000000000000000000000001455300150500221075ustar00rootroot00000000000000simplisafe-python-2024.01.0/simplipy/device/sensor/__init__.py000066400000000000000000000000261455300150500242160ustar00rootroot00000000000000"""Define sensors.""" simplisafe-python-2024.01.0/simplipy/device/sensor/v2.py000066400000000000000000000042501455300150500230110ustar00rootroot00000000000000"""Define a v2 (old) SimpliSafe sensor.""" from typing import cast from simplipy.device import Device, DeviceTypes from simplipy.errors import SimplipyError class SensorV2(Device): """A V2 (old) sensor. Note that this class shouldn't be instantiated directly; it will be instantiated as appropriate via :meth:`simplipy.API.async_get_systems`. """ @property def data(self) -> int: """Return the sensor's current data flag (currently not understood). Returns: The current data flag. """ return cast(int, self._system.sensor_data[self._serial]["sensorData"]) @property def error(self) -> bool: """Return the sensor's error status. Returns: The current error status. """ return cast(bool, self._system.sensor_data[self._serial]["error"]) @property def low_battery(self) -> bool: """Return whether the sensor's battery is low. Returns: The current low battery status. """ return cast( bool, self._system.sensor_data[self._serial].get("battery", "ok") != "ok" ) @property def settings(self) -> bool: """Return the sensor's settings. Returns: The current settings. """ return cast(bool, self._system.sensor_data[self._serial]["setting"]) @property def trigger_instantly(self) -> bool: """Return whether the sensor will trigger instantly. Returns: The "instant trigger" settings. """ return cast(bool, self._system.sensor_data[self._serial]["instant"]) @property def triggered(self) -> bool: """Return whether the sensor has been triggered. Returns: The triggered status. Raises: SimplipyError: Raised when the state can't be determined. """ if self.type == DeviceTypes.ENTRY: return cast( bool, self._system.sensor_data[self._serial].get("entryStatus", "closed") == "open", ) raise SimplipyError(f"Cannot determine triggered state for sensor: {self.name}") simplisafe-python-2024.01.0/simplipy/device/sensor/v3.py000066400000000000000000000054611455300150500230170ustar00rootroot00000000000000"""Define a v3 (new) SimpliSafe sensor.""" from __future__ import annotations from typing import Any, cast from simplipy.device import DeviceTypes, DeviceV3 class SensorV3(DeviceV3): """A V3 (new) sensor. Note that this class shouldn't be instantiated directly; it will be instantiated as appropriate via :meth:`simplipy.API.async_get_systems`. """ @property def trigger_instantly(self) -> bool: """Return whether the sensor will trigger instantly. Returns: The "instant trigger" status. """ return ( self._system.sensor_data[self._serial]["setting"].get( "instantTrigger", False ) is True ) @property def triggered(self) -> bool: """Return whether the sensor has been triggered. Returns: The triggered status. """ if self.type in ( DeviceTypes.CARBON_MONOXIDE, DeviceTypes.ENTRY, DeviceTypes.GLASS_BREAK, DeviceTypes.LEAK, DeviceTypes.MOTION, DeviceTypes.MOTION_V2, DeviceTypes.SMOKE, DeviceTypes.TEMPERATURE, ): return ( self._system.sensor_data[self._serial]["status"].get("triggered") is True ) if self.type == DeviceTypes.SMOKE_AND_CARBON_MONOXIDE: return ( self._system.sensor_data[self._serial]["status"].get( "coTriggered", False ) is True or self._system.sensor_data[self._serial]["status"].get( "smokeTriggered", False ) is True ) return False @property def temperature(self) -> int: """Return the temperature of the sensor (as appropriate). If the sensor isn't a temperature sensor, an ``AttributeError`` will be raised. Returns: The temperature. Raises: AttributeError: Raised when property is read on a non-temperature device. """ if self.type != DeviceTypes.TEMPERATURE: raise AttributeError("Non-temperature sensor cannot have a temperature") return cast( int, self._system.sensor_data[self._serial]["status"]["temperature"] ) def as_dict(self) -> dict[str, Any]: """Return dictionary version of this device. Returns: A dict representation of this device. """ data: dict[str, Any] = { **super().as_dict(), "trigger_instantly": self.trigger_instantly, "triggered": self.triggered, } if self.type == DeviceTypes.TEMPERATURE: data["temperature"] = self.temperature return data simplisafe-python-2024.01.0/simplipy/errors.py000066400000000000000000000037771455300150500212430ustar00rootroot00000000000000"""Define package errors.""" from __future__ import annotations from typing import Any class SimplipyError(Exception): """A base error.""" pass class EndpointUnavailableError(SimplipyError): """An error related to accessing an endpoint that isn't available in the plan.""" pass class InvalidCredentialsError(SimplipyError): """An error related to invalid credentials.""" pass class MaxUserPinsExceededError(SimplipyError): """An error related to exceeding the maximum number of user PINs.""" pass class PinError(SimplipyError): """An error related to invalid PINs or PIN operations.""" pass class RequestError(SimplipyError): """An error related to invalid requests.""" pass class WebsocketError(SimplipyError): """An error related to generic websocket errors.""" pass class CannotConnectError(WebsocketError): """Define a error when the websocket can't be connected to.""" pass class ConnectionClosedError(WebsocketError): """Define a error when the websocket closes unexpectedly.""" pass class ConnectionFailedError(WebsocketError): """Define a error when the websocket connection fails.""" pass class InvalidMessageError(WebsocketError): """Define a error related to an invalid message from the websocket server.""" pass class NotConnectedError(WebsocketError): """Define a error when the websocket isn't properly connected to.""" pass DATA_ERROR_MAP: dict[str, type[SimplipyError]] = { "NoRemoteManagement": EndpointUnavailableError, "PinError": PinError, } def raise_on_data_error(data: dict[str, Any] | None) -> None: """Raise a specific error if the data payload suggests there is one. Args: data: An optional API response payload. Raises: error: A SimplipyError subclass. """ if not data: return if (error_type := data.get("type")) not in DATA_ERROR_MAP: return error = DATA_ERROR_MAP[error_type](data["message"]) raise error simplisafe-python-2024.01.0/simplipy/py.typed000066400000000000000000000000001455300150500210240ustar00rootroot00000000000000simplisafe-python-2024.01.0/simplipy/system/000077500000000000000000000000001455300150500206635ustar00rootroot00000000000000simplisafe-python-2024.01.0/simplipy/system/__init__.py000066400000000000000000000377101455300150500230040ustar00rootroot00000000000000"""Define V2 and V3 SimpliSafe systems.""" from __future__ import annotations from collections.abc import Callable from dataclasses import asdict, dataclass, field from datetime import datetime from enum import Enum from functools import wraps from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast from simplipy.const import LOGGER from simplipy.device.sensor.v2 import SensorV2 from simplipy.device.sensor.v3 import SensorV3 from simplipy.errors import MaxUserPinsExceededError, PinError, SimplipyError from simplipy.util.dt import utc_from_timestamp from simplipy.util.string import convert_to_underscore if TYPE_CHECKING: from simplipy.api import API CONF_DEFAULT = "default" CONF_DURESS_PIN = "duress" CONF_MASTER_PIN = "master" DEFAULT_MAX_USER_PINS = 4 MAX_PIN_LENGTH = 4 PIN_SEQUENCES = {"1234567890", "0987654321"} RESERVED_PIN_LABELS = {CONF_DURESS_PIN, CONF_MASTER_PIN} @dataclass(frozen=True) class SystemNotification: """Define a representation of a system notification.""" notification_id: str text: str category: str code: str timestamp: float received_dt: datetime | None = field(init=False) link: str | None = None link_label: str | None = None def __post_init__(self) -> None: """Run post-init initialization.""" object.__setattr__(self, "received_dt", utc_from_timestamp(self.timestamp)) class SystemStates(Enum): """States that the system can be in.""" ALARM = 1 ALARM_COUNT = 2 AWAY = 3 AWAY_COUNT = 4 ENTRY_DELAY = 5 ERROR = 6 EXIT_DELAY = 7 HOME = 8 HOME_COUNT = 9 OFF = 10 TEST = 11 UNKNOWN = 99 _GuardedCallableReturnType = TypeVar( # pylint: disable=invalid-name "_GuardedCallableReturnType" ) # pylint: disable=consider-alternative-union-syntax _GuardedCallableType = Callable[..., Optional[_GuardedCallableReturnType]] def guard_from_missing_data( *, default_value: _GuardedCallableReturnType | None = None, ) -> Callable[[_GuardedCallableType], _GuardedCallableType]: """Guard a missing property by returning a set value. Args: default_value: The optional default value to assign to the property. Returns: A decorated callable. """ def decorator(func: _GuardedCallableType) -> _GuardedCallableType: """Decorate. Args: func: The callable to decorate. Returns: A decorated callable. """ @wraps(func) def wrapper(system: System) -> _GuardedCallableReturnType | None: """Call the function and handle any issue. Args: system: A :meth:`simplipy.system.System` object (or one of its subclasses). Returns: A decorate callable. """ try: return func(system) except KeyError: LOGGER.warning( "SimpliSafe didn't return data for property: %s", func.__name__ ) return default_value return wrapper return decorator class System: # pylint: disable=too-many-public-methods """Define a system. Note that this class shouldn't be instantiated directly; it will be instantiated as appropriate via :meth:`simplipy.API.async_get_systems`. Args: api: A :meth:`simplipy.API` object. sid: A subscription ID. """ def __init__(self, api: API, sid: int) -> None: """Initialize. Args: api: A :meth:`simplipy.API` object. sid: A subscription ID. """ self._api = api self._sid = sid # These will get filled in after initial update: self._notifications: list[SystemNotification] = [] self._state = SystemStates.UNKNOWN self.sensor_data: dict[str, dict[str, Any]] = {} self.sensors: dict[str, SensorV2 | SensorV3] = {} @property @guard_from_missing_data() def address(self) -> str | None: """Return the street address of the system. Returns: The street address. """ return cast(str, self._api.subscription_data[self._sid]["location"]["street1"]) @property @guard_from_missing_data(default_value=False) def alarm_going_off(self) -> bool: """Return whether the alarm is going off. Returns: Whether the alarm is going off. """ return cast( bool, self._api.subscription_data[self._sid]["location"]["system"]["isAlarming"], ) @property @guard_from_missing_data() def connection_type(self) -> str | None: """Return the system's connection type (cell or WiFi). Returns: The connection type. """ return cast( str, self._api.subscription_data[self._sid]["location"]["system"]["connType"], ) @property def notifications(self) -> list[SystemNotification]: """Return the system's current messages/notifications. Returns: A list of :meth:`simplipy.system.SystemNotification` objects. """ return self._notifications @property def serial(self) -> str: """Return the system's serial number. Returns: The system serial number. """ return cast( str, self._api.subscription_data[self._sid]["location"]["system"]["serial"], ) @property def state(self) -> SystemStates: """Return the current state of the system. Returns: The system state. """ return self._state @property def system_id(self) -> int: """Return the SimpliSafe identifier for this system. Returns: The system ID. """ return self._sid @property @guard_from_missing_data() def temperature(self) -> int | None: """Return the overall temperature measured by the system. Returns: The average system temperature. """ return cast( int, self._api.subscription_data[self._sid]["location"]["system"]["temperature"], ) @property @guard_from_missing_data() def version(self) -> int | None: """Return the system version. Returns: The system version. """ return cast( int, self._api.subscription_data[self._sid]["location"]["system"]["version"], ) async def _async_clear_notifications(self) -> None: """Clear active notifications. Raises: NotImplementedError: Raises when not implemented. """ raise NotImplementedError() async def _async_set_state(self, value: SystemStates) -> None: """Set the system state. Args: value: A :meth:`simplipy.system.SystemStates` object. Raises: NotImplementedError: Raises when not implemented. """ raise NotImplementedError() async def _async_set_updated_pins(self, pins: dict[str, Any]) -> None: """Post new PINs. Args: pins: A dictionary of PINs. Raises: NotImplementedError: Raises when not implemented. """ raise NotImplementedError() async def _async_update_device_data(self, cached: bool = False) -> None: """Update all device data. Args: cached: Whether to update with cached data. Raises: NotImplementedError: Raises when not implemented. """ raise NotImplementedError() async def _async_update_settings_data(self, cached: bool = True) -> None: """Update all settings data. Args: cached: Whether to update with cached data. Raises: NotImplementedError: Raises when not implemented. """ raise NotImplementedError() async def _async_update_subscription_data(self) -> None: """Update subscription data.""" await self._api.async_update_subscription_data() def as_dict(self) -> dict[str, Any]: """Return dictionary version of this device. Returns: A dict representation of this device. """ return { "address": self.address, "alarm_going_off": self.alarm_going_off, "connection_type": self.connection_type, "notifications": [ asdict(notification) for notification in self.notifications ], "serial": self.serial, "state": self.state.value, "system_id": self.system_id, "temperature": self.temperature, "version": self.version, "sensors": [sensor.as_dict() for sensor in self.sensors.values()], } async def async_clear_notifications(self) -> None: """Clear all active notifications. This will remove the notifications from SimpliSafe's cloud, meaning they will no longer visible in the SimpliSafe mobile and web apps. """ if self._notifications: await self._async_clear_notifications() self._notifications = [] def generate_device_objects(self) -> None: """Generate device objects for this system. Raises: NotImplementedError: Raises when not implemented. """ raise NotImplementedError() async def async_get_events( self, from_datetime: datetime | None = None, num_events: int | None = None ) -> list[dict[str, Any]]: """Get events recorded by the base station. If no parameters are provided, this will return the most recent 50 events. Args: from_datetime: The starting datetime (if desired). num_events: The number of events to return. Returns: An API response payload. """ params = {} if from_datetime: params["fromTimestamp"] = round(from_datetime.timestamp()) if num_events: params["numEvents"] = num_events events_resp = await self._api.async_request( "get", f"subscriptions/{self.system_id}/events", params=params ) return cast(list[dict[str, Any]], events_resp.get("events", [])) async def async_get_latest_event(self) -> dict[str, Any]: """Get the most recent system event. Returns: An API response payload. Raises: SimplipyError: Raised when there are no events. """ events = await self.async_get_events(num_events=1) try: return events[0] except IndexError: raise SimplipyError("SimpliSafe didn't return any events") from None async def async_get_pins(self, cached: bool = True) -> dict[str, str]: """Return all of the set PINs, including master and duress. The ``cached`` parameter determines whether the SimpliSafe Cloud uses the last known values retrieved from the base station (``True``) or retrieves new data. Args: cached: Whether to used cached data. Raises: NotImplementedError: Raises when not implemented. """ raise NotImplementedError() async def async_remove_pin(self, pin_or_label: str) -> None: """Remove a PIN by its value or label. Args: pin_or_label: The PIN value or label to remove. Raises: PinError: Raised when attempting to remove a PIN that doesn't exist. """ # Because SimpliSafe's API works by sending the entire payload of PINs, we # can't reasonably check a local cache for up-to-date PIN data; so, we fetch the # latest each time: latest_pins = await self.async_get_pins(cached=False) if pin_or_label in RESERVED_PIN_LABELS: raise PinError(f"Refusing to delete reserved PIN: {pin_or_label}") try: label = next((k for k, v in latest_pins.items() if pin_or_label in (k, v))) except StopIteration: raise PinError(f"Cannot delete nonexistent PIN: {pin_or_label}") from None del latest_pins[label] await self._async_set_updated_pins(latest_pins) async def async_set_away(self) -> None: """Set the system in "Away" mode.""" await self._async_set_state(SystemStates.AWAY) async def async_set_home(self) -> None: """Set the system in "Home" mode.""" await self._async_set_state(SystemStates.HOME) async def async_set_off(self) -> None: """Set the system in "Off" mode.""" await self._async_set_state(SystemStates.OFF) async def async_set_pin(self, label: str, pin: str) -> None: """Set a PIN. Args: label: The label to use for the PIN (shown in the SimpliSafe app). pin: The pin value. Raises: MaxUserPinsExceededError: Raised when attempting to add more than the maximum number of user PINs. PinError: Raised when setting an invalid PIN. """ if len(pin) != MAX_PIN_LENGTH: raise PinError(f"PINs must be {MAX_PIN_LENGTH} digits long") try: int(pin) except ValueError: raise PinError("PINs can only contain numbers") from None if any(pin in sequence for sequence in PIN_SEQUENCES): raise PinError(f"Refusing to create PIN that is a sequence: {pin}") # Because SimpliSafe's API works by sending the entire payload of PINs, we # can't reasonably check a local cache for up-to-date PIN data; so, we fetch the # latest each time. latest_pins = await self.async_get_pins(cached=False) if pin in latest_pins.values(): raise PinError(f"Refusing to create duplicate PIN: {pin}") max_pins = DEFAULT_MAX_USER_PINS + len(RESERVED_PIN_LABELS) if len(latest_pins) == max_pins and label not in RESERVED_PIN_LABELS: raise MaxUserPinsExceededError( f"Refusing to create more than {max_pins} user PINs" ) latest_pins[label] = pin await self._async_set_updated_pins(latest_pins) async def async_update( self, *, include_subscription: bool = True, include_settings: bool = True, include_devices: bool = True, cached: bool = True, ) -> None: """Get the latest system data. The ``cached`` parameter determines whether the SimpliSafe Cloud uses the last known values retrieved from the base station (``True``) or retrieves new data. Args: include_subscription: Whether system state/properties should be updated. include_settings: Whether system settings (like PINs) should be updated. include_devices: whether sensors/locks/etc. should be updated. cached: Whether to used cached data. """ if include_subscription: await self._async_update_subscription_data() if include_settings: await self._async_update_settings_data(cached) if include_devices: await self._async_update_device_data(cached) # Create notifications: self._notifications = [ SystemNotification( raw_message["id"], raw_message["text"], raw_message["category"], raw_message["code"], raw_message["timestamp"], link=raw_message["link"], link_label=raw_message["linkLabel"], ) for raw_message in self._api.subscription_data[self._sid]["location"][ "system" ].get("messages", []) ] # Set the current state: raw_state = self._api.subscription_data[self._sid]["location"]["system"].get( "alarmState" ) try: self._state = SystemStates[convert_to_underscore(raw_state).upper()] except KeyError: LOGGER.error("Unknown raw system state: %s", raw_state) self._state = SystemStates.UNKNOWN simplisafe-python-2024.01.0/simplipy/system/v2.py000066400000000000000000000103551455300150500215700ustar00rootroot00000000000000"""Define a V2 (original) SimpliSafe system.""" from __future__ import annotations from typing import Any from simplipy.const import LOGGER from simplipy.device import get_device_type_from_data from simplipy.device.sensor.v2 import SensorV2 from simplipy.system import ( CONF_DURESS_PIN, CONF_MASTER_PIN, DEFAULT_MAX_USER_PINS, System, SystemStates, ) def create_pin_payload(pins: dict[str, Any]) -> dict[str, dict[str, dict[str, str]]]: """Create the request payload to send for updating PINs. Args: pins: A dictionary of pins. Returns: A SimpliSafe V2 PIN payload. """ duress_pin = pins.pop(CONF_DURESS_PIN) master_pin = pins.pop(CONF_MASTER_PIN) payload = { "pins": {CONF_DURESS_PIN: {"value": duress_pin}, "pin1": {"value": master_pin}} } empty_user_index = len(pins) for idx, (label, pin) in enumerate(pins.items()): payload["pins"][f"pin{idx + 2}"] = {"name": label, "value": pin} for idx in range(DEFAULT_MAX_USER_PINS - empty_user_index): payload["pins"][f"pin{str(idx + 2 + empty_user_index)}"] = { "name": "", "pin": "", } LOGGER.debug("PIN payload: %s", payload) return payload class SystemV2(System): """Define a V2 (original) system.""" async def _async_clear_notifications(self) -> None: """Clear active notifications.""" await self._api.async_request( "delete", f"subscriptions/{self.system_id}/messages" ) async def _async_set_state(self, value: SystemStates) -> None: """Set the state of the system. Args: value: A :meth:`simplipy.system.SystemStates` object. """ await self._api.async_request( "post", f"subscriptions/{self.system_id}/state", params={"state": value.name.lower()}, ) self._state = value async def _async_set_updated_pins(self, pins: dict[str, Any]) -> None: """Post new PINs. Args: pins: A dictionary of PINs. """ await self._api.async_request( "post", f"subscriptions/{self.system_id}/pins", json=create_pin_payload(pins), ) async def _async_update_device_data(self, cached: bool = True) -> None: """Update all device data. Args: cached: Whether to update with cached data. """ sensor_resp = await self._api.async_request( "get", f"subscriptions/{self.system_id}/settings", params={"settingsType": "all", "cached": str(cached).lower()}, ) for sensor in sensor_resp.get("settings", {}).get("sensors", []): if not sensor: continue self.sensor_data[sensor["serial"]] = sensor async def _async_update_settings_data(self, cached: bool = True) -> None: """Update all settings data. Args: cached: Whether to update with cached data. """ pass def generate_device_objects(self) -> None: """Generate device objects for this system.""" for serial, data in self.sensor_data.items(): sensor_type = get_device_type_from_data(data) self.sensors[serial] = SensorV2(self, sensor_type, serial) async def async_get_pins(self, cached: bool = True) -> dict[str, str]: """Return all of the set PINs, including master and duress. The ``cached`` parameter determines whether the SimpliSafe Cloud uses the last known values retrieved from the base station (``True``) or retrieves new data. Args: cached: Whether to update with cached data. Returns: A dictionary of PINs. """ pins_resp = await self._api.async_request( "get", f"subscriptions/{self.system_id}/pins", params={"settingsType": "all", "cached": str(cached).lower()}, ) pins = { CONF_MASTER_PIN: pins_resp["pins"].pop("pin1")["value"], CONF_DURESS_PIN: pins_resp["pins"].pop("duress")["value"], } for user_pin in [p for p in pins_resp["pins"].values() if p["value"]]: pins[user_pin["name"]] = user_pin["value"] return pins simplisafe-python-2024.01.0/simplipy/system/v3.py000066400000000000000000000475071455300150500216020ustar00rootroot00000000000000"""Define a V3 (new) SimpliSafe system.""" from __future__ import annotations from datetime import datetime, timedelta from enum import Enum from typing import TYPE_CHECKING, Any, Final, cast import voluptuous as vol from simplipy.const import LOGGER from simplipy.device import DeviceTypes, get_device_type_from_data from simplipy.device.camera import Camera from simplipy.device.lock import Lock from simplipy.device.sensor.v3 import SensorV3 from simplipy.system import ( CONF_DURESS_PIN, CONF_MASTER_PIN, DEFAULT_MAX_USER_PINS, System, SystemStates, guard_from_missing_data, ) from simplipy.util.dt import utcnow if TYPE_CHECKING: from simplipy.api import API CONF_ALARM_DURATION = "alarm_duration" CONF_ALARM_VOLUME = "alarm_volume" CONF_CHIME_VOLUME = "chime_volume" CONF_ENTRY_DELAY_AWAY = "entry_delay_away" CONF_ENTRY_DELAY_HOME = "entry_delay_home" CONF_EXIT_DELAY_AWAY = "exit_delay_away" CONF_EXIT_DELAY_HOME = "exit_delay_home" CONF_LIGHT = "light" CONF_VOICE_PROMPT_VOLUME = "voice_prompt_volume" DEFAULT_LOCK_STATE_CHANGE_WINDOW = timedelta(seconds=15) SYSTEM_PROPERTIES_VALUE_MAP = { CONF_ALARM_DURATION: "alarmDuration", CONF_ALARM_VOLUME: "alarmVolume", CONF_CHIME_VOLUME: "doorChime", CONF_ENTRY_DELAY_AWAY: "entryDelayAway", CONF_ENTRY_DELAY_HOME: "entryDelayHome", CONF_EXIT_DELAY_AWAY: "exitDelayAway", CONF_EXIT_DELAY_HOME: "exitDelayHome", CONF_LIGHT: "light", CONF_VOICE_PROMPT_VOLUME: "voicePrompts", } MIN_ALARM_DURATION: Final = 30 MAX_ALARM_DURATION: Final = 480 MIN_ENTRY_DELAY_AWAY: Final = 30 MAX_ENTRY_DELAY_AWAY: Final = 255 MIN_ENTRY_DELAY_HOME: Final = 0 MAX_ENTRY_DELAY_HOME: Final = 255 MIN_EXIT_DELAY_AWAY: Final = 45 MAX_EXIT_DELAY_AWAY: Final = 255 MIN_EXIT_DELAY_HOME: Final = 0 MAX_EXIT_DELAY_HOME: Final = 255 class Volume(Enum): """Define a representation of a SimpliSafe volume level.""" OFF = 0 LOW = 1 MEDIUM = 2 HIGH = 3 SYSTEM_PROPERTIES_PAYLOAD_SCHEMA = vol.Schema( { vol.Optional(CONF_ALARM_DURATION): vol.All( vol.Coerce(int), vol.Range(min=MIN_ALARM_DURATION, max=MAX_ALARM_DURATION) ), vol.Optional(CONF_ALARM_VOLUME): vol.All(Volume, lambda volume: volume.value), vol.Optional(CONF_CHIME_VOLUME): vol.All(Volume, lambda volume: volume.value), vol.Optional(CONF_ENTRY_DELAY_AWAY): vol.All( vol.Coerce(int), vol.Range(min=MIN_ENTRY_DELAY_AWAY, max=MAX_ENTRY_DELAY_AWAY), ), vol.Optional(CONF_ENTRY_DELAY_HOME): vol.All( vol.Coerce(int), vol.Range(min=MIN_ENTRY_DELAY_HOME, max=MAX_ENTRY_DELAY_HOME), ), vol.Optional(CONF_EXIT_DELAY_AWAY): vol.All( vol.Coerce(int), vol.Range(min=MIN_EXIT_DELAY_AWAY, max=MAX_EXIT_DELAY_AWAY) ), vol.Optional(CONF_EXIT_DELAY_HOME): vol.All( vol.Coerce(int), vol.Range(min=MIN_EXIT_DELAY_HOME, max=MAX_EXIT_DELAY_HOME) ), vol.Optional(CONF_LIGHT): bool, vol.Optional(CONF_VOICE_PROMPT_VOLUME): vol.All( Volume, lambda volume: volume.value ), } ) def create_pin_payload(pins: dict) -> dict[str, dict[str, dict[str, str]]]: """Create the request payload to send for updating PINs. Args: pins: A dictionary of pins. Returns: A SimpliSafe V3 PIN payload. """ duress_pin = pins.pop(CONF_DURESS_PIN) master_pin = pins.pop(CONF_MASTER_PIN) payload = { "pins": { CONF_DURESS_PIN: {"pin": duress_pin}, CONF_MASTER_PIN: {"pin": master_pin}, } } user_pins = {} for idx, (label, pin) in enumerate(pins.items()): user_pins[str(idx)] = {"name": label, "pin": pin} empty_user_index = len(pins) for idx in range(DEFAULT_MAX_USER_PINS - empty_user_index): user_pins[str(idx + empty_user_index)] = { "name": "", "pin": "", } payload["pins"]["users"] = user_pins LOGGER.debug("PIN payload: %s", payload) return payload class SystemV3(System): # pylint: disable=too-many-public-methods """Define a V3 (new) system. Note that this class shouldn't be instantiated directly; it will be instantiated as appropriate via :meth:`simplipy.API.async_get_systems`. Args: api: A :meth:`simplipy.API` object. sid: A subscription ID. """ def __init__(self, api: API, system_id: int) -> None: """Initialize. Args: api: A :meth:`simplipy.API` object. system_id: A system ID. """ super().__init__(api, system_id) self._last_state_change_dt: datetime | None = None # This will be filled in by the appropriate data update methods: self.camera_data: dict[str, dict] = self._generate_camera_data() self.cameras: dict[str, Camera] = {} self.locks: dict[str, Lock] = {} self.settings_data: dict[str, dict] = {} @property @guard_from_missing_data() def alarm_duration(self) -> int | None: """Return the number of seconds an activated alarm will sound for. Returns: The alarm duration. """ return cast( int, self.settings_data["settings"]["normal"][ SYSTEM_PROPERTIES_VALUE_MAP["alarm_duration"] ], ) @property @guard_from_missing_data() def alarm_volume(self) -> Volume: """Return the volume level of the alarm. Returns: The alarm volume. """ return Volume( int( self.settings_data["settings"]["normal"][ SYSTEM_PROPERTIES_VALUE_MAP["alarm_volume"] ] ) ) @property @guard_from_missing_data() def battery_backup_power_level(self) -> int: """Return the power rating of the battery backup. Returns: The battery backup power rating. """ return cast(int, self.settings_data["basestationStatus"]["backupBattery"]) @property @guard_from_missing_data() def chime_volume(self) -> Volume: """Return the volume level of the door chime. Returns: The door chime volume. """ return Volume( int( self.settings_data["settings"]["normal"][ SYSTEM_PROPERTIES_VALUE_MAP["chime_volume"] ] ) ) @property @guard_from_missing_data() def entry_delay_away(self) -> int: """Return the number of seconds to delay when returning to an "away" alarm. Returns: The entry delay when returning to an "away" alarm. """ return cast( int, self.settings_data["settings"]["normal"][ SYSTEM_PROPERTIES_VALUE_MAP["entry_delay_away"] ], ) @property @guard_from_missing_data() def entry_delay_home(self) -> int: """Return the number of seconds to delay when returning to a "home" alarm. Returns: The entry delay when returning to a "home" alarm. """ return cast( int, self.settings_data["settings"]["normal"][ SYSTEM_PROPERTIES_VALUE_MAP["entry_delay_home"] ], ) @property @guard_from_missing_data() def exit_delay_away(self) -> int: """Return the number of seconds to delay when exiting an "away" alarm. Returns: The exit delay when exiting an "away" alarm. """ return cast( int, self.settings_data["settings"]["normal"][ SYSTEM_PROPERTIES_VALUE_MAP["exit_delay_away"] ], ) @property @guard_from_missing_data() def exit_delay_home(self) -> int: """Return the number of seconds to delay when exiting an "home" alarm. Returns: The exit delay when exiting a "home" alarm. """ return cast( int, self.settings_data["settings"]["normal"][ SYSTEM_PROPERTIES_VALUE_MAP["exit_delay_home"] ], ) @property @guard_from_missing_data() def gsm_strength(self) -> int: """Return the signal strength of the cell antenna. Returns: The cell antenna strength. """ return cast(int, self.settings_data["basestationStatus"]["gsmRssi"]) @property @guard_from_missing_data() def light(self) -> bool: """Return whether the base station light is on. Returns: The light status. """ return cast( bool, self.settings_data["settings"]["normal"][ SYSTEM_PROPERTIES_VALUE_MAP["light"] ], ) @property @guard_from_missing_data(default_value=False) def offline(self) -> bool: """Return whether the system is offline. Returns: The offline status. """ return cast( bool, self._api.subscription_data[self._sid]["location"]["system"]["isOffline"], ) @property @guard_from_missing_data(default_value=False) def power_outage(self) -> bool: """Return whether the system is experiencing a power outage. Returns: The power outage status. """ return cast( bool, self._api.subscription_data[self._sid]["location"]["system"]["powerOutage"], ) @property @guard_from_missing_data(default_value=False) def rf_jamming(self) -> bool: """Return whether the base station is noticing RF jamming. Returns: The RF jamming status. """ return cast(bool, self.settings_data["basestationStatus"]["rfJamming"]) @property @guard_from_missing_data() def voice_prompt_volume(self) -> Volume: """Return the volume level of the voice prompt. Returns: The voice prompt volume. """ return Volume( int( self.settings_data["settings"]["normal"][ SYSTEM_PROPERTIES_VALUE_MAP["voice_prompt_volume"] ] ) ) @property @guard_from_missing_data() def wall_power_level(self) -> int: """Return the power rating of the A/C outlet. Returns: The A/C power rating. """ return cast(int, self.settings_data["basestationStatus"]["wallPower"]) @property @guard_from_missing_data() def wifi_ssid(self) -> str: """Return the ssid of the base station. Returns: The connected SSID. """ return cast(str, self.settings_data["settings"]["normal"]["wifiSSID"]) @property @guard_from_missing_data() def wifi_strength(self) -> int: """Return the signal strength of the wifi antenna. Returns: The WiFi strength. """ return cast(int, self.settings_data["basestationStatus"]["wifiRssi"]) async def _async_clear_notifications(self) -> None: """Clear active notifications.""" await self._api.async_request( "delete", f"ss3/subscriptions/{self.system_id}/messages" ) async def _async_set_state(self, value: SystemStates) -> None: """Set the state of the system. Args: value: A :meth:`simplipy.system.SystemStates` object. """ await self._api.async_request( "post", f"ss3/subscriptions/{self.system_id}/state/{value.name.lower()}" ) self._state = value self._last_state_change_dt = utcnow() async def _async_set_updated_pins(self, pins: dict[str, Any]) -> None: """Post new PINs. Args: pins: A dictionary of PINs. """ self.settings_data = await self._api.async_request( "post", f"ss3/subscriptions/{self.system_id}/settings/pins", json=create_pin_payload(pins), ) async def _async_update_device_data(self, cached: bool = True) -> None: """Update all device data. Args: cached: Whether to update with cached data. """ sensor_resp = await self._api.async_request( "get", f"ss3/subscriptions/{self.system_id}/sensors", params={"forceUpdate": str(not cached).lower()}, ) self.sensor_data = { sensor["serial"]: sensor for sensor in sensor_resp.get("sensors", []) } async def _async_update_settings_data(self, cached: bool = True) -> None: """Update all settings data. Args: cached: Whether to update with cached data. """ settings_resp = await self._api.async_request( "get", f"ss3/subscriptions/{self.system_id}/settings/normal", params={"forceUpdate": str(not cached).lower()}, ) if settings_resp: self.settings_data = settings_resp async def _async_update_subscription_data(self) -> None: """Update subscription data.""" await super()._async_update_subscription_data() self.camera_data = self._generate_camera_data() def _generate_camera_data(self) -> dict[str, dict]: """Generate usable, hashable camera data from subscription data. This method exists because the SimpliSafe API includes camera data with the subscription (and not with other devices); by splitting this out, we can separate this action from updating the subscription data itself. Returns: A dictionary of camera UUID to camera data. """ return { camera["uuid"]: camera for camera in self._api.subscription_data[self._sid]["location"][ "system" ].get("cameras", []) } def as_dict(self) -> dict[str, Any]: """Return dictionary version of this device. Returns: A dict representation of this device. """ data = { **super().as_dict(), "alarm_duration": self.alarm_duration, "battery_backup_power_level": self.battery_backup_power_level, "cameras": [camera.as_dict() for camera in self.cameras.values()], "entry_delay_away": self.entry_delay_away, "entry_delay_home": self.entry_delay_home, "exit_delay_away": self.exit_delay_away, "exit_delay_home": self.exit_delay_home, "gsm_strength": self.gsm_strength, "light": self.light, "locks": [lock.as_dict() for lock in self.locks.values()], "offline": self.offline, "power_outage": self.power_outage, "rf_jamming": self.rf_jamming, "wall_power_level": self.wall_power_level, "wifi_ssid": self.wifi_ssid, "wifi_strength": self.wifi_strength, } for key, volume_enum in ( ("alarm_volume", self.alarm_volume), ("chime_volume", self.chime_volume), ("voice_prompt_volume", self.voice_prompt_volume), ): if volume_enum: data[key] = volume_enum.value return data def generate_device_objects(self) -> None: """Generate device objects for this system.""" for serial, sensor in self.sensor_data.items(): if (sensor_type := get_device_type_from_data(sensor)) == DeviceTypes.LOCK: self.locks[serial] = Lock( self._api.async_request, self, sensor_type, serial ) else: self.sensors[serial] = SensorV3(self, sensor_type, serial) for serial in self.camera_data: self.cameras[serial] = Camera(self, DeviceTypes.CAMERA, serial) async def async_get_pins(self, cached: bool = True) -> dict[str, str]: """Return all of the set PINs, including master and duress. The ``cached`` parameter determines whether the SimpliSafe Cloud uses the last known values retrieved from the base station (``True``) or retrieves new data. Args: cached: Whether to update with cached data. Returns: A dictionary of PINs. """ await self._async_update_settings_data(cached) pins = { CONF_MASTER_PIN: self.settings_data["settings"]["pins"]["master"]["pin"], CONF_DURESS_PIN: self.settings_data["settings"]["pins"]["duress"]["pin"], } for user_pin in [ p for p in self.settings_data["settings"]["pins"]["users"] if p["pin"] ]: pins[user_pin["name"]] = user_pin["pin"] return pins async def async_set_properties( self, properties: dict[str, bool | int | Volume] ) -> None: """Set various system properties. Volume properties should take values from :meth:`simplipy.system.v3.Volume`. The following properties can be set: 1. alarm_duration (in seconds): 30-480 2. alarm_volume: Volume.OFF, Volume.LOW, Volume.MEDIUM, Volume.HIGH 3. chime_volume: Volume.OFF, Volume.LOW, Volume.MEDIUM, Volume.HIGH 4. entry_delay_away (in seconds): 30-255 5. entry_delay_home (in seconds): 0-255 6. exit_delay_away (in seconds): 45-255 7. exit_delay_home (in seconds): 0-255 8. light: True or False 9. voice_prompt_volume: Volume.OFF, Volume.LOW, Volume.MEDIUM, Volume.HIGH Args: properties: The system properties to set. Raises: ValueError: Raised on invalid properties. """ try: parsed_properties = SYSTEM_PROPERTIES_PAYLOAD_SCHEMA(properties) except vol.Invalid as err: raise ValueError( f"Using invalid values for system properties ({properties}): {err}" ) from None settings_resp = await self._api.async_request( "post", f"ss3/subscriptions/{self.system_id}/settings/normal", json={ "normal": { SYSTEM_PROPERTIES_VALUE_MAP[prop]: value for prop, value in parsed_properties.items() } }, ) if settings_resp: self.settings_data = settings_resp async def async_update( self, *, include_subscription: bool = True, include_settings: bool = True, include_devices: bool = True, cached: bool = True, ) -> None: """Get the latest system data. The ``cached`` parameter determines whether the SimpliSafe Cloud uses the last known values retrieved from the base station (``True``) or retrieves new data. Args: include_subscription: Whether system state/properties should be updated. include_settings: Whether system settings (like PINs) should be updated. include_devices: whether sensors/locks/etc. should be updated. cached: Whether to used cached data. """ if ( self.locks and self._last_state_change_dt and utcnow() <= self._last_state_change_dt + DEFAULT_LOCK_STATE_CHANGE_WINDOW ): # The SimpliSafe cloud API currently has a bug wherein systems with locks # will audible announce that those locks aren't responding when the system # is updated within a certain window (around 15 seconds) of the system # changing state. Oof. So, we refuse to update inside that window: LOGGER.info( "Skipping system update within %s seconds from last system arm/disarm", DEFAULT_LOCK_STATE_CHANGE_WINDOW, ) return await super().async_update( include_subscription=include_subscription, include_settings=include_settings, include_devices=include_devices, cached=cached, ) simplisafe-python-2024.01.0/simplipy/util/000077500000000000000000000000001455300150500203145ustar00rootroot00000000000000simplisafe-python-2024.01.0/simplipy/util/__init__.py000066400000000000000000000013621455300150500224270ustar00rootroot00000000000000"""Define utility modules.""" from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from typing import Any, Optional # pylint: disable=consider-alternative-union-syntax CallbackType = Callable[..., Optional[Awaitable[None]]] def execute_callback(callback: CallbackType, *args: Any) -> None: """Schedule a callback to be called. The callback is expected to be short-lived, as no sort of task management takes place – this is a fire-and-forget system. Args: callback: The callback to execute. *args: Any arguments to pass to the callback. """ if asyncio.iscoroutinefunction(callback): asyncio.create_task(callback(*args)) else: callback(*args) simplisafe-python-2024.01.0/simplipy/util/auth.py000066400000000000000000000045741455300150500216410ustar00rootroot00000000000000"""Define some utilities to work with SimpliSafe's authentication mechanism.""" from __future__ import annotations import base64 import hashlib import os import re import urllib.parse from uuid import uuid4 AUTH_URL_HOSTNAME = "auth.simplisafe.com" AUTH_URL_BASE = f"https://{AUTH_URL_HOSTNAME}" AUTH_URL_LOGIN = f"{AUTH_URL_BASE}/authorize" DEFAULT_AUTH0_CLIENT = ( "eyJ2ZXJzaW9uIjoiMi4zLjIiLCJuYW1lIjoiQXV0aDAuc3dpZnQiLCJlbnYiOnsic3dpZnQiOiI1LngiLC" "JpT1MiOiIxNi4zIn19" ) DEFAULT_CLIENT_ID = "42aBZ5lYrVW12jfOuu3CQROitwxg9sN5" DEFAULT_REDIRECT_URI = ( "com.simplisafe.mobile://auth.simplisafe.com/ios/com.simplisafe.mobile/callback" ) DEFAULT_SCOPE = ( "offline_access email openid https://api.simplisafe.com/scopes/user:platform" ) def get_auth_url(code_challenge: str, *, device_id: str | None = None) -> str: """Get a SimpliSafe authorization URL to visit in a browser. Args: code_challenge: A code challenge generated by :meth:`simplipy.util.auth.get_auth0_code_challenge`. device_id: A UUID to identify the device getting the auth URL. If not provided, a random UUID will be generated. Returns: An authorization URL. """ params = { "audience": "https://api.simplisafe.com/", "auth0Client": DEFAULT_AUTH0_CLIENT, "client_id": DEFAULT_CLIENT_ID, "code_challenge": code_challenge, "code_challenge_method": "S256", "device": "iPhone", "device_id": (device_id or str(uuid4())).upper(), "redirect_uri": DEFAULT_REDIRECT_URI, "response_type": "code", "scope": DEFAULT_SCOPE, } return f"{AUTH_URL_LOGIN}?{urllib.parse.urlencode(params)}" def get_auth0_code_challenge(code_verifier: str) -> str: """Get an Auth0 code challenge from a code verifier. Args: code_verifier: A code challenge generated by :meth:`simplipy.util.auth.get_auth0_code_verifier`. Returns: A code challenge. """ verifier = hashlib.sha256(code_verifier.encode("utf-8")).digest() challenge = base64.urlsafe_b64encode(verifier).decode("utf-8") return challenge.replace("=", "") def get_auth0_code_verifier() -> str: """Get an Auth0 code verifier. Returns: A code verifier. """ verifier = base64.urlsafe_b64encode(os.urandom(40)).decode("utf-8") return re.sub("[^a-zA-Z0-9]+", "", verifier) simplisafe-python-2024.01.0/simplipy/util/dt.py000066400000000000000000000012111455300150500212700ustar00rootroot00000000000000"""Define datetime utilities.""" from datetime import datetime try: from datetime import UTC except ImportError: # In place for support of Python 3.10 from datetime import timezone UTC = timezone.utc def utcnow() -> datetime: """Return the current UTC time. Returns: A ``datetime.datetime`` object. """ return datetime.now(tz=UTC) def utc_from_timestamp(timestamp: float) -> datetime: """Return a UTC time from a timestamp. Args: timestamp: The epoch to convert. Returns: A parsed ``datetime.datetime`` object. """ return datetime.fromtimestamp(timestamp, tz=UTC) simplisafe-python-2024.01.0/simplipy/util/string.py000066400000000000000000000005631455300150500222000ustar00rootroot00000000000000"""Define various string utilities.""" import re def convert_to_underscore(string: str) -> str: """Convert thisString to this_string. Args: string: The string to convert. Returns: A converted string. """ first_pass = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", string) return re.sub("([a-z0-9])([A-Z])", r"\1_\2", first_pass).lower() simplisafe-python-2024.01.0/simplipy/websocket.py000066400000000000000000000365541455300150500217140ustar00rootroot00000000000000"""Define a connection to the SimpliSafe websocket.""" from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from dataclasses import InitVar, dataclass, field from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, Final, cast from aiohttp import ClientWebSocketResponse, WSMsgType from aiohttp.client_exceptions import ClientError from simplipy.const import DEFAULT_USER_AGENT, LOGGER from simplipy.device import DeviceTypes from simplipy.errors import ( CannotConnectError, ConnectionClosedError, ConnectionFailedError, InvalidMessageError, NotConnectedError, ) from simplipy.util import CallbackType, execute_callback from simplipy.util.dt import utc_from_timestamp, utcnow if TYPE_CHECKING: from simplipy import API WEBSOCKET_SERVER_URL = "wss://socketlink.prd.aser.simplisafe.com" DEFAULT_WATCHDOG_TIMEOUT = timedelta(minutes=5) EVENT_ALARM_CANCELED: Final = "alarm_canceled" EVENT_ALARM_TRIGGERED: Final = "alarm_triggered" EVENT_ARMED_AWAY: Final = "armed_away" EVENT_ARMED_AWAY_BY_KEYPAD: Final = "armed_away_by_keypad" EVENT_ARMED_AWAY_BY_REMOTE: Final = "armed_away_by_remote" EVENT_ARMED_HOME: Final = "armed_home" EVENT_AUTOMATIC_TEST: Final = "automatic_test" EVENT_AWAY_EXIT_DELAY_BY_KEYPAD: Final = "away_exit_delay_by_keypad" EVENT_AWAY_EXIT_DELAY_BY_REMOTE: Final = "away_exit_delay_by_remote" EVENT_CAMERA_MOTION_DETECTED: Final = "camera_motion_detected" EVENT_CONNECTION_LOST: Final = "connection_lost" EVENT_CONNECTION_RESTORED: Final = "connection_restored" EVENT_DISARMED_BY_KEYPAD: Final = "disarmed_by_keypad" EVENT_DISARMED_BY_REMOTE: Final = "disarmed_by_remote" EVENT_DOORBELL_DETECTED: Final = "doorbell_detected" EVENT_DEVICE_TEST: Final = "device_test" EVENT_ENTRY_DELAY: Final = "entry_delay" EVENT_HOME_EXIT_DELAY: Final = "home_exit_delay" EVENT_LOCK_ERROR: Final = "lock_error" EVENT_LOCK_LOCKED: Final = "lock_locked" EVENT_LOCK_UNLOCKED: Final = "lock_unlocked" EVENT_POWER_OUTAGE: Final = "power_outage" EVENT_POWER_RESTORED: Final = "power_restored" EVENT_SECRET_ALERT_TRIGGERED: Final = "secret_alert_triggered" EVENT_SENSOR_NOT_RESPONDING: Final = "sensor_not_responding" EVENT_SENSOR_PAIRED_AND_NAMED: Final = "sensor_paired_and_named" EVENT_SENSOR_RESTORED: Final = "sensor_restored" EVENT_USER_INITIATED_TEST: Final = "user_initiated_test" EVENT_MAPPING = { 1110: EVENT_ALARM_TRIGGERED, 1120: EVENT_ALARM_TRIGGERED, 1132: EVENT_ALARM_TRIGGERED, 1134: EVENT_ALARM_TRIGGERED, 1154: EVENT_ALARM_TRIGGERED, 1159: EVENT_ALARM_TRIGGERED, 1162: EVENT_ALARM_TRIGGERED, 1170: EVENT_CAMERA_MOTION_DETECTED, 1301: EVENT_POWER_OUTAGE, 1350: EVENT_CONNECTION_LOST, 1381: EVENT_SENSOR_NOT_RESPONDING, 1400: EVENT_DISARMED_BY_KEYPAD, 1406: EVENT_ALARM_CANCELED, 1407: EVENT_DISARMED_BY_REMOTE, 1409: EVENT_SECRET_ALERT_TRIGGERED, 1429: EVENT_ENTRY_DELAY, 1458: EVENT_DOORBELL_DETECTED, 1531: EVENT_SENSOR_PAIRED_AND_NAMED, 1601: EVENT_USER_INITIATED_TEST, 1602: EVENT_AUTOMATIC_TEST, 1604: EVENT_DEVICE_TEST, 3301: EVENT_POWER_RESTORED, 3350: EVENT_CONNECTION_RESTORED, 3381: EVENT_SENSOR_RESTORED, 3401: EVENT_ARMED_AWAY_BY_KEYPAD, 3407: EVENT_ARMED_AWAY_BY_REMOTE, 3441: EVENT_ARMED_HOME, 3481: EVENT_ARMED_AWAY, 3487: EVENT_ARMED_AWAY, 3491: EVENT_ARMED_HOME, 9401: EVENT_AWAY_EXIT_DELAY_BY_KEYPAD, 9407: EVENT_AWAY_EXIT_DELAY_BY_REMOTE, 9441: EVENT_HOME_EXIT_DELAY, 9700: EVENT_LOCK_UNLOCKED, 9701: EVENT_LOCK_LOCKED, 9703: EVENT_LOCK_ERROR, } class Watchdog: """Define a watchdog to kick the websocket connection at intervals.""" def __init__( self, action: Callable[..., Awaitable[None]], timeout: timedelta = DEFAULT_WATCHDOG_TIMEOUT, ): """Initialize. Args: action: The coroutine function to call when the watchdog expires. timeout: The time duration before the watchdog times out. """ self._action = action self._action_task: asyncio.Task | None = None self._loop = asyncio.get_running_loop() self._timeout_seconds = timeout.total_seconds() self._timer_task: asyncio.TimerHandle | None = None def _on_expire(self) -> None: """Log and act when the watchdog expires.""" LOGGER.info("Websocket watchdog expired") execute_callback(self._action) def cancel(self) -> None: """Cancel the watchdog.""" if self._timer_task: self._timer_task.cancel() self._timer_task = None def trigger(self) -> None: """Trigger the watchdog.""" LOGGER.info( "Websocket watchdog triggered – sleeping for %s seconds", self._timeout_seconds, ) if self._timer_task: self._timer_task.cancel() self._timer_task = self._loop.call_later(self._timeout_seconds, self._on_expire) @dataclass(frozen=True) class WebsocketEvent: """Define a representation of a message.""" event_cid: InitVar[int] info: str system_id: int _raw_timestamp: float _video: dict | None _vid: str | None event_type: str | None = field(init=False) timestamp: datetime = field(init=False) media_urls: dict[str, str] | None = field(init=False) changed_by: str | None = None sensor_name: str | None = None sensor_serial: str | None = None sensor_type: DeviceTypes | None = None def __post_init__(self, event_cid: int) -> None: """Run post-init initialization. Args: event_cid: A SimpliSafe code for a particular event. """ if event_cid in EVENT_MAPPING: object.__setattr__(self, "event_type", EVENT_MAPPING[event_cid]) else: LOGGER.warning( 'Encountered unknown websocket event type: %s ("%s"). Please report it ' "at https://github.com/bachya/simplisafe-python/issues.", event_cid, self.info, ) object.__setattr__(self, "event_type", None) object.__setattr__(self, "timestamp", utc_from_timestamp(self._raw_timestamp)) if self.sensor_type is not None: try: object.__setattr__(self, "sensor_type", DeviceTypes(self.sensor_type)) except ValueError: LOGGER.warning( 'Encountered unknown device type: %s ("%s"). Please report it at' "https://github.com/home-assistant/home-assistant/issues.", self.sensor_type, self.info, ) object.__setattr__(self, "sensor_type", None) if self._vid is not None and self._video is not None: object.__setattr__( self, "media_urls", { "image_url": self._video[self._vid]["_links"]["snapshot/jpg"][ "href" ], "clip_url": self._video[self._vid]["_links"]["download/mp4"][ "href" ], "hls_url": self._video[self._vid]["_links"]["playback/hls"]["href"], }, ) object.__setattr__(self, "_vid", None) object.__setattr__(self, "_video", None) else: object.__setattr__(self, "media_urls", None) def websocket_event_from_payload(payload: dict[str, Any]) -> WebsocketEvent: """Create a Message object from a websocket event payload. Args: payload: A raw websocket response payload. Returns: A parsed WebsocketEvent object. """ return WebsocketEvent( payload["data"]["eventCid"], payload["data"]["info"], payload["data"]["sid"], payload["data"]["eventTimestamp"], payload["data"].get("video"), payload["data"].get("videoStartedBy"), changed_by=payload["data"]["pinName"], sensor_name=payload["data"]["sensorName"], sensor_serial=payload["data"]["sensorSerial"], sensor_type=payload["data"]["sensorType"], ) class WebsocketClient: """A websocket connection to the SimpliSafe cloud. Note that this class shouldn't be instantiated directly; it will be instantiated as appropriate via :meth:`simplipy.API.async_from_auth` or :meth:`simplipy.API.async_from_refresh_token`. Args: api: A simplipy API object. """ def __init__(self, api: API) -> None: """Initialize. Args: api: A simplipy API object. """ self._api = api self._connect_callbacks: list[CallbackType] = [] self._disconnect_callbacks: list[CallbackType] = [] self._event_callbacks: list[CallbackType] = [] self._loop = asyncio.get_running_loop() self._watchdog = Watchdog(self.async_reconnect) # These will get filled in after initial authentication: self._client: ClientWebSocketResponse = None # type: ignore[assignment] @property def connected(self) -> bool: """Return if currently connected to the websocket. Returns: Whether the websocket is connected. """ return self._client is not None and not self._client.closed @staticmethod def _add_callback( callback_list: list[CallbackType], callback: CallbackType ) -> Callable[[], None]: """Add a callback to a particular list. Args: callback_list: A list on this object to store the callback in. callback: The callback to execute. Returns: A callable to cancel the callback. """ callback_list.append(callback) def remove() -> None: """Remove the callback.""" callback_list.remove(callback) return remove async def _async_receive_json(self) -> dict[str, Any]: """Receive a JSON response from the websocket server. Returns: A websocket response payload. Raises: ConnectionClosedError: Raised when the server closes the websocket. ConnectionFailedError: Raised when the websocket fails in anyway. InvalidMessageError: Raised when an invalid message is received. """ msg = await self._client.receive() if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): raise ConnectionClosedError("Connection was closed.") if msg.type == WSMsgType.ERROR: raise ConnectionFailedError if msg.type != WSMsgType.TEXT: raise InvalidMessageError(f"Received non-text message: {msg.type}") try: data = msg.json() except ValueError as err: raise InvalidMessageError("Received invalid JSON") from err LOGGER.debug("Received data from websocket server: %s", data) self._watchdog.trigger() return cast(dict[str, Any], data) async def _async_send_json(self, payload: dict[str, Any]) -> None: """Send a JSON message to the websocket server. Args: payload: A JSON payload. Raises: NotConnectedError: Raised if client is not connected. """ if not self.connected: raise NotConnectedError LOGGER.debug("Sending data to websocket server: %s", payload) await self._client.send_json(payload) def _parse_payload(self, payload: dict[str, Any]) -> None: """Parse an incoming payload. Args: payload: A JSON payload. """ if payload["type"] == "com.simplisafe.event.standard": event = websocket_event_from_payload(payload) for callback in self._event_callbacks: execute_callback(callback, event) def add_connect_callback( self, callback: Callable[[], Awaitable[None] | None] ) -> Callable[[], None]: """Add a callback to be called after connecting. Args: callback: The callback to execute. Returns: A callable to cancel the callback. """ return self._add_callback(self._connect_callbacks, callback) def add_disconnect_callback( self, callback: Callable[[], Awaitable[None] | None] ) -> Callable[[], None]: """Add a callback to be called after disconnecting. Args: callback: The callback to execute. Returns: A callable to cancel the callback. """ return self._add_callback(self._disconnect_callbacks, callback) def add_event_callback( self, callback: Callable[[WebsocketEvent], Awaitable[None] | None] ) -> Callable[[], None]: """Add a callback to be called upon receiving an event. Note that callbacks should expect to receive a WebsocketEvent object as a parameter. Args: callback: The callback to execute. Returns: A callable to cancel the callback. """ return self._add_callback(self._event_callbacks, callback) async def async_connect(self) -> None: """Connect to the websocket server. Raises: CannotConnectError: Raises when we cannot connect to the websocket. """ if self.connected: return try: self._client = await self._api.session.ws_connect( WEBSOCKET_SERVER_URL, heartbeat=55 ) except ClientError as err: raise CannotConnectError(err) from err LOGGER.info("Connected to websocket server") self._watchdog.trigger() for callback in self._connect_callbacks: execute_callback(callback) async def async_disconnect(self) -> None: """Disconnect from the websocket server.""" if not self.connected: return await self._client.close() LOGGER.info("Disconnected from websocket server") async def async_listen(self) -> None: """Start listening to the websocket server.""" now = utcnow() now_ts = round(now.timestamp() * 1000) now_utc_iso = f"{now.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3]}Z" try: await self._async_send_json( { "datacontenttype": "application/json", "type": "com.simplisafe.connection.identify", "time": now_utc_iso, "id": f"ts:{now_ts}", "specversion": "1.0", "source": DEFAULT_USER_AGENT, "data": { "auth": { "schema": "bearer", "token": self._api.access_token, }, "join": [f"uid:{self._api.user_id}"], }, } ) while not self._client.closed: message = await self._async_receive_json() self._parse_payload(message) except ConnectionClosedError: pass finally: LOGGER.debug("Listen completed; cleaning up") self._watchdog.cancel() for callback in self._disconnect_callbacks: execute_callback(callback) async def async_reconnect(self) -> None: """Reconnect (and re-listen, if appropriate) to the websocket.""" await self.async_disconnect() await asyncio.sleep(1) await self.async_connect() simplisafe-python-2024.01.0/tests/000077500000000000000000000000001455300150500166335ustar00rootroot00000000000000simplisafe-python-2024.01.0/tests/__init__.py000066400000000000000000000000341455300150500207410ustar00rootroot00000000000000"""Define package tests.""" simplisafe-python-2024.01.0/tests/common.py000066400000000000000000000025121455300150500204750ustar00rootroot00000000000000"""Define common test utilities.""" from __future__ import annotations import json import os from typing import Any from unittest.mock import Mock import aiohttp TEST_ACCESS_TOKEN = "abcde12345" # noqa: S105 TEST_ADDRESS = "1234 Main Street" TEST_AUTHORIZATION_CODE = "123abc" TEST_CAMERA_ID = "1234567890" TEST_CAMERA_ID_2 = "1234567891" TEST_CODE_VERIFIER = "123abc" TEST_LOCK_ID = "987" TEST_LOCK_ID_2 = "654" TEST_LOCK_ID_3 = "321" TEST_REFRESH_TOKEN = "qrstu98765" # noqa: S105 TEST_SUBSCRIPTION_ID = 12345 TEST_SYSTEM_ID = 12345 TEST_SYSTEM_SERIAL_NO = "1234ABCD" TEST_USER_ID = 12345 def create_ws_message(result: dict[str, Any]) -> Mock: """Return a mock WSMessage. Args: A JSON payload. Returns: A mocked websocket message. """ message = Mock(spec_set=aiohttp.http_websocket.WSMessage) message.type = aiohttp.http_websocket.WSMsgType.TEXT message.data = json.dumps(result) message.json.return_value = result return message def load_fixture(filename: str) -> str: """Load a fixture. Args: filename: The filename of the fixtures/ file to load. Returns: A string containing the contents of the file. """ path = os.path.join(os.path.dirname(__file__), "fixtures", filename) with open(path, encoding="utf-8") as fptr: return fptr.read() simplisafe-python-2024.01.0/tests/conftest.py000066400000000000000000000375251455300150500210460ustar00rootroot00000000000000"""Define fixtures, constants, etc. available for all tests.""" from __future__ import annotations import asyncio import json from collections import deque from collections.abc import Generator from typing import Any, cast from unittest.mock import AsyncMock, Mock import aiohttp import pytest import pytest_asyncio from aresponses import ResponsesMockServer from simplipy.api import API from tests.common import ( TEST_SUBSCRIPTION_ID, TEST_USER_ID, create_ws_message, load_fixture, ) @pytest.fixture(name="api_token_response") def api_token_response_fixture() -> dict[str, Any]: """Define a fixture to return a successful token response. Returns: An API response payload. """ return cast(dict[str, Any], json.loads(load_fixture("api_token_response.json"))) @pytest.fixture(name="auth_check_response", scope="session") def auth_check_response_fixture() -> dict[str, Any]: """Define a fixture to return a successful authorization check. Returns: An API response payload. """ return cast(dict[str, Any], json.loads(load_fixture("auth_check_response.json"))) @pytest.fixture(name="authenticated_simplisafe_server") def authenticated_simplisafe_server_fixture( api_token_response: dict[str, Any], auth_check_response: dict[str, Any] ) -> Generator[ResponsesMockServer, None, None]: """Define a fixture that returns an authenticated API connection. Args: api_token_response: An API response payload. auth_check_response: An API response payload. """ server = ResponsesMockServer() server.add( "auth.simplisafe.com", "/oauth/token", "post", response=aiohttp.web_response.json_response(api_token_response, status=200), ) server.add( "api.simplisafe.com", "/v1/api/authCheck", "get", response=aiohttp.web_response.json_response(auth_check_response, status=200), ) yield server @pytest.fixture(name="authenticated_simplisafe_server_v2") def authenticated_simplisafe_server_v2_fixture( authenticated_simplisafe_server: ResponsesMockServer, v2_settings_response: dict[str, Any], v2_subscriptions_response: dict[str, Any], ) -> Generator[ResponsesMockServer, None, None]: """Define a fixture that returns an authenticated API connection to a V2 system. Args: authenticated_simplisafe_server: A mock SimpliSafe cloud API connection. v2_settings_response: An API response payload. v2_subscriptions_response: An API response payload. """ authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", "get", response=aiohttp.web_response.json_response( v2_subscriptions_response, status=200 ), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/settings", "get", response=aiohttp.web_response.json_response(v2_settings_response, status=200), ) yield authenticated_simplisafe_server @pytest.fixture(name="authenticated_simplisafe_server_v3") def authenticated_simplisafe_server_v3_fixture( authenticated_simplisafe_server: ResponsesMockServer, subscriptions_response: dict[str, Any], v3_sensors_response: dict[str, Any], v3_settings_response: dict[str, Any], ) -> Generator[ResponsesMockServer, None, None]: """Define a fixture that returns an authenticated API connection to a V3 system. Args: authenticated_simplisafe_server: A mock SimpliSafe cloud API connection. subscriptions_response: An API response payload. v3_sensors_response: An API response payload. v3_settings_response: An API response payload. """ authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/users/{TEST_USER_ID}/subscriptions", "get", response=aiohttp.web_response.json_response(subscriptions_response, status=200), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response(v3_settings_response, status=200), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/sensors", "get", response=aiohttp.web_response.json_response(v3_sensors_response, status=200), ) yield authenticated_simplisafe_server @pytest.fixture(name="events_response", scope="session") def events_response_fixture() -> dict[str, Any]: """Define a fixture to return an events response. Returns: An API response payload. """ return cast(dict[str, Any], json.loads(load_fixture("events_response.json"))) @pytest.fixture(name="invalid_authorization_code_response", scope="session") def invalid_authorization_code_response_fixture() -> dict[str, Any]: """Define a fixture to return an invalid authorization code response. Returns: An API response payload. """ return cast( dict[str, Any], json.loads(load_fixture("invalid_authorization_code_response.json")), ) @pytest.fixture(name="invalid_refresh_token_response", scope="session") def invalid_refresh_token_response_fixture() -> dict[str, Any]: """Define a fixture to return an invalid refresh token response. Returns: An API response payload. """ return cast( dict[str, Any], json.loads(load_fixture("invalid_refresh_token_response.json")) ) @pytest.fixture(name="latest_event_response", scope="session") def latest_event_response_fixture() -> dict[str, Any]: """Define a fixture to return the latest system event. Returns: An API response payload. """ return cast(dict[str, Any], json.loads(load_fixture("latest_event_response.json"))) @pytest.fixture(name="mock_api") def mock_api_fixture(ws_client_session: AsyncMock) -> Mock: """Define a fixture to return a mock simplipy.API object. Args: ws_client_session: The mocked websocket client session. Returns: The mock object. """ mock_api = Mock(API) mock_api.access_token = "12345" # noqa: S105 mock_api.session = ws_client_session mock_api.user_id = 98765 return mock_api @pytest.fixture(name="subscriptions_response") def subscriptions_response_fixture() -> dict[str, Any]: """Define a fixture to return a subscriptions response. Returns: An API response payload. """ return cast(dict[str, Any], json.loads(load_fixture("subscriptions_response.json"))) @pytest.fixture(name="unavailable_endpoint_response", scope="session") def unavailable_endpoint_response_fixture() -> dict[str, Any]: """Define a fixture to return an unavailable endpoint response. Returns: An API response payload. """ return cast( dict[str, Any], json.loads(load_fixture("unavailable_endpoint_response.json")) ) @pytest.fixture(name="v2_pins_response", scope="session") def v2_pins_response_fixture() -> dict[str, Any]: """Define a fixture that returns a V2 PINs response. Returns: An API response payload. """ return cast(dict[str, Any], json.loads(load_fixture("v2_pins_response.json"))) @pytest.fixture(name="v2_settings_response", scope="session") def v2_settings_response_fixture() -> dict[str, Any]: """Define a fixture that returns a V2 settings response. Returns: An API response payload. """ return cast(dict[str, Any], json.loads(load_fixture("v2_settings_response.json"))) @pytest.fixture(name="v2_state_response", scope="session") def v2_state_response_fixture() -> dict[str, Any]: """Define a fixture that returns a V2 state change response. Returns: An API response payload. """ return cast(dict[str, Any], json.loads(load_fixture("v2_state_response.json"))) @pytest.fixture(name="v2_subscriptions_response") def v2_subscriptions_response_fixture( subscriptions_response: dict[str, Any], ) -> dict[str, Any]: """Define a fixture that returns a V2 subscriptions response. Returns: An API response payload. """ response = {**subscriptions_response} response["subscriptions"][0]["location"]["system"]["version"] = 2 return response @pytest.fixture(name="v3_sensors_response", scope="session") def v3_sensors_response_fixture() -> dict[str, Any]: """Define a fixture that returns a V3 sensors response. Returns: An API response payload. """ return cast(dict[str, Any], json.loads(load_fixture("v3_sensors_response.json"))) @pytest.fixture(name="v3_settings_response") def v3_settings_response_fixture() -> dict[str, Any]: """Define a fixture that returns a V3 settings response. Returns: An API response payload. """ return cast(dict[str, Any], json.loads(load_fixture("v3_settings_response.json"))) @pytest.fixture(name="v3_state_response", scope="session") def v3_state_response_fixture() -> dict[str, Any]: """Define a fixture that returns a V3 state change response. Returns: An API response payload. """ return cast(dict[str, Any], json.loads(load_fixture("v3_state_response.json"))) @pytest_asyncio.fixture(name="ws_client") async def ws_client_fixture( ws_message_hello: dict[str, Any], ws_message_registered: dict[str, Any], ws_message_subscribed: dict[str, Any], ws_messages: deque, ) -> AsyncMock: """Mock a websocket client. This fixture only allows a single message to be received. Args: ws_message_hello: A mocked websocket message. ws_message_registered: A mocked websocket message. ws_message_subscribed: A mocked websocket message. ws_messages: A message queue. Returns: A mocked websocket client. """ ws_client = AsyncMock(spec_set=aiohttp.ClientWebSocketResponse, closed=False) ws_client.receive_json.side_effect = ( ws_message_hello, ws_message_registered, ws_message_subscribed, ) for data in (ws_message_hello, ws_message_registered, ws_message_subscribed): ws_messages.append(create_ws_message(data)) async def receive() -> Mock: """Return a websocket message.""" await asyncio.sleep(0) message: Mock = ws_messages.popleft() if not ws_messages: ws_client.closed = True return message ws_client.receive.side_effect = receive async def reset_close() -> None: """Reset the websocket client close method.""" ws_client.closed = True ws_client.close.side_effect = reset_close return ws_client @pytest.fixture(name="ws_client_session") def ws_client_session_fixture(ws_client: AsyncMock) -> dict[str, Any]: """Mock an aiohttp client session. Args: ws_client: A mocked websocket client. Returns: A mocked websocket client session. """ client_session = AsyncMock(spec_set=aiohttp.ClientSession) client_session.ws_connect.side_effect = AsyncMock(return_value=ws_client) return client_session @pytest.fixture(name="ws_message_event") def ws_message_event_fixture(ws_message_event_data: dict[str, Any]) -> dict[str, Any]: """Define a fixture to represent an event response. Args: ws_message_event_data: A mocked websocket response payload. Returns: A websocket response payload. """ return { "data": ws_message_event_data, "datacontenttype": "application/json", "id": "id:16803409109", "source": "messagequeue", "specversion": "1.0", "time": "2021-09-29T23:14:46.000Z", "type": "com.simplisafe.event.standard", } @pytest.fixture(name="ws_message_event_data", scope="session") def ws_message_event_data_fixture() -> dict[str, Any]: """Define a fixture that returns the data payload from a data event. Returns: A API response payload. """ return cast(dict[str, Any], json.loads(load_fixture("ws_message_event_data.json"))) @pytest.fixture(name="ws_motion_event") def ws_motion_event_fixture(ws_motion_event_data: dict[str, Any]) -> dict[str, Any]: """Define a fixture to represent an event response. Args: ws_motion_event_data: A mocked websocket response payload. Returns: A websocket response payload. """ return { "data": ws_motion_event_data, "datacontenttype": "application/json", "id": "id:16803409109", "source": "messagequeue", "specversion": "1.0", "time": "2021-09-29T23:14:46.000Z", "type": "com.simplisafe.event.standard", } @pytest.fixture(name="ws_motion_event_data", scope="session") def ws_motion_event_data_fixture() -> dict[str, Any]: """Define a fixture that returns the data payload from a data event. Returns: A API response payload. """ return cast(dict[str, Any], json.loads(load_fixture("ws_motion_event_data.json"))) @pytest.fixture(name="ws_message_hello") def ws_message_hello_fixture(ws_message_hello_data: dict[str, Any]) -> dict[str, Any]: """Define a fixture to represent the "hello" response. Args: ws_message_hello_data: A mocked websocket response payload. Returns: A websocket response payload. """ return { "data": ws_message_hello_data, "datacontenttype": "application/json", "id": "id:16803409109", "source": "service", "specversion": "1.0", "time": "2021-09-29T23:14:46.000Z", "type": "com.simplisafe.service.hello", } @pytest.fixture(name="ws_message_hello_data", scope="session") def ws_message_hello_data_fixture() -> dict[str, Any]: """Define a fixture that returns the data payload from a "hello" event. Returns: A API response payload. """ return cast(dict[str, Any], json.loads(load_fixture("ws_message_hello_data.json"))) @pytest.fixture(name="ws_message_registered", scope="session") def ws_message_registered_fixture() -> dict[str, Any]: """Define a fixture to represent the "registered" response. Returns: A websocket response payload. """ return { "datacontenttype": "application/json", "id": "id:16803409109", "source": "service", "specversion": "1.0", "time": "2021-09-29T23:14:46.000Z", "type": "com.simplisafe.service.registered", } @pytest.fixture(name="ws_message_registered_data", scope="session") def ws_message_registered_data_fixture() -> dict[str, Any]: """Define a fixture that returns the data payload from a "registered" event. Returns: An API response payload. """ return cast( dict[str, Any], json.loads(load_fixture("ws_message_registered_data.json")) ) @pytest.fixture(name="ws_message_subscribed") def ws_message_subscribed_fixture( ws_message_subscribed_data: dict[str, Any], ) -> dict[str, Any]: """Define a fixture to represent the "registered" response. Args: ws_message_subscribed_data: A mocked websocket response payload. Returns: A websocket response payload. """ return { "data": ws_message_subscribed_data, "datacontenttype": "application/json", "id": "id:16803409109", "source": "service", "specversion": "1.0", "time": "2021-09-29T23:14:46.000Z", "type": "com.simplisafe.service.subscribed", } @pytest.fixture(name="ws_message_subscribed_data", scope="session") def ws_message_subscribed_data_fixture() -> dict[str, Any]: """Define a fixture that returns the data payload from a "subscribed" event. Returns: An API response payload. """ return cast( dict[str, Any], json.loads(load_fixture("ws_message_subscribed_data.json")) ) @pytest.fixture(name="ws_messages") def ws_messages_fixture() -> deque: """Return a message buffer for the WS client. Returns: A queue. """ return deque() simplisafe-python-2024.01.0/tests/fixtures/000077500000000000000000000000001455300150500205045ustar00rootroot00000000000000simplisafe-python-2024.01.0/tests/fixtures/api_token_response.json000066400000000000000000000003521455300150500252660ustar00rootroot00000000000000{ "access_token": "abcde12345", "refresh_token": "qrstu98765", "id_token": "vwxyz00000", "scope": "openid email https://api.simplisafe.com/scopes/user:platform offline_access", "expires_in": 3600, "token_type": "Bearer" } simplisafe-python-2024.01.0/tests/fixtures/auth_check_response.json000066400000000000000000000000521455300150500254100ustar00rootroot00000000000000{ "userId": 12345, "isAdmin": false } simplisafe-python-2024.01.0/tests/fixtures/events_response.json000066400000000000000000000023511455300150500246220ustar00rootroot00000000000000{ "numEvents": 2, "lastEventTimestamp": 1534035861, "events": [ { "eventId": 2921814837, "eventTimestamp": 1534720376, "eventCid": 3401, "zoneCid": "0", "sensorType": 1, "sensorSerial": "123", "account": 12345, "userId": 12345, "sid": 12345, "info": "System Armed (Away) by Keypad Garage Keypad", "pinName": "", "sensorName": "Garage Keypad", "messageSubject": "SimpliSafe System Armed (away mode)", "messageBody": "System Armed (away mode)", "eventType": "activity", "timezone": 2, "locationOffset": -360, "videoStartedBy": "", "video": {} }, { "eventId": 2920433155, "eventTimestamp": 1534702778, "eventCid": 1400, "zoneCid": "1", "sensorType": 1, "sensorSerial": "456", "account": 12345, "userId": 12345, "sid": 12345, "info": "System Disarmed by Master PIN", "pinName": "Master PIN", "sensorName": "Garage Keypad", "messageSubject": "SimpliSafe System Disarmed", "messageBody": "System Disarmed", "eventType": "activity", "timezone": 2, "locationOffset": -360, "videoStartedBy": "", "video": {} } ] } simplisafe-python-2024.01.0/tests/fixtures/invalid_authorization_code_response.json000066400000000000000000000001241455300150500307120ustar00rootroot00000000000000{ "error": "invalid_grant", "error_description": "Invalid authorization code" } simplisafe-python-2024.01.0/tests/fixtures/invalid_refresh_token_response.json000066400000000000000000000001331455300150500276560ustar00rootroot00000000000000{ "error": "invalid_grant", "error_description": "Unknown or invalid refresh token." } simplisafe-python-2024.01.0/tests/fixtures/latest_event_response.json000066400000000000000000000012561455300150500260160ustar00rootroot00000000000000{ "numEvents": 50, "lastEventTimestamp": 1564018073, "events": [ { "eventId": 1234567890, "eventTimestamp": 1564018073, "eventCid": 1400, "zoneCid": "2", "sensorType": 1, "sensorSerial": "01010101", "account": "00011122", "userId": 12345, "sid": 12345, "info": "System Disarmed by PIN 2", "pinName": "", "sensorName": "Kitchen", "messageSubject": "SimpliSafe System Disarmed", "messageBody": "System Disarmed: Your SimpliSafe security system was ...", "eventType": "activity", "timezone": 2, "locationOffset": -360, "videoStartedBy": "", "video": {} } ] } simplisafe-python-2024.01.0/tests/fixtures/subscriptions_response.json000066400000000000000000000577361455300150500262460ustar00rootroot00000000000000{ "subscriptions": [ { "uid": 12345, "sid": 12345, "sStatus": 20, "activated": 1445034752, "planSku": "SSEDSM2", "planName": "Interactive Monitoring", "price": 24.99, "currency": "USD", "country": "US", "expires": 1602887552, "canceled": 0, "extraTime": 0, "creditCard": { "lastFour": "", "type": "", "ppid": "ABCDE12345", "uid": 12345 }, "time": 2628000, "paymentProfileId": "ABCDE12345", "features": { "monitoring": true, "alerts": true, "online": true, "hazard": true, "video": true, "cameras": 10, "dispatch": true, "proInstall": false, "discount": 0, "vipCS": false, "medical": true, "careVisit": false, "storageDays": 30 }, "status": { "hasBaseStation": true, "isActive": true, "monitoring": "Active" }, "subscriptionFeatures": { "monitoredSensorsTypes": [ "Entry", "Motion", "GlassBreak", "Smoke", "CO", "Freeze", "Water" ], "monitoredPanicConditions": ["Fire", "Medical", "Duress"], "dispatchTypes": ["Police", "Fire", "Medical", "Guard"], "remoteControl": [ "ArmDisarm", "LockUnlock", "ViewSettings", "ConfigureSettings" ], "cameraFeatures": { "liveView": true, "maxRecordingCameras": 10, "recordingStorageDays": 30, "videoVerification": true }, "support": { "level": "Basic", "annualVisit": false, "professionalInstall": false }, "cellCommunicationBackup": true, "alertChannels": ["Push", "SMS", "Email"], "alertTypes": ["Alarm", "Error", "Activity", "Camera"], "alarmModes": ["Alarm", "SecretAlert", "Disabled"], "supportedIntegrations": [ "GoogleAssistant", "AmazonAlexa", "AugustLock" ], "timeline": {} }, "dispatcher": "cops", "dcid": 0, "location": { "sid": 12345, "uid": 12345, "lStatus": 10, "account": "1234ABCD", "street1": "1234 Main Street", "street2": "", "locationName": "", "city": "Atlantis", "county": "SEA", "state": "UW", "zip": "12345", "country": "US", "crossStreet": "River 1 and River 2", "notes": "", "residenceType": 2, "numAdults": 2, "numChildren": 0, "locationOffset": -360, "safeWord": "TRITON", "signature": "Atlantis Citizen 1", "timeZone": 2, "primaryContacts": [ { "name": "John Doe", "phone": "1234567890" } ], "secondaryContacts": [ { "name": "Jane Doe", "phone": "9876543210" } ], "copsOptIn": false, "certificateUri": "https://simplisafe.com/account2/12345/alarm-certificate/12345", "nestStructureId": "", "system": { "serial": "1234ABCD", "alarmState": "OFF", "alarmStateTimestamp": 0, "isAlarming": false, "version": 3, "capabilities": { "setWifiOverCell": true, "setDoorbellChimeVolume": true, "outdoorBattCamera": true }, "temperature": 67, "exitDelayRemaining": 60, "cameras": [ { "staleSettingsTypes": [], "upgradeWhitelisted": false, "model": "SS001", "uuid": "1234567890", "uid": 12345, "sid": 12345, "cameraSettings": { "cameraName": "Camera", "pictureQuality": "720p", "nightVision": "auto", "statusLight": "off", "micSensitivity": 100, "micEnable": true, "speakerVolume": 75, "motionSensitivity": 0, "shutterHome": "closedAlarmOnly", "shutterAway": "open", "shutterOff": "closedAlarmOnly", "wifiSsid": "", "canStream": false, "canRecord": false, "pirEnable": true, "vaEnable": true, "notificationsEnable": false, "enableDoorbellNotification": true, "doorbellChimeVolume": "off", "privacyEnable": false, "hdr": false, "vaZoningEnable": false, "vaZoningRows": 0, "vaZoningCols": 0, "vaZoningMask": [], "maxDigitalZoom": 10, "supportedResolutions": ["480p", "720p"], "admin": { "IRLED": 0, "pirSens": 0, "statusLEDState": 1, "lux": "lowLux", "motionDetectionEnabled": false, "motionThresholdZero": 0, "motionThresholdOne": 10000, "levelChangeDelayZero": 30, "levelChangeDelayOne": 10, "audioDetectionEnabled": false, "audioChannelNum": 2, "audioSampleRate": 16000, "audioChunkBytes": 2048, "audioSampleFormat": 3, "audioSensitivity": 50, "audioThreshold": 50, "audioDirection": 0, "bitRate": 284, "longPress": 2000, "kframe": 1, "gopLength": 40, "idr": 1, "fps": 20, "firmwareVersion": "2.6.1.107", "netConfigVersion": "", "camAgentVersion": "", "lastLogin": 1600639997, "lastLogout": 1600639944, "pirSampleRateMs": 800, "pirHysteresisHigh": 2, "pirHysteresisLow": 10, "pirFilterCoefficient": 1, "logEnabled": true, "logLevel": 3, "logQDepth": 20, "firmwareGroup": "public", "irOpenThreshold": 445, "irCloseThreshold": 840, "irOpenDelay": 3, "irCloseDelay": 3, "irThreshold1x": 388, "irThreshold2x": 335, "irThreshold3x": 260, "rssi": [[1600935204, -43]], "battery": [], "dbm": 0, "vmUse": 161592, "resSet": 10540, "uptime": 810043.74, "wifiDisconnects": 1, "wifiDriverReloads": 1, "statsPeriod": 3600000, "sarlaccDebugLogTypes": 0, "odProcessingFps": 8, "odObjectMinWidthPercent": 6, "odObjectMinHeightPercent": 24, "odEnableObjectDetection": true, "odClassificationMask": 2, "odClassificationConfidenceThreshold": 0.95, "odEnableOverlay": false, "odAnalyticsLib": 2, "odSensitivity": 85, "odEventObjectMask": 2, "odLuxThreshold": 445, "odLuxHysteresisHigh": 4, "odLuxHysteresisLow": 4, "odLuxSamplingFrequency": 30, "odFGExtractorMode": 2, "odVideoScaleFactor": 1, "odSceneType": 1, "odCameraView": 3, "odCameraFOV": 2, "odBackgroundLearnStationary": true, "odBackgroundLearnStationarySpeed": 15, "odClassifierQualityProfile": 1, "odEnableVideoAnalyticsWhileStreaming": false, "wlanMac": "XX:XX:XX:XX:XX:XX", "region": "us-east-1", "enableWifiAnalyticsLib": false, "ivLicense": "" }, "pirLevel": "medium", "odLevel": "medium" }, "__v": 0, "cameraStatus": { "firmwareVersion": "2.6.1.107", "netConfigVersion": "", "camAgentVersion": "", "lastLogin": 1600639997, "lastLogout": 1600639944, "wlanMac": "XX:XX:XX:XX:XX:XX", "fwDownloadVersion": "", "fwDownloadPercentage": 0, "recovered": false, "recoveredFromVersion": "", "_id": "1234567890", "initErrors": [], "speedTestTokenCreated": 1600235629 }, "supportedFeatures": { "providers": { "webrtc": "none", "recording": "simplisafe", "live": "simplisafe" }, "audioEncodings": ["speex"], "resolutions": ["480p", "720p"], "_id": "1234567890", "pir": true, "videoAnalytics": false, "privacyShutter": true, "microphone": true, "fullDuplexAudio": false, "wired": true, "networkSpeedTest": false, "videoEncoding": "h264" }, "subscription": { "enabled": true, "freeTrialActive": false, "freeTrialUsed": true, "freeTrialEnds": 0, "freeTrialExpires": 0, "planSku": "SSVM1", "price": 0, "expires": 0, "storageDays": 30, "trialUsed": true, "trialActive": false, "trialExpires": 0 }, "status": "online" }, { "staleSettingsTypes": [], "upgradeWhitelisted": false, "model": "SS002", "uuid": "1234567892", "uid": 12345, "sid": 12345, "cameraSettings": { "cameraName": "Doorbell", "pictureQuality": "720p", "nightVision": "auto", "statusLight": "off", "micSensitivity": 100, "micEnable": true, "speakerVolume": 75, "motionSensitivity": 0, "shutterHome": "closedAlarmOnly", "shutterAway": "open", "shutterOff": "closedAlarmOnly", "wifiSsid": "", "canStream": false, "canRecord": false, "pirEnable": true, "vaEnable": true, "notificationsEnable": false, "enableDoorbellNotification": true, "doorbellChimeVolume": "off", "privacyEnable": false, "hdr": false, "vaZoningEnable": false, "vaZoningRows": 0, "vaZoningCols": 0, "vaZoningMask": [], "maxDigitalZoom": 10, "supportedResolutions": ["480p", "720p"], "admin": { "IRLED": 0, "pirSens": 0, "statusLEDState": 1, "lux": "lowLux", "motionDetectionEnabled": false, "motionThresholdZero": 0, "motionThresholdOne": 10000, "levelChangeDelayZero": 30, "levelChangeDelayOne": 10, "audioDetectionEnabled": false, "audioChannelNum": 2, "audioSampleRate": 16000, "audioChunkBytes": 2048, "audioSampleFormat": 3, "audioSensitivity": 50, "audioThreshold": 50, "audioDirection": 0, "bitRate": 284, "longPress": 2000, "kframe": 1, "gopLength": 40, "idr": 1, "fps": 20, "firmwareVersion": "2.6.1.107", "netConfigVersion": "", "camAgentVersion": "", "lastLogin": 1600639997, "lastLogout": 1600639944, "pirSampleRateMs": 800, "pirHysteresisHigh": 2, "pirHysteresisLow": 10, "pirFilterCoefficient": 1, "logEnabled": true, "logLevel": 3, "logQDepth": 20, "firmwareGroup": "public", "irOpenThreshold": 445, "irCloseThreshold": 840, "irOpenDelay": 3, "irCloseDelay": 3, "irThreshold1x": 388, "irThreshold2x": 335, "irThreshold3x": 260, "rssi": [[1600935204, -43]], "battery": [], "dbm": 0, "vmUse": 161592, "resSet": 10540, "uptime": 810043.74, "wifiDisconnects": 1, "wifiDriverReloads": 1, "statsPeriod": 3600000, "sarlaccDebugLogTypes": 0, "odProcessingFps": 8, "odObjectMinWidthPercent": 6, "odObjectMinHeightPercent": 24, "odEnableObjectDetection": true, "odClassificationMask": 2, "odClassificationConfidenceThreshold": 0.95, "odEnableOverlay": false, "odAnalyticsLib": 2, "odSensitivity": 85, "odEventObjectMask": 2, "odLuxThreshold": 445, "odLuxHysteresisHigh": 4, "odLuxHysteresisLow": 4, "odLuxSamplingFrequency": 30, "odFGExtractorMode": 2, "odVideoScaleFactor": 1, "odSceneType": 1, "odCameraView": 3, "odCameraFOV": 2, "odBackgroundLearnStationary": true, "odBackgroundLearnStationarySpeed": 15, "odClassifierQualityProfile": 1, "odEnableVideoAnalyticsWhileStreaming": false, "wlanMac": "XX:XX:XX:XX:XX:XX", "region": "us-east-1", "enableWifiAnalyticsLib": false, "ivLicense": "" }, "pirLevel": "medium", "odLevel": "medium" }, "__v": 0, "cameraStatus": { "firmwareVersion": "2.6.1.107", "netConfigVersion": "", "camAgentVersion": "", "lastLogin": 1600639997, "lastLogout": 1600639944, "wlanMac": "XX:XX:XX:XX:XX:XX", "fwDownloadVersion": "", "fwDownloadPercentage": 0, "recovered": false, "recoveredFromVersion": "", "_id": "1234567890", "initErrors": [], "speedTestTokenCreated": 1600235629 }, "supportedFeatures": { "providers": { "webrtc": "none", "recording": "simplisafe", "live": "simplisafe" }, "audioEncodings": ["speex"], "resolutions": ["480p", "720p"], "_id": "1234567890", "pir": true, "videoAnalytics": false, "privacyShutter": true, "microphone": true, "fullDuplexAudio": false, "wired": true, "networkSpeedTest": false, "videoEncoding": "h264" }, "subscription": { "enabled": true, "freeTrialActive": false, "freeTrialUsed": true, "freeTrialEnds": 0, "freeTrialExpires": 0, "planSku": "SSVM1", "price": 0, "expires": 0, "storageDays": 30, "trialUsed": true, "trialActive": false, "trialExpires": 0 }, "status": "online" }, { "staleSettingsTypes": [], "upgradeWhitelisted": false, "model": "ABC1111111", "uuid": "1234567891", "uid": 12345, "sid": 12345, "cameraSettings": { "cameraName": "Unknown Camera", "pictureQuality": "720p", "nightVision": "auto", "statusLight": "off", "micSensitivity": 100, "micEnable": true, "speakerVolume": 75, "motionSensitivity": 0, "shutterHome": "closedAlarmOnly", "shutterAway": "open", "shutterOff": "closedAlarmOnly", "wifiSsid": "", "canStream": false, "canRecord": false, "pirEnable": true, "vaEnable": true, "notificationsEnable": false, "enableDoorbellNotification": true, "doorbellChimeVolume": "off", "privacyEnable": false, "hdr": false, "vaZoningEnable": false, "vaZoningRows": 0, "vaZoningCols": 0, "vaZoningMask": [], "maxDigitalZoom": 10, "supportedResolutions": ["480p", "720p"], "admin": { "IRLED": 0, "pirSens": 0, "statusLEDState": 1, "lux": "lowLux", "motionDetectionEnabled": false, "motionThresholdZero": 0, "motionThresholdOne": 10000, "levelChangeDelayZero": 30, "levelChangeDelayOne": 10, "audioDetectionEnabled": false, "audioChannelNum": 2, "audioSampleRate": 16000, "audioChunkBytes": 2048, "audioSampleFormat": 3, "audioSensitivity": 50, "audioThreshold": 50, "audioDirection": 0, "bitRate": 284, "longPress": 2000, "kframe": 1, "gopLength": 40, "idr": 1, "fps": 20, "firmwareVersion": "2.6.1.107", "netConfigVersion": "", "camAgentVersion": "", "lastLogin": 1600639997, "lastLogout": 1600639944, "pirSampleRateMs": 800, "pirHysteresisHigh": 2, "pirHysteresisLow": 10, "pirFilterCoefficient": 1, "logEnabled": true, "logLevel": 3, "logQDepth": 20, "firmwareGroup": "public", "irOpenThreshold": 445, "irCloseThreshold": 840, "irOpenDelay": 3, "irCloseDelay": 3, "irThreshold1x": 388, "irThreshold2x": 335, "irThreshold3x": 260, "rssi": [[1600935204, -43]], "battery": [], "dbm": 0, "vmUse": 161592, "resSet": 10540, "uptime": 810043.74, "wifiDisconnects": 1, "wifiDriverReloads": 1, "statsPeriod": 3600000, "sarlaccDebugLogTypes": 0, "odProcessingFps": 8, "odObjectMinWidthPercent": 6, "odObjectMinHeightPercent": 24, "odEnableObjectDetection": true, "odClassificationMask": 2, "odClassificationConfidenceThreshold": 0.95, "odEnableOverlay": false, "odAnalyticsLib": 2, "odSensitivity": 85, "odEventObjectMask": 2, "odLuxThreshold": 445, "odLuxHysteresisHigh": 4, "odLuxHysteresisLow": 4, "odLuxSamplingFrequency": 30, "odFGExtractorMode": 2, "odVideoScaleFactor": 1, "odSceneType": 1, "odCameraView": 3, "odCameraFOV": 2, "odBackgroundLearnStationary": true, "odBackgroundLearnStationarySpeed": 15, "odClassifierQualityProfile": 1, "odEnableVideoAnalyticsWhileStreaming": false, "wlanMac": "XX:XX:XX:XX:XX:XX", "region": "us-east-1", "enableWifiAnalyticsLib": false, "ivLicense": "" }, "pirLevel": "medium", "odLevel": "medium" }, "__v": 0, "cameraStatus": { "firmwareVersion": "2.6.1.107", "netConfigVersion": "", "camAgentVersion": "", "lastLogin": 1600639997, "lastLogout": 1600639944, "wlanMac": "XX:XX:XX:XX:XX:XX", "fwDownloadVersion": "", "fwDownloadPercentage": 0, "recovered": false, "recoveredFromVersion": "", "_id": "1234567890", "initErrors": [], "speedTestTokenCreated": 1600235629 }, "supportedFeatures": { "providers": { "webrtc": "none", "recording": "simplisafe", "live": "simplisafe" }, "audioEncodings": ["speex"], "resolutions": ["480p", "720p"], "_id": "1234567890", "pir": true, "videoAnalytics": false, "privacyShutter": true, "microphone": true, "fullDuplexAudio": false, "wired": true, "networkSpeedTest": false, "videoEncoding": "h264" }, "subscription": { "enabled": true, "freeTrialActive": false, "freeTrialUsed": true, "freeTrialEnds": 0, "freeTrialExpires": 0, "planSku": "SSVM1", "price": 0, "expires": 0, "storageDays": 30, "trialUsed": true, "trialActive": false, "trialExpires": 0 }, "status": "online" } ], "connType": "wifi", "stateUpdated": 1601502948, "messages": [ { "_id": "xxxxxxxxxxxxxxxxxxxxxxxx", "id": "xxxxxxxxxxxxxxxxxxxxxxxx", "textTemplate": "Power Outage - Backup battery in use.", "data": { "time": "2020-02-16T03:20:28+00:00" }, "text": "Power Outage - Backup battery in use.", "code": "2000", "filters": [], "link": "http://link.to.info", "linkLabel": "More Info", "expiration": 0, "category": "error", "timestamp": 1581823228 } ], "powerOutage": false, "lastPowerOutage": 1581991064, "lastSuccessfulWifiTS": 1601424776, "isOffline": false } }, "pinUnlocked": true, "billDate": 1602887552, "billInterval": 2628000, "pinUnlockedBy": "pin", "autoActivation": null } ] } simplisafe-python-2024.01.0/tests/fixtures/unavailable_endpoint_response.json000066400000000000000000000002301455300150500274730ustar00rootroot00000000000000{ "type": "NoRemoteManagement", "message": "Subscription does not support remote management", "code": "078", "statusCode": 403, "props": {} } simplisafe-python-2024.01.0/tests/fixtures/v2_deleted_pins_response.json000066400000000000000000000005661455300150500263720ustar00rootroot00000000000000{ "pins": { "pin1": { "value": "1234" }, "pin2": { "value": "3456", "name": "Mother" }, "pin3": { "value": "", "name": "" }, "pin4": { "value": "", "name": "" }, "pin5": { "value": "", "name": "" }, "duress": { "value": "9876" } }, "lastUpdated": 1563208180 } simplisafe-python-2024.01.0/tests/fixtures/v2_pins_response.json000066400000000000000000000006001455300150500246710ustar00rootroot00000000000000{ "pins": { "pin1": { "value": "1234" }, "pin2": { "value": "3456", "name": "Mother" }, "pin3": { "value": "4567", "name": "Father" }, "pin4": { "value": "", "name": "" }, "pin5": { "value": "", "name": "" }, "duress": { "value": "9876" } }, "lastUpdated": 1563208180 } simplisafe-python-2024.01.0/tests/fixtures/v2_settings_response.json000066400000000000000000000245011455300150500255660ustar00rootroot00000000000000{ "account": 12345, "type": "all", "success": true, "settings": { "general": { "dialingPrefix": "", "light": true, "doorChime": true, "voicePrompts": false, "systemVolume": 35, "alarmVolume": 100, "exitDelay": 120, "entryDelayHome": 120, "entryDelayAway": 120, "alarmDuration": 4 }, "pins": { "pin1": "1234", "pin2": "", "pin3": "", "pin4": "", "pin5": "", "duressPin": "" }, "error": { "keypadBatteryLow": false, "communicationError": false, "noDialTone": false, "dialError": false, "checksumWrong": false, "notRegistered": false, "messageFailed": false, "outOfRange": false }, "sensors": [ { "type": 1, "serial": "195", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 0, "sensorData": 0, "name": "Garage Keypad", "error": false }, { "type": 1, "serial": "583", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 0, "sensorData": 0, "name": "Master Bedroom Keypad", "error": false }, { "type": 2, "serial": "654", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 0, "sensorData": 0, "name": "Keychain #1", "error": false }, { "type": 2, "serial": "429", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 0, "sensorData": 0, "name": "Keychain #1", "error": false }, { "type": 3, "serial": "425", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 2, "sensorData": 0, "name": "Front Door Panic", "error": false }, { "type": 3, "serial": "874", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 6, "sensorData": 0, "name": "Master Bathroom Panic", "error": false }, { "type": 3, "serial": "427", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 5, "sensorData": 0, "name": "Master Bedroom Panic", "error": false }, { "type": 4, "serial": "672", "setting": 2, "instant": false, "enotify": false, "sensorStatus": 0, "sensorData": 0, "name": "Main Level Motion", "error": false }, { "type": 4, "serial": "119", "setting": 2, "instant": false, "enotify": false, "sensorStatus": 0, "sensorData": 0, "name": "Basement Motion", "error": false }, { "type": 5, "serial": "324", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 2, "sensorData": 210, "name": "Master Window #1", "error": false, "entryStatus": "closed" }, { "type": 5, "serial": "609", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 0, "sensorData": 130, "name": "Door to Garage", "error": false, "entryStatus": "closed" }, { "type": 5, "serial": "936", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 4, "sensorData": 178, "name": "Basement Window #1", "error": false, "entryStatus": "closed" }, { "type": 5, "serial": "102", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 4, "sensorData": 162, "name": "Family Room Window #1", "error": false, "entryStatus": "closed" }, { "type": 5, "serial": "419", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 4, "sensorData": 114, "name": "Family Room Window #2", "error": false, "entryStatus": "closed" }, { "type": 5, "serial": "199", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 2, "sensorData": 226, "name": "Master Window #2", "error": false, "entryStatus": "closed" }, { "type": 5, "serial": "610", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 1, "sensorData": 210, "name": "Master Bathroom Window", "error": false, "entryStatus": "closed" }, { "type": 5, "serial": "84", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 0, "sensorData": 82, "name": "Front Door", "error": false, "entryStatus": "closed" }, { "type": 5, "serial": "190", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 1, "sensorData": 210, "name": "Back Patio Door", "error": false, "entryStatus": "closed" }, { "type": 5, "serial": "939", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 2, "sensorData": 162, "name": "Office Window #2", "error": false, "entryStatus": "closed" }, { "type": 5, "serial": "460", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 3, "sensorData": 178, "name": "Basement Window #2", "error": false, "entryStatus": "closed" }, { "type": 5, "serial": "231", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 2, "sensorData": 162, "name": "Office Window #1", "error": false, "entryStatus": "closed" }, { "type": 5, "serial": "271", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 1, "sensorData": 242, "name": "Equipment Room Window", "error": false, "entryStatus": "closed" }, { "type": 5, "serial": "707", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 6, "sensorData": 178, "name": "Basement Bedroom Window", "error": false, "entryStatus": "closed" }, { "type": 6, "serial": "87", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 3, "sensorData": 240, "name": "Office Glass Break", "error": false, "battery": "ok" }, { "type": 6, "serial": "30", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 2, "sensorData": 0, "name": "Equipment Room Glass", "error": false, "battery": "ok" }, { "type": 6, "serial": "205", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 3, "sensorData": 224, "name": "Master Bathroom Glass", "error": false, "battery": "ok" }, { "type": 6, "serial": "143", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 2, "sensorData": 240, "name": "Basement Glass Break", "error": false, "battery": "ok" }, { "type": 6, "serial": "527", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 1, "sensorData": 32, "name": "Basement Bedroom Glass", "error": false, "battery": "ok" }, { "type": 6, "serial": "132", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 3, "sensorData": 240, "name": "Kitchen Glass Break", "error": false, "battery": "ok" }, { "type": 6, "serial": "199", "setting": 1, "instant": false, "enotify": false, "sensorStatus": 2, "sensorData": 240, "name": "Master Glass Break", "error": false, "battery": "ok" }, { "type": 9, "serial": "314", "setting": 63, "instant": true, "enotify": true, "sensorStatus": 6, "sensorData": 0, "name": "Washing Machine", "error": false }, { "type": 9, "serial": "372", "setting": 63, "instant": true, "enotify": true, "sensorStatus": 4, "sensorData": 0, "name": "Refrigerator Water", "error": false }, { "type": 8, "serial": "620", "setting": 63, "instant": true, "enotify": true, "sensorStatus": 0, "sensorData": 0, "name": "Upstairs Smoke", "error": false, "battery": "ok" }, { "type": 8, "serial": "994", "setting": 63, "instant": true, "enotify": true, "sensorStatus": 0, "sensorData": 0, "name": "Downstairs Smoke", "error": false, "battery": "ok" }, { "type": 7, "serial": "507", "setting": 63, "instant": true, "enotify": true, "sensorStatus": 0, "sensorData": 0, "name": "Upstairs CO", "error": false, "battery": "ok" }, { "type": 42, "serial": "974", "setting": 63, "instant": true, "enotify": true, "sensorStatus": 0, "sensorData": 0, "name": "Downstairs Thingy", "error": false, "battery": "ok" }, {}, {}, {}, {}, {} ] }, "lastUpdated": 1521939555, "lastStatus": "success_set" } simplisafe-python-2024.01.0/tests/fixtures/v2_state_response.json000066400000000000000000000001651455300150500250460ustar00rootroot00000000000000{ "success": true, "reason": null, "requestedState": "home", "lastUpdated": 1534725096, "exitDelay": 120 } simplisafe-python-2024.01.0/tests/fixtures/v3_sensors_response.json000066400000000000000000000253261455300150500254310ustar00rootroot00000000000000{ "account": 12345, "success": true, "sensors": [ { "type": 5, "serial": "825", "name": "Fire Door", "setting": { "instantTrigger": false, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0 }, "status": { "triggered": false }, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 5, "serial": "14", "name": "Front Door", "setting": { "instantTrigger": false, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0 }, "status": { "triggered": false }, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 5, "serial": "185", "name": "Patio Door", "setting": { "instantTrigger": true, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0 }, "status": { "triggered": false }, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 13, "serial": "236", "name": "Basement", "setting": { "alarmVolume": 3, "doorChime": 0, "exitBeeps": 0, "entryBeeps": 2 }, "status": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 3, "serial": "789", "name": "Front Door", "setting": { "alarm": 1 }, "status": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 3, "serial": "822", "name": "Master BR", "setting": { "alarm": 1 }, "status": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 1, "serial": "972", "name": "Kitchen", "setting": { "lowPowerMode": false, "alarm": 1 }, "status": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 8, "serial": "93", "name": "Upstairs", "setting": {}, "status": { "test": false, "tamper": false, "malfunction": false, "triggered": false }, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 8, "serial": "650", "name": "Downstairs", "setting": {}, "status": { "test": false, "tamper": false, "malfunction": false, "triggered": false }, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 6, "serial": "491", "name": "Basement N", "setting": { "instantTrigger": false, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0 }, "status": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 6, "serial": "280", "name": "Mud Counter", "setting": { "instantTrigger": false, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0 }, "status": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 6, "serial": "430", "name": "Basement S", "setting": { "instantTrigger": false, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0 }, "status": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 9, "serial": "129", "name": "Laundry", "setting": { "alarm": 1 }, "status": { "triggered": false }, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 9, "serial": "975", "name": "Basement", "setting": { "alarm": 1 }, "status": { "triggered": false }, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 9, "serial": "382", "name": "Fridge", "setting": { "alarm": 1 }, "status": { "triggered": false }, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 10, "serial": "320", "name": "Basement", "setting": { "highTemp": 95, "lowTemp": 41, "alarm": 1 }, "status": { "temperature": 67, "triggered": false }, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 4, "serial": "785", "name": "Upstairs", "setting": { "instantTrigger": false, "away2": 1, "away": 1, "home2": 0, "home": 0, "off": 0 }, "status": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 4, "serial": "934", "name": "Downstairs", "setting": { "instantTrigger": false, "away2": 1, "away": 1, "home2": 0, "home": 0, "off": 0 }, "status": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 6, "serial": "634", "name": "Landing", "setting": { "instantTrigger": false, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0 }, "status": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 6, "serial": "801", "name": "Living Room", "setting": { "instantTrigger": false, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0 }, "status": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "type": 6, "serial": "946", "name": "Eating Area", "setting": { "instantTrigger": false, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0 }, "status": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "serial": "987", "type": 16, "status": { "pinPadState": 0, "lockState": 1, "pinPadOffline": false, "pinPadLowBattery": false, "lockDisabled": false, "lockLowBattery": false, "calibrationErrDelta": 0, "calibrationErrZero": 0, "lockJamState": 0 }, "name": "Front Door", "deviceGroupID": 1, "firmwareVersion": "1.0.0", "bootVersion": "1.0.0", "setting": { "autoLock": 3, "away": 1, "home": 1, "awayToOff": 0, "homeToOff": 1 }, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "serial": "987a", "type": 253, "status": {}, "name": "Front Door", "deviceGroupID": 1, "setting": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "serial": "654", "type": 16, "status": { "pinPadState": 0, "lockState": 1, "pinPadOffline": false, "pinPadLowBattery": false, "lockDisabled": false, "lockLowBattery": false, "calibrationErrDelta": 0, "calibrationErrZero": 0, "lockJamState": 1 }, "name": "Back Door", "deviceGroupID": 1, "firmwareVersion": "1.0.0", "bootVersion": "1.0.0", "setting": { "autoLock": 3, "away": 1, "home": 1, "awayToOff": 0, "homeToOff": 1 }, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "serial": "654a", "type": 253, "status": {}, "name": "Front Door", "deviceGroupID": 1, "setting": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "serial": "321", "type": 16, "status": { "pinPadState": 0, "lockState": 42, "pinPadOffline": false, "pinPadLowBattery": false, "lockDisabled": false, "lockLowBattery": false, "calibrationErrDelta": 0, "calibrationErrZero": 0, "lockJamState": 0 }, "name": "Side Door", "deviceGroupID": 1, "firmwareVersion": "1.0.0", "bootVersion": "1.0.0", "setting": { "autoLock": 3, "away": 1, "home": 1, "awayToOff": 0, "homeToOff": 1 }, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "serial": "321a", "type": 253, "status": {}, "name": "Front Door", "deviceGroupID": 1, "setting": {}, "flags": { "swingerShutdown": false, "lowBattery": false, "offline": false } }, { "serial": "00000000", "type": 14, "name": "Kitchen", "setting": {}, "status": { "coTriggered": false, "malfunction": false, "tamper": true, "lowSensitivity": false, "endOfLife": false, "test": false, "smokeTriggered": false, "preSmokeAlarm": false }, "timestamp": 0, "rssi": -45, "WDTCount": 23, "nonce": 0, "rebootCnt": 17, "deviceGroupID": 0, "flags": { "offline": true, "lowBattery": false, "swingerShutdown": false } } ], "lastUpdated": 1534626361, "lastSynced": 1534626361, "lastStatusUpdate": 1534626358 } simplisafe-python-2024.01.0/tests/fixtures/v3_settings_response.json000066400000000000000000000030161455300150500255650ustar00rootroot00000000000000{ "account": "12345012", "settings": { "normal": { "wifiSSID": "MY_WIFI", "alarmDuration": 240, "alarmVolume": 3, "doorChime": 2, "entryDelayAway": 30, "entryDelayAway2": 30, "entryDelayHome": 30, "entryDelayHome2": 30, "exitDelayAway": 60, "exitDelayAway2": 60, "exitDelayHome": 0, "exitDelayHome2": 0, "lastUpdated": "2019-07-03T03:24:20.999Z", "light": true, "voicePrompts": 2, "_id": "1197192618725121765212" }, "pins": { "lastUpdated": "2019-07-04T20:47:44.016Z", "_id": "asd6281526381253123", "users": [ { "_id": "1271279d966212121124c7", "pin": "3454", "name": "Test 1" }, { "_id": "1271279d966212121124c6", "pin": "5424", "name": "Test 2" }, { "_id": "1271279d966212121124c5", "pin": "", "name": "" }, { "_id": "1271279d966212121124c4", "pin": "", "name": "" } ], "duress": { "pin": "9876" }, "master": { "pin": "1234" } } }, "basestationStatus": { "lastUpdated": "2019-07-15T15:28:22.961Z", "rfJamming": false, "ethernetStatus": 4, "gsmRssi": -73, "gsmStatus": 3, "backupBattery": 5293, "wallPower": 5933, "wifiRssi": -49, "wifiStatus": 1, "_id": "6128153715231t237123", "encryptionErrors": [] }, "lastUpdated": 1562273264 } simplisafe-python-2024.01.0/tests/fixtures/v3_state_response.json000066400000000000000000000001541455300150500250450ustar00rootroot00000000000000{ "success": true, "reason": null, "state": "HOME", "lastUpdated": 1534725096, "exitDelay": 120 } simplisafe-python-2024.01.0/tests/fixtures/ws_message_event_data.json000066400000000000000000000017341455300150500257330ustar00rootroot00000000000000{ "eventTimestamp": 1632957286, "eventCid": 1400, "zoneCid": "1", "sensorType": 1, "sensorSerial": "abcdef12", "account": "abcdef12", "userId": 12345, "sid": 12345, "info": "System Disarmed by Master PIN", "pinName": "Master PIN", "sensorName": "", "messageSubject": "SimpliSafe System Disarmed", "messageBody": "System Disarmed: Your SimpliSafe security system was disarmed by Keypad Master PIN at 1234 Main Street on 9-29-21 at 5:14 pm", "eventType": "activity", "timezone": 2, "locationOffset": -360, "internal": { "dispatcher": "my_dispatcher" }, "senderId": "wifi", "openCount": 0, "eventId": 16803409109, "serviceFeatures": { "monitoring": true, "alerts": true, "online": true, "hazard": true, "video": true, "cameras": 10, "dispatch": true, "proInstall": false, "discount": 0, "vipCS": false, "medical": true, "careVisit": false, "storageDays": 30 }, "copsVideoOptIn": false } simplisafe-python-2024.01.0/tests/fixtures/ws_message_hello_data.json000066400000000000000000000001371455300150500257110ustar00rootroot00000000000000{ "key": "AbCdEfgHiK", "timeouts": { "heartbeat": 45000, "inactivity": 30000 } } simplisafe-python-2024.01.0/tests/fixtures/ws_message_subscribed_data.json000066400000000000000000000000401455300150500267240ustar00rootroot00000000000000{ "namespace": "uid:428836" } simplisafe-python-2024.01.0/tests/fixtures/ws_motion_event_data.json000066400000000000000000000056171455300150500256200ustar00rootroot00000000000000{ "eventUuid": "xxx", "eventTimestamp": 1703882325, "eventCid": 1170, "zoneCid": "1", "sensorType": 17, "sensorSerial": "f11b6abd", "account": "abcdef12", "userId": 12345, "sid": 12345, "info": "Back Yard Camera Detected Motion", "pinName": "", "sensorName": "Back Yard", "messageSubject": "Camera Detected Motion", "messageBody": "Back Yard Camera Detected Motion on 12/29/2023 at 12:38 PM", "eventType": "activityCam", "timezone": 3, "locationOffset": -480, "internal": { "dispatcher": "my_dispatcher" }, "senderId": "", "eventId": 38552328826, "serviceFeatures": { "monitoring": false, "alerts": true, "online": true, "hazard": false, "video": true, "cameras": 10, "dispatch": false, "proInstall": false, "discount": 0, "vipCS": false, "medical": false, "careVisit": false, "storageDays": 30 }, "copsVideoOptIn": false, "videoStartedBy": "6172311af9da430ab2e11c59f11b6abd", "video": { "6172311af9da430ab2e11c59f11b6abd": { "clipId": "26361666077", "preroll": 3, "postroll": 7, "cameraName": "Back Yard", "eventId": "38552328826", "sid": 12345, "timestamp": 1703882325, "recordingType": "KVS", "account": "abcdef12", "region": "us-east-1", "actualDuration": 0, "status": "PENDING", "_links": { "_self": { "href": "https://chronicle.us-east-1.prd.cam.simplisafe.com/v1/recordings/26361666077", "method": "GET" }, "preview/mjpg": { "href": "https://remix.us-east-1.prd.cam.simplisafe.com/v1/preview/6172311af9da430ab2e11c59f11b6abd/12345/time/1703882322/1703882332?account=611485993050®ion=us-east-1{&fps,width}", "method": "GET", "templated": true }, "snapshot/mjpg": { "href": "https://remix.us-east-1.prd.cam.simplisafe.com/v1/preview/6172311af9da430ab2e11c59f11b6abd/12345/time/1703882322/1703882326?account=611485993050®ion=us-east-1{&fps,width}", "method": "GET", "templated": true }, "snapshot/jpg": { "href": "https://image-url{&width}", "method": "GET", "templated": true }, "download/mp4": { "href": "https://clip-url", "method": "GET" }, "share": { "href": "https://remix.us-east-1.prd.cam.simplisafe.com/v2/share/6172311af9da430ab2e11c59f11b6abd/12345/time/1703882322/1703882332?account=611485993050®ion=us-east-1", "method": "POST" }, "playback/dash": { "href": "https://mediator.prd.cam.simplisafe.com/v1/recording/6172311af9da430ab2e11c59f11b6abd/12345/time/1703882322/1703882332/dash?account=611485993050®ion=us-east-1", "method": "GET" }, "playback/hls": { "href": "https://hls-url", "method": "GET" } } } } } simplisafe-python-2024.01.0/tests/sensor/000077500000000000000000000000001455300150500201445ustar00rootroot00000000000000simplisafe-python-2024.01.0/tests/sensor/__init__.py000066400000000000000000000000401455300150500222470ustar00rootroot00000000000000"""Define tests for sensors.""" simplisafe-python-2024.01.0/tests/sensor/test_base.py000066400000000000000000000020061455300150500224650ustar00rootroot00000000000000"""Define base tests for Sensor objects.""" import aiohttp import pytest from aresponses import ResponsesMockServer from simplipy import API from simplipy.device import DeviceTypes from tests.common import TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, TEST_SYSTEM_ID @pytest.mark.asyncio async def test_properties_base( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v2: ResponsesMockServer, ) -> None: """Test that base sensor properties are created properly.""" async with authenticated_simplisafe_server_v2, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] sensor = system.sensors["195"] assert sensor.name == "Garage Keypad" assert sensor.serial == "195" assert sensor.type == DeviceTypes.KEYPAD aresponses.assert_plan_strictly_followed() simplisafe-python-2024.01.0/tests/sensor/test_v2.py000066400000000000000000000032121455300150500221020ustar00rootroot00000000000000"""Define tests for V2 Sensor objects.""" from typing import cast import aiohttp import pytest from aresponses import ResponsesMockServer from simplipy import API from simplipy.device.sensor.v2 import SensorV2 from simplipy.errors import SimplipyError from tests.common import TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, TEST_SYSTEM_ID @pytest.mark.asyncio async def test_properties_v2( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v2: ResponsesMockServer, ) -> None: """Test that v2 sensor properties are created properly.""" async with authenticated_simplisafe_server_v2, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] keypad: SensorV2 = cast(SensorV2, system.sensors["195"]) assert keypad.data == 0 assert not keypad.error assert not keypad.low_battery assert keypad.settings == 1 # Ensure that attempting to access the triggered of anything but # an entry sensor in a V2 system throws an error: with pytest.raises(SimplipyError): assert keypad.triggered == 42 entry_sensor: SensorV2 = cast(SensorV2, system.sensors["609"]) assert entry_sensor.data == 130 assert not entry_sensor.error assert not entry_sensor.low_battery assert entry_sensor.settings == 1 assert not entry_sensor.trigger_instantly assert not entry_sensor.triggered aresponses.assert_plan_strictly_followed() simplisafe-python-2024.01.0/tests/sensor/test_v3.py000066400000000000000000000032071455300150500221070ustar00rootroot00000000000000"""Define tests for v3 Sensor objects.""" from typing import cast import aiohttp import pytest from aresponses import ResponsesMockServer from simplipy import API from simplipy.device.sensor.v3 import SensorV3 from tests.common import TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, TEST_SYSTEM_ID @pytest.mark.asyncio async def test_properties_v3( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test that v3 sensor properties are created properly.""" async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] entry_sensor: SensorV3 = cast(SensorV3, system.sensors["825"]) assert not entry_sensor.error assert not entry_sensor.low_battery assert not entry_sensor.offline assert not entry_sensor.settings["instantTrigger"] assert not entry_sensor.trigger_instantly assert not entry_sensor.triggered siren: SensorV3 = cast(SensorV3, system.sensors["236"]) assert not siren.triggered temperature_sensor: SensorV3 = cast(SensorV3, system.sensors["320"]) assert temperature_sensor.temperature == 67 # Ensure that attempting to access the temperature attribute of a # non-temperature sensor throws an error: with pytest.raises(AttributeError): assert siren.temperature == 42 aresponses.assert_plan_strictly_followed() simplisafe-python-2024.01.0/tests/system/000077500000000000000000000000001455300150500201575ustar00rootroot00000000000000simplisafe-python-2024.01.0/tests/system/__init__.py000066400000000000000000000000401455300150500222620ustar00rootroot00000000000000"""Define tests for systems.""" simplisafe-python-2024.01.0/tests/system/test_base.py000066400000000000000000000251671455300150500225150ustar00rootroot00000000000000"""Define base tests for System objects.""" from datetime import datetime from typing import Any, cast from unittest.mock import Mock import aiohttp import pytest from aresponses import ResponsesMockServer from simplipy import API from simplipy.system import SystemStates from simplipy.system.v3 import SystemV3 from tests.common import ( TEST_ADDRESS, TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, TEST_SUBSCRIPTION_ID, TEST_SYSTEM_ID, TEST_SYSTEM_SERIAL_NO, TEST_USER_ID, ) @pytest.mark.asyncio async def test_deactivated_system( aresponses: ResponsesMockServer, authenticated_simplisafe_server: ResponsesMockServer, subscriptions_response: dict[str, Any], ) -> None: """Test that API.async_get_systems doesn't return deactivated systems. Args: aresponses: An aresponses server. authenticated_simplisafe_server: A authenticated API connection. subscriptions_response: An API response payload. """ subscriptions_response["subscriptions"][0]["status"]["hasBaseStation"] = 0 async with authenticated_simplisafe_server: authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", "get", response=aiohttp.web_response.json_response( subscriptions_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() assert len(systems) == 0 aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_get_events( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v2: ResponsesMockServer, events_response: dict[str, Any], ) -> None: """Test getting events from a system. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v2: A authenticated API connection. events_response: An API response payload. """ async with authenticated_simplisafe_server_v2: authenticated_simplisafe_server_v2.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SYSTEM_ID}/events", "get", response=aiohttp.web_response.json_response(events_response, status=200), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] events = await system.async_get_events(datetime.now(), 2) assert len(events) == 2 aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_missing_property( # pylint: disable=too-many-arguments aresponses: ResponsesMockServer, authenticated_simplisafe_server: ResponsesMockServer, caplog: Mock, subscriptions_response: dict[str, Any], v3_sensors_response: dict[str, Any], v3_settings_response: dict[str, Any], ) -> None: """Test that missing property data is handled correctly. Args: aresponses: An aresponses server. authenticated_simplisafe_server: A authenticated API connection. caplog: A mocked logging utility. subscriptions_response: An API response payload. v3_sensors_response: An API response payload. v3_settings_response: An API response payload. """ subscriptions_response["subscriptions"][0]["location"]["system"].pop("isOffline") async with authenticated_simplisafe_server: authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/users/{TEST_USER_ID}/subscriptions", "get", response=aiohttp.web_response.json_response( subscriptions_response, status=200 ), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/sensors", "get", response=aiohttp.web_response.json_response( v3_sensors_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) assert system.offline is False assert any( "SimpliSafe didn't return data for property: offline" in e.message for e in caplog.records ) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_missing_system_info( aresponses: ResponsesMockServer, authenticated_simplisafe_server: ResponsesMockServer, caplog: Mock, subscriptions_response: dict[str, Any], ) -> None: """Test that a subscription with missing system data is handled correctly. Args: aresponses: An aresponses server. authenticated_simplisafe_server: A authenticated API connection. caplog: A mocked logging utility. subscriptions_response: An API response payload. """ subscriptions_response["subscriptions"][0]["location"]["system"] = {} async with authenticated_simplisafe_server: authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", "get", response=aiohttp.web_response.json_response( subscriptions_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) await simplisafe.async_get_systems() assert any( "Skipping subscription with missing system data" in e.message for e in caplog.records ) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_properties( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v2: ResponsesMockServer, ) -> None: """Test that base system properties are created properly. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v2: A authenticated API connection. """ async with authenticated_simplisafe_server_v2, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] assert not system.alarm_going_off assert system.address == TEST_ADDRESS assert system.connection_type == "wifi" assert system.serial == TEST_SYSTEM_SERIAL_NO assert system.state == SystemStates.OFF assert system.system_id == TEST_SYSTEM_ID assert system.temperature == 67 assert system.version == 2 aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_unknown_sensor_type( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v2: ResponsesMockServer, caplog: Mock, ) -> None: """Test whether a message is logged upon finding an unknown sensor type. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v2: A authenticated API connection. caplog: A mocked logging utility. """ async with authenticated_simplisafe_server_v2, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) await simplisafe.async_get_systems() assert any("Unknown device type" in e.message for e in caplog.records) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_unknown_system_state( # pylint: disable=too-many-arguments aresponses: ResponsesMockServer, authenticated_simplisafe_server: ResponsesMockServer, caplog: Mock, subscriptions_response: dict[str, Any], v3_sensors_response: dict[str, Any], v3_settings_response: dict[str, Any], ) -> None: """Test that an unknown system state is logged. Args: aresponses: An aresponses server. authenticated_simplisafe_server: A authenticated API connection. caplog: A mocked logging utility. subscriptions_response: An API response payload. v3_sensors_response: An API response payload. v3_settings_response: An API response payload. """ subscriptions_response["subscriptions"][0]["location"]["system"][ "alarmState" ] = "NOT_REAL_STATE" async with authenticated_simplisafe_server: authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/users/{TEST_USER_ID}/subscriptions", "get", response=aiohttp.web_response.json_response( subscriptions_response, status=200 ), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/sensors", "get", response=aiohttp.web_response.json_response( v3_sensors_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) await simplisafe.async_get_systems() assert any("Unknown raw system state" in e.message for e in caplog.records) assert any("NOT_REAL_STATE" in e.message for e in caplog.records) aresponses.assert_plan_strictly_followed() simplisafe-python-2024.01.0/tests/system/test_v2.py000066400000000000000000000240151455300150500221210ustar00rootroot00000000000000"""Define tests for v2 System objects.""" from typing import Any import aiohttp import pytest from aresponses import ResponsesMockServer from simplipy import API from simplipy.system import SystemStates from tests.common import ( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, TEST_SUBSCRIPTION_ID, TEST_SYSTEM_ID, TEST_SYSTEM_SERIAL_NO, ) @pytest.mark.asyncio async def test_clear_notifications( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v2: ResponsesMockServer, v2_settings_response: dict[str, Any], ) -> None: """Test clearing all active notifications. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v2: A authenticated API connection. v2_settings_response: An API response payload. """ async with authenticated_simplisafe_server_v2: authenticated_simplisafe_server_v2.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/messages", "delete", response=aiohttp.web_response.json_response( v2_settings_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] await system.async_clear_notifications() assert system.notifications == [] aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_get_pins( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v2: ResponsesMockServer, v2_pins_response: dict[str, Any], ) -> None: """Test getting PINs associated with a V2 system. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v2: A authenticated API connection. v2_pins_response: An API response payload. """ async with authenticated_simplisafe_server_v2: authenticated_simplisafe_server_v2.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/pins", "get", response=aiohttp.web_response.json_response(v2_pins_response, status=200), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] pins = await system.async_get_pins() assert len(pins) == 4 assert pins["master"] == "1234" assert pins["duress"] == "9876" assert pins["Mother"] == "3456" assert pins["Father"] == "4567" aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_async_get_systems( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v2: ResponsesMockServer, ) -> None: """Test the ability to get systems attached to a v2 account. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v2: A authenticated API connection. """ async with authenticated_simplisafe_server_v2, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() assert len(systems) == 1 system = systems[TEST_SYSTEM_ID] assert system.serial == TEST_SYSTEM_SERIAL_NO assert system.system_id == TEST_SYSTEM_ID assert len(system.sensors) == 35 aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_set_pin( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v2: ResponsesMockServer, v2_pins_response: dict[str, Any], v2_settings_response: dict[str, Any], ) -> None: """Test setting a PIN in a V2 system. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v2: A authenticated API connection. v2_pins_response: An API response payload. v2_settings_response: An API response payload. """ async with authenticated_simplisafe_server_v2: authenticated_simplisafe_server_v2.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/pins", "get", response=aiohttp.web_response.json_response(v2_pins_response, status=200), ) authenticated_simplisafe_server_v2.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/pins", "get", response=aiohttp.web_response.json_response(v2_pins_response, status=200), ) authenticated_simplisafe_server_v2.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/pins", "post", response=aiohttp.web_response.json_response( v2_settings_response, status=200 ), ) v2_pins_response["pins"]["pin4"]["value"] = "1275" v2_pins_response["pins"]["pin4"]["name"] = "whatever" authenticated_simplisafe_server_v2.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/pins", "get", response=aiohttp.web_response.json_response(v2_pins_response, status=200), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] latest_pins = await system.async_get_pins() assert len(latest_pins) == 4 await system.async_set_pin("whatever", "1275") new_pins = await system.async_get_pins() assert len(new_pins) == 5 aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_set_states( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v2: ResponsesMockServer, v2_state_response: dict[str, Any], ) -> None: """Test the ability to set the state of a v2 system. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v2: A authenticated API connection. v2_state_response: An API response payload. """ async with authenticated_simplisafe_server_v2: v2_state_response["requestedState"] = "away" authenticated_simplisafe_server_v2.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/state", "post", response=aiohttp.web_response.json_response(v2_state_response, status=200), ) v2_state_response["requestedState"] = "home" authenticated_simplisafe_server_v2.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/state", "post", response=aiohttp.web_response.json_response(v2_state_response, status=200), ) v2_state_response["requestedState"] = "off" authenticated_simplisafe_server_v2.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/state", "post", response=aiohttp.web_response.json_response(v2_state_response, status=200), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] await system.async_set_away() state = system.state assert state == SystemStates.AWAY await system.async_set_home() state = system.state assert state == SystemStates.HOME await system.async_set_off() state = system.state assert state == SystemStates.OFF aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_update_system_data( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v2: ResponsesMockServer, v2_settings_response: dict[str, Any], v2_subscriptions_response: dict[str, Any], ) -> None: """Test getting updated data for a v2 system. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v2: A authenticated API connection. v2_settings_response: An API response payload. v2_subscriptions_response: An API response payload. """ async with authenticated_simplisafe_server_v2: authenticated_simplisafe_server_v2.add( "api.simplisafe.com", f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", "get", response=aiohttp.web_response.json_response( v2_subscriptions_response, status=200 ), ) authenticated_simplisafe_server_v2.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/settings", "get", response=aiohttp.web_response.json_response( v2_settings_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] assert system.serial == TEST_SYSTEM_SERIAL_NO assert system.system_id == TEST_SYSTEM_ID assert len(system.sensors) == 35 # If this succeeds without throwing an exception, the update is successful: await system.async_update() aresponses.assert_plan_strictly_followed() simplisafe-python-2024.01.0/tests/system/test_v3.py000066400000000000000000002135261455300150500221310ustar00rootroot00000000000000"""Define tests for v3 System objects.""" # pylint: disable=too-many-lines import logging from datetime import datetime, timedelta, timezone from typing import Any, cast from unittest.mock import Mock import aiohttp import pytest from aresponses import ResponsesMockServer from simplipy import API from simplipy.errors import ( EndpointUnavailableError, InvalidCredentialsError, MaxUserPinsExceededError, PinError, RequestError, SimplipyError, ) from simplipy.system import SystemStates from simplipy.system.v3 import SystemV3, Volume from simplipy.util.dt import utcnow from tests.common import ( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, TEST_SUBSCRIPTION_ID, TEST_SYSTEM_ID, TEST_SYSTEM_SERIAL_NO, TEST_USER_ID, ) @pytest.mark.asyncio async def test_as_dict( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test dumping the system as a dict.""" async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] assert system.as_dict() == { "address": "1234 Main Street", "alarm_going_off": False, "connection_type": "wifi", "notifications": [ { "notification_id": "xxxxxxxxxxxxxxxxxxxxxxxx", "text": "Power Outage - Backup battery in use.", "category": "error", "code": "2000", "timestamp": 1581823228, "received_dt": datetime( 2020, 2, 16, 3, 20, 28, tzinfo=timezone.utc ), "link": "http://link.to.info", "link_label": "More Info", } ], "serial": "1234ABCD", "state": 10, "system_id": 12345, "temperature": 67, "version": 3, "sensors": [ { "name": "Fire Door", "serial": "825", "type": 5, "error": False, "low_battery": False, "offline": False, "settings": { "instantTrigger": False, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0, }, "trigger_instantly": False, "triggered": False, }, { "name": "Front Door", "serial": "14", "type": 5, "error": False, "low_battery": False, "offline": False, "settings": { "instantTrigger": False, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0, }, "trigger_instantly": False, "triggered": False, }, { "name": "Patio Door", "serial": "185", "type": 5, "error": False, "low_battery": False, "offline": False, "settings": { "instantTrigger": True, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0, }, "trigger_instantly": True, "triggered": False, }, { "name": "Basement", "serial": "236", "type": 13, "error": False, "low_battery": False, "offline": False, "settings": { "alarmVolume": 3, "doorChime": 0, "exitBeeps": 0, "entryBeeps": 2, }, "trigger_instantly": False, "triggered": False, }, { "name": "Front Door", "serial": "789", "type": 3, "error": False, "low_battery": False, "offline": False, "settings": {"alarm": 1}, "trigger_instantly": False, "triggered": False, }, { "name": "Master BR", "serial": "822", "type": 3, "error": False, "low_battery": False, "offline": False, "settings": {"alarm": 1}, "trigger_instantly": False, "triggered": False, }, { "name": "Kitchen", "serial": "972", "type": 1, "error": False, "low_battery": False, "offline": False, "settings": {"lowPowerMode": False, "alarm": 1}, "trigger_instantly": False, "triggered": False, }, { "name": "Upstairs", "serial": "93", "type": 8, "error": False, "low_battery": False, "offline": False, "settings": {}, "trigger_instantly": False, "triggered": False, }, { "name": "Downstairs", "serial": "650", "type": 8, "error": False, "low_battery": False, "offline": False, "settings": {}, "trigger_instantly": False, "triggered": False, }, { "name": "Basement N", "serial": "491", "type": 6, "error": False, "low_battery": False, "offline": False, "settings": { "instantTrigger": False, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0, }, "trigger_instantly": False, "triggered": False, }, { "name": "Mud Counter", "serial": "280", "type": 6, "error": False, "low_battery": False, "offline": False, "settings": { "instantTrigger": False, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0, }, "trigger_instantly": False, "triggered": False, }, { "name": "Basement S", "serial": "430", "type": 6, "error": False, "low_battery": False, "offline": False, "settings": { "instantTrigger": False, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0, }, "trigger_instantly": False, "triggered": False, }, { "name": "Laundry", "serial": "129", "type": 9, "error": False, "low_battery": False, "offline": False, "settings": {"alarm": 1}, "trigger_instantly": False, "triggered": False, }, { "name": "Basement", "serial": "975", "type": 9, "error": False, "low_battery": False, "offline": False, "settings": {"alarm": 1}, "trigger_instantly": False, "triggered": False, }, { "name": "Fridge", "serial": "382", "type": 9, "error": False, "low_battery": False, "offline": False, "settings": {"alarm": 1}, "trigger_instantly": False, "triggered": False, }, { "name": "Basement", "serial": "320", "type": 10, "error": False, "low_battery": False, "offline": False, "settings": {"highTemp": 95, "lowTemp": 41, "alarm": 1}, "trigger_instantly": False, "triggered": False, "temperature": 67, }, { "name": "Upstairs", "serial": "785", "type": 4, "error": False, "low_battery": False, "offline": False, "settings": { "instantTrigger": False, "away2": 1, "away": 1, "home2": 0, "home": 0, "off": 0, }, "trigger_instantly": False, "triggered": False, }, { "name": "Downstairs", "serial": "934", "type": 4, "error": False, "low_battery": False, "offline": False, "settings": { "instantTrigger": False, "away2": 1, "away": 1, "home2": 0, "home": 0, "off": 0, }, "trigger_instantly": False, "triggered": False, }, { "name": "Landing", "serial": "634", "type": 6, "error": False, "low_battery": False, "offline": False, "settings": { "instantTrigger": False, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0, }, "trigger_instantly": False, "triggered": False, }, { "name": "Living Room", "serial": "801", "type": 6, "error": False, "low_battery": False, "offline": False, "settings": { "instantTrigger": False, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0, }, "trigger_instantly": False, "triggered": False, }, { "name": "Eating Area", "serial": "946", "type": 6, "error": False, "low_battery": False, "offline": False, "settings": { "instantTrigger": False, "away2": 1, "away": 1, "home2": 1, "home": 1, "off": 0, }, "trigger_instantly": False, "triggered": False, }, { "name": "Front Door", "serial": "987a", "type": 253, "error": False, "low_battery": False, "offline": False, "settings": {}, "trigger_instantly": False, "triggered": False, }, { "name": "Front Door", "serial": "654a", "type": 253, "error": False, "low_battery": False, "offline": False, "settings": {}, "trigger_instantly": False, "triggered": False, }, { "name": "Front Door", "serial": "321a", "type": 253, "error": False, "low_battery": False, "offline": False, "settings": {}, "trigger_instantly": False, "triggered": False, }, { "name": "Kitchen", "serial": "00000000", "type": 14, "error": False, "low_battery": False, "offline": True, "settings": {}, "trigger_instantly": False, "triggered": False, }, ], "alarm_duration": 240, "alarm_volume": 3, "battery_backup_power_level": 5293, "cameras": [ { "camera_settings": { "cameraName": "Camera", "pictureQuality": "720p", "nightVision": "auto", "statusLight": "off", "micSensitivity": 100, "micEnable": True, "speakerVolume": 75, "motionSensitivity": 0, "shutterHome": "closedAlarmOnly", "shutterAway": "open", "shutterOff": "closedAlarmOnly", "wifiSsid": "", "canStream": False, "canRecord": False, "pirEnable": True, "vaEnable": True, "notificationsEnable": False, "enableDoorbellNotification": True, "doorbellChimeVolume": "off", "privacyEnable": False, "hdr": False, "vaZoningEnable": False, "vaZoningRows": 0, "vaZoningCols": 0, "vaZoningMask": [], "maxDigitalZoom": 10, "supportedResolutions": ["480p", "720p"], "admin": { "IRLED": 0, "pirSens": 0, "statusLEDState": 1, "lux": "lowLux", "motionDetectionEnabled": False, "motionThresholdZero": 0, "motionThresholdOne": 10000, "levelChangeDelayZero": 30, "levelChangeDelayOne": 10, "audioDetectionEnabled": False, "audioChannelNum": 2, "audioSampleRate": 16000, "audioChunkBytes": 2048, "audioSampleFormat": 3, "audioSensitivity": 50, "audioThreshold": 50, "audioDirection": 0, "bitRate": 284, "longPress": 2000, "kframe": 1, "gopLength": 40, "idr": 1, "fps": 20, "firmwareVersion": "2.6.1.107", "netConfigVersion": "", "camAgentVersion": "", "lastLogin": 1600639997, "lastLogout": 1600639944, "pirSampleRateMs": 800, "pirHysteresisHigh": 2, "pirHysteresisLow": 10, "pirFilterCoefficient": 1, "logEnabled": True, "logLevel": 3, "logQDepth": 20, "firmwareGroup": "public", "irOpenThreshold": 445, "irCloseThreshold": 840, "irOpenDelay": 3, "irCloseDelay": 3, "irThreshold1x": 388, "irThreshold2x": 335, "irThreshold3x": 260, "rssi": [[1600935204, -43]], "battery": [], "dbm": 0, "vmUse": 161592, "resSet": 10540, "uptime": 810043.74, "wifiDisconnects": 1, "wifiDriverReloads": 1, "statsPeriod": 3600000, "sarlaccDebugLogTypes": 0, "odProcessingFps": 8, "odObjectMinWidthPercent": 6, "odObjectMinHeightPercent": 24, "odEnableObjectDetection": True, "odClassificationMask": 2, "odClassificationConfidenceThreshold": 0.95, "odEnableOverlay": False, "odAnalyticsLib": 2, "odSensitivity": 85, "odEventObjectMask": 2, "odLuxThreshold": 445, "odLuxHysteresisHigh": 4, "odLuxHysteresisLow": 4, "odLuxSamplingFrequency": 30, "odFGExtractorMode": 2, "odVideoScaleFactor": 1, "odSceneType": 1, "odCameraView": 3, "odCameraFOV": 2, "odBackgroundLearnStationary": True, "odBackgroundLearnStationarySpeed": 15, "odClassifierQualityProfile": 1, "odEnableVideoAnalyticsWhileStreaming": False, "wlanMac": "XX:XX:XX:XX:XX:XX", "region": "us-east-1", "enableWifiAnalyticsLib": False, "ivLicense": "", }, "pirLevel": "medium", "odLevel": "medium", }, "camera_type": 0, "name": "Camera", "serial": "1234567890", "shutter_open_when_away": True, "shutter_open_when_home": False, "shutter_open_when_off": False, "status": "online", "subscription_enabled": True, }, { "camera_settings": { "cameraName": "Doorbell", "pictureQuality": "720p", "nightVision": "auto", "statusLight": "off", "micSensitivity": 100, "micEnable": True, "speakerVolume": 75, "motionSensitivity": 0, "shutterHome": "closedAlarmOnly", "shutterAway": "open", "shutterOff": "closedAlarmOnly", "wifiSsid": "", "canStream": False, "canRecord": False, "pirEnable": True, "vaEnable": True, "notificationsEnable": False, "enableDoorbellNotification": True, "doorbellChimeVolume": "off", "privacyEnable": False, "hdr": False, "vaZoningEnable": False, "vaZoningRows": 0, "vaZoningCols": 0, "vaZoningMask": [], "maxDigitalZoom": 10, "supportedResolutions": ["480p", "720p"], "admin": { "IRLED": 0, "pirSens": 0, "statusLEDState": 1, "lux": "lowLux", "motionDetectionEnabled": False, "motionThresholdZero": 0, "motionThresholdOne": 10000, "levelChangeDelayZero": 30, "levelChangeDelayOne": 10, "audioDetectionEnabled": False, "audioChannelNum": 2, "audioSampleRate": 16000, "audioChunkBytes": 2048, "audioSampleFormat": 3, "audioSensitivity": 50, "audioThreshold": 50, "audioDirection": 0, "bitRate": 284, "longPress": 2000, "kframe": 1, "gopLength": 40, "idr": 1, "fps": 20, "firmwareVersion": "2.6.1.107", "netConfigVersion": "", "camAgentVersion": "", "lastLogin": 1600639997, "lastLogout": 1600639944, "pirSampleRateMs": 800, "pirHysteresisHigh": 2, "pirHysteresisLow": 10, "pirFilterCoefficient": 1, "logEnabled": True, "logLevel": 3, "logQDepth": 20, "firmwareGroup": "public", "irOpenThreshold": 445, "irCloseThreshold": 840, "irOpenDelay": 3, "irCloseDelay": 3, "irThreshold1x": 388, "irThreshold2x": 335, "irThreshold3x": 260, "rssi": [[1600935204, -43]], "battery": [], "dbm": 0, "vmUse": 161592, "resSet": 10540, "uptime": 810043.74, "wifiDisconnects": 1, "wifiDriverReloads": 1, "statsPeriod": 3600000, "sarlaccDebugLogTypes": 0, "odProcessingFps": 8, "odObjectMinWidthPercent": 6, "odObjectMinHeightPercent": 24, "odEnableObjectDetection": True, "odClassificationMask": 2, "odClassificationConfidenceThreshold": 0.95, "odEnableOverlay": False, "odAnalyticsLib": 2, "odSensitivity": 85, "odEventObjectMask": 2, "odLuxThreshold": 445, "odLuxHysteresisHigh": 4, "odLuxHysteresisLow": 4, "odLuxSamplingFrequency": 30, "odFGExtractorMode": 2, "odVideoScaleFactor": 1, "odSceneType": 1, "odCameraView": 3, "odCameraFOV": 2, "odBackgroundLearnStationary": True, "odBackgroundLearnStationarySpeed": 15, "odClassifierQualityProfile": 1, "odEnableVideoAnalyticsWhileStreaming": False, "wlanMac": "XX:XX:XX:XX:XX:XX", "region": "us-east-1", "enableWifiAnalyticsLib": False, "ivLicense": "", }, "pirLevel": "medium", "odLevel": "medium", }, "camera_type": 1, "name": "Doorbell", "serial": "1234567892", "shutter_open_when_away": True, "shutter_open_when_home": False, "shutter_open_when_off": False, "status": "online", "subscription_enabled": True, }, { "camera_settings": { "cameraName": "Unknown Camera", "pictureQuality": "720p", "nightVision": "auto", "statusLight": "off", "micSensitivity": 100, "micEnable": True, "speakerVolume": 75, "motionSensitivity": 0, "shutterHome": "closedAlarmOnly", "shutterAway": "open", "shutterOff": "closedAlarmOnly", "wifiSsid": "", "canStream": False, "canRecord": False, "pirEnable": True, "vaEnable": True, "notificationsEnable": False, "enableDoorbellNotification": True, "doorbellChimeVolume": "off", "privacyEnable": False, "hdr": False, "vaZoningEnable": False, "vaZoningRows": 0, "vaZoningCols": 0, "vaZoningMask": [], "maxDigitalZoom": 10, "supportedResolutions": ["480p", "720p"], "admin": { "IRLED": 0, "pirSens": 0, "statusLEDState": 1, "lux": "lowLux", "motionDetectionEnabled": False, "motionThresholdZero": 0, "motionThresholdOne": 10000, "levelChangeDelayZero": 30, "levelChangeDelayOne": 10, "audioDetectionEnabled": False, "audioChannelNum": 2, "audioSampleRate": 16000, "audioChunkBytes": 2048, "audioSampleFormat": 3, "audioSensitivity": 50, "audioThreshold": 50, "audioDirection": 0, "bitRate": 284, "longPress": 2000, "kframe": 1, "gopLength": 40, "idr": 1, "fps": 20, "firmwareVersion": "2.6.1.107", "netConfigVersion": "", "camAgentVersion": "", "lastLogin": 1600639997, "lastLogout": 1600639944, "pirSampleRateMs": 800, "pirHysteresisHigh": 2, "pirHysteresisLow": 10, "pirFilterCoefficient": 1, "logEnabled": True, "logLevel": 3, "logQDepth": 20, "firmwareGroup": "public", "irOpenThreshold": 445, "irCloseThreshold": 840, "irOpenDelay": 3, "irCloseDelay": 3, "irThreshold1x": 388, "irThreshold2x": 335, "irThreshold3x": 260, "rssi": [[1600935204, -43]], "battery": [], "dbm": 0, "vmUse": 161592, "resSet": 10540, "uptime": 810043.74, "wifiDisconnects": 1, "wifiDriverReloads": 1, "statsPeriod": 3600000, "sarlaccDebugLogTypes": 0, "odProcessingFps": 8, "odObjectMinWidthPercent": 6, "odObjectMinHeightPercent": 24, "odEnableObjectDetection": True, "odClassificationMask": 2, "odClassificationConfidenceThreshold": 0.95, "odEnableOverlay": False, "odAnalyticsLib": 2, "odSensitivity": 85, "odEventObjectMask": 2, "odLuxThreshold": 445, "odLuxHysteresisHigh": 4, "odLuxHysteresisLow": 4, "odLuxSamplingFrequency": 30, "odFGExtractorMode": 2, "odVideoScaleFactor": 1, "odSceneType": 1, "odCameraView": 3, "odCameraFOV": 2, "odBackgroundLearnStationary": True, "odBackgroundLearnStationarySpeed": 15, "odClassifierQualityProfile": 1, "odEnableVideoAnalyticsWhileStreaming": False, "wlanMac": "XX:XX:XX:XX:XX:XX", "region": "us-east-1", "enableWifiAnalyticsLib": False, "ivLicense": "", }, "pirLevel": "medium", "odLevel": "medium", }, "camera_type": 99, "name": "Unknown Camera", "serial": "1234567891", "shutter_open_when_away": True, "shutter_open_when_home": False, "shutter_open_when_off": False, "status": "online", "subscription_enabled": True, }, ], "chime_volume": 2, "entry_delay_away": 30, "entry_delay_home": 30, "exit_delay_away": 60, "exit_delay_home": 0, "gsm_strength": -73, "light": True, "locks": [ { "name": "Front Door", "serial": "987", "type": 16, "error": False, "low_battery": False, "offline": False, "settings": { "autoLock": 3, "away": 1, "home": 1, "awayToOff": 0, "homeToOff": 1, }, "disabled": False, "lock_low_battery": False, "pin_pad_low_battery": False, "pin_pad_offline": False, "state": 1, }, { "name": "Back Door", "serial": "654", "type": 16, "error": False, "low_battery": False, "offline": False, "settings": { "autoLock": 3, "away": 1, "home": 1, "awayToOff": 0, "homeToOff": 1, }, "disabled": False, "lock_low_battery": False, "pin_pad_low_battery": False, "pin_pad_offline": False, "state": 2, }, { "name": "Side Door", "serial": "321", "type": 16, "error": False, "low_battery": False, "offline": False, "settings": { "autoLock": 3, "away": 1, "home": 1, "awayToOff": 0, "homeToOff": 1, }, "disabled": False, "lock_low_battery": False, "pin_pad_low_battery": False, "pin_pad_offline": False, "state": 99, }, ], "offline": False, "power_outage": False, "rf_jamming": False, "voice_prompt_volume": 2, "wall_power_level": 5933, "wifi_ssid": "MY_WIFI", "wifi_strength": -49, } aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_alarm_state( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test that we can get the alarm state.""" async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] assert system.state == SystemStates.OFF aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_clear_notifications( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, v3_settings_response: dict[str, Any], ) -> None: """Test clearing all active notifications.""" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/messages", "delete", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] await system.async_clear_notifications() assert system.notifications == [] aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_get_last_event( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, latest_event_response: dict[str, Any], ) -> None: """Test getting the latest event.""" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/events", "get", response=aiohttp.web_response.json_response( latest_event_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] latest_event = await system.async_get_latest_event() assert latest_event["eventId"] == 1234567890 aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_get_pins( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, v3_settings_response: dict[str, Any], ) -> None: """Test getting PINs associated with a V3 system.""" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] pins = await system.async_get_pins() assert len(pins) == 4 assert pins["master"] == "1234" assert pins["duress"] == "9876" assert pins["Test 1"] == "3454" assert pins["Test 2"] == "5424" aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_async_get_systems( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test the ability to get systems attached to a v3 account.""" async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() assert len(systems) == 1 system = systems[TEST_SYSTEM_ID] assert system.serial == TEST_SYSTEM_SERIAL_NO assert system.system_id == TEST_SYSTEM_ID assert len(system.sensors) == 25 aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_empty_events( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, events_response: dict[str, Any], ) -> None: """Test that an empty events structure is handled correctly.""" events_response["events"] = [] async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/events", "get", response=aiohttp.web_response.json_response(events_response, status=200), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] # Test the events key existing, but being empty: with pytest.raises(SimplipyError): _ = await system.async_get_latest_event() aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_lock_state_update_bug( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, caplog: Mock, v3_state_response: dict[str, Any], ) -> None: """Test halting updates within a 15-second window from arming/disarming.""" caplog.set_level(logging.INFO) v3_state_response["state"] = "AWAY" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/state/away", "post", response=aiohttp.web_response.json_response(v3_state_response, status=200), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] await system.async_set_away() assert system.state == SystemStates.AWAY await system.async_update() assert any("Skipping system update" in e.message for e in caplog.records) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_missing_events( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, events_response: dict[str, Any], ) -> None: """Test that an altogether-missing events structure is handled correctly.""" events_response.pop("events") async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/events", "get", response=aiohttp.web_response.json_response(events_response, status=200), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] # Test that the events key exists, but is empty: with pytest.raises(SimplipyError): _ = await system.async_get_latest_event() aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_no_state_change_on_failure( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test that the system doesn't change state on an error.""" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/state/away", "post", response=aresponses.Response(text="Unauthorized", status=401), ) authenticated_simplisafe_server_v3.add( "auth.simplisafe.com", "/oauth/token", "post", response=aresponses.Response(text="Unauthorized", status=401), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) # pylint: disable=protected-access # Manually set the expiration datetime to force a refresh token flow: simplisafe._token_last_refreshed = utcnow() - timedelta(seconds=30) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] assert system.state == SystemStates.OFF with pytest.raises(InvalidCredentialsError): await system.async_set_away() assert system.state == SystemStates.OFF aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_properties( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, v3_settings_response: dict[str, Any], ) -> None: """Test that v3 system properties are available.""" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "post", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) assert system.alarm_duration == 240 assert system.alarm_volume == Volume.HIGH assert system.battery_backup_power_level == 5293 assert system.chime_volume == Volume.MEDIUM assert system.connection_type == "wifi" assert system.entry_delay_away == 30 assert system.entry_delay_home == 30 assert system.exit_delay_away == 60 assert system.exit_delay_home == 0 assert system.gsm_strength == -73 assert system.light is True assert system.offline is False assert system.power_outage is False assert system.rf_jamming is False assert system.voice_prompt_volume == Volume.MEDIUM assert system.wall_power_level == 5933 assert system.wifi_ssid == "MY_WIFI" assert system.wifi_strength == -49 # Test "setting" various system properties by overriding their values, then # calling the update functions: system.settings_data["settings"]["normal"]["alarmDuration"] = 0 system.settings_data["settings"]["normal"]["alarmVolume"] = 0 system.settings_data["settings"]["normal"]["doorChime"] = 0 system.settings_data["settings"]["normal"]["entryDelayAway"] = 0 system.settings_data["settings"]["normal"]["entryDelayHome"] = 0 system.settings_data["settings"]["normal"]["exitDelayAway"] = 0 system.settings_data["settings"]["normal"]["exitDelayHome"] = 1000 system.settings_data["settings"]["normal"]["light"] = False system.settings_data["settings"]["normal"]["voicePrompts"] = 0 await system.async_set_properties( { "alarm_duration": 240, "alarm_volume": Volume.HIGH, "chime_volume": Volume.MEDIUM, "entry_delay_away": 30, "entry_delay_home": 30, "exit_delay_away": 60, "exit_delay_home": 0, "light": True, "voice_prompt_volume": Volume.MEDIUM, } ) assert system.alarm_duration == 240 assert system.alarm_volume == Volume.HIGH assert system.chime_volume == Volume.MEDIUM assert system.entry_delay_away == 30 assert system.entry_delay_home == 30 assert system.exit_delay_away == 60 assert system.exit_delay_home == 0 assert system.light is True assert system.voice_prompt_volume == Volume.MEDIUM aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_remove_nonexistent_pin( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, v3_settings_response: dict[str, Any], ) -> None: """Test throwing an error when removing a nonexistent PIN.""" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] with pytest.raises(PinError) as err: await system.async_remove_pin("0000") assert "Refusing to delete nonexistent PIN" in str(err) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_remove_pin( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, v3_settings_response: dict[str, Any], ) -> None: """Test removing a PIN in a V3 system.""" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) v3_settings_response["settings"]["pins"]["users"][1]["pin"] = "" v3_settings_response["settings"]["pins"]["users"][1]["name"] = "" authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/pins", "post", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] latest_pins = await system.async_get_pins() assert len(latest_pins) == 4 await system.async_remove_pin("Test 2") latest_pins = await system.async_get_pins() assert len(latest_pins) == 3 aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_remove_reserved_pin( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, v3_settings_response: dict[str, Any], ) -> None: """Test throwing an error when removing a reserved PIN.""" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] with pytest.raises(PinError) as err: await system.async_remove_pin("master") assert "Refusing to delete reserved PIN" in str(err) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_set_duplicate_pin( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, v3_settings_response: dict[str, Any], ) -> None: """Test throwing an error when setting a duplicate PIN.""" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/pins", "post", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) async with aiohttp.ClientSession() as session: with pytest.raises(PinError) as err: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] await system.async_set_pin("whatever", "3454") assert "Refusing to create duplicate PIN" in str(err) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_set_invalid_property( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, v3_settings_response: dict[str, Any], ) -> None: """Test that setting an invalid property raises a ValueError.""" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "post", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) with pytest.raises(ValueError): await system.async_set_properties({"foo": 1}) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_set_max_user_pins( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, v3_settings_response: dict[str, Any], ) -> None: """Test throwing an error when setting too many user PINs.""" v3_settings_response["settings"]["pins"]["users"] = [ { "_id": "1271279d966212121124c6", "pin": "1234", "name": "Test 1", }, { "_id": "1271279d966212121124c7", "pin": "5678", "name": "Test 2", }, { "_id": "1271279d966212121124c8", "pin": "9012", "name": "Test 3", }, { "_id": "1271279d966212121124c9", "pin": "3456", "name": "Test 4", }, ] async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/pins", "post", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) async with aiohttp.ClientSession() as session: with pytest.raises(MaxUserPinsExceededError) as err: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] await system.async_set_pin("whatever", "8121") assert "Refusing to create more than" in str(err) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_set_pin( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, v3_settings_response: dict[str, Any], ) -> None: """Test setting a PIN in a V3 system.""" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) v3_settings_response["settings"]["pins"]["users"][2]["pin"] = "1274" v3_settings_response["settings"]["pins"]["users"][2]["name"] = "whatever" authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/pins", "post", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] latest_pins = await system.async_get_pins() assert len(latest_pins) == 4 await system.async_set_pin("whatever", "1274") latest_pins = await system.async_get_pins() assert len(latest_pins) == 5 aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio @pytest.mark.parametrize( "pin", ["1234", "5678", "7890", "6543", "4321"], ) async def test_set_pin_sequence( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, pin: str, ) -> None: """Test throwing an error when setting a PIN that is in a sequence.""" async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: with pytest.raises(PinError) as err: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] await system.async_set_pin("label", pin) assert "Refusing to create PIN that is a sequence" in str(err) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_set_pin_wrong_chars( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test throwing an error when setting a PIN with non-digits.""" async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: with pytest.raises(PinError) as err: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] await system.async_set_pin("whatever", "abcd") assert "PINs can only contain numbers" in str(err) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_set_pin_wrong_length( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test throwing an error when setting a PIN with the wrong length.""" async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: with pytest.raises(PinError) as err: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] await system.async_set_pin("whatever", "1122334455") assert "digits long" in str(err) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_set_states( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, v3_state_response: dict[str, Any], ) -> None: """Test the ability to set the state of the system.""" v3_state_response["state"] = "AWAY" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/state/away", "post", response=aiohttp.web_response.json_response(v3_state_response, status=200), ) v3_state_response["state"] = "HOME" authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/state/home", "post", response=aiohttp.web_response.json_response(v3_state_response, status=200), ) v3_state_response["state"] = "OFF" authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/state/off", "post", response=aiohttp.web_response.json_response(v3_state_response, status=200), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] await system.async_set_away() state = system.state assert state == SystemStates.AWAY await system.async_set_home() state = system.state assert state == SystemStates.HOME await system.async_set_off() state = system.state assert state == SystemStates.OFF aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_system_notifications( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test getting system notifications.""" async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] assert len(system.notifications) == 1 notification1 = system.notifications[0] assert notification1.notification_id == "xxxxxxxxxxxxxxxxxxxxxxxx" assert notification1.text == "Power Outage - Backup battery in use." assert notification1.category == "error" assert notification1.code == "2000" assert notification1.received_dt == datetime( 2020, 2, 16, 3, 20, 28, tzinfo=timezone.utc ) assert notification1.link == "http://link.to.info" assert notification1.link_label == "More Info" aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_unavailable_endpoint( aresponses: ResponsesMockServer, unavailable_endpoint_response: dict[str, Any], authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test that an unavailable endpoint logs a message.""" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( unavailable_endpoint_response, status=403 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] with pytest.raises(EndpointUnavailableError): await system.async_update( include_subscription=False, include_devices=False ) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_update_system_data( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, subscriptions_response: dict[str, Any], v3_sensors_response: dict[str, Any], v3_settings_response: dict[str, Any], ) -> None: """Test getting updated data for a v3 system.""" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/users/{TEST_USER_ID}/subscriptions", "get", response=aiohttp.web_response.json_response( subscriptions_response, status=200 ), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/sensors", "get", response=aiohttp.web_response.json_response( v3_sensors_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] await system.async_update() assert system.serial == TEST_SYSTEM_SERIAL_NO assert system.system_id == TEST_SYSTEM_ID assert len(system.sensors) == 25 aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_update_error( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, subscriptions_response: dict[str, Any], v3_settings_response: dict[str, Any], ) -> None: """Test handling a generic error during update.""" async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/users/{TEST_USER_ID}/subscriptions", "get", response=aiohttp.web_response.json_response( subscriptions_response, status=200 ), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", "get", response=aiohttp.web_response.json_response( v3_settings_response, status=200 ), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/sensors", "get", response=aresponses.Response(text="Server Error", status=500), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session, # Set so that our tests don't take too long: request_retries=1, ) systems = await simplisafe.async_get_systems() system = systems[TEST_SYSTEM_ID] with pytest.raises(RequestError): await system.async_update() aresponses.assert_plan_strictly_followed() simplisafe-python-2024.01.0/tests/test_api.py000066400000000000000000000425201455300150500210200ustar00rootroot00000000000000"""Define tests for the System object.""" # pylint: disable=protected-access from __future__ import annotations import asyncio from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, Mock, patch import aiohttp import pytest from aresponses import ResponsesMockServer from simplipy import API from simplipy.errors import InvalidCredentialsError, RequestError, SimplipyError from simplipy.util.dt import utcnow from .common import ( TEST_ACCESS_TOKEN, TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, TEST_REFRESH_TOKEN, TEST_SUBSCRIPTION_ID, ) @pytest.mark.asyncio async def test_401_bad_credentials( aresponses: ResponsesMockServer, invalid_authorization_code_response: dict[str, Any], ) -> None: """Test that an InvalidCredentialsError is raised with an invalid auth code. Args: aresponses: An aresponses server. invalid_authorization_code_response: An API response payload. """ aresponses.add( "auth.simplisafe.com", "/oauth/token", "post", response=aiohttp.web_response.json_response( invalid_authorization_code_response, status=401 ), ) async with aiohttp.ClientSession() as session: with pytest.raises(InvalidCredentialsError): await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_401_refresh_token_failure( aresponses: ResponsesMockServer, authenticated_simplisafe_server: ResponsesMockServer, invalid_refresh_token_response: dict[str, Any], ) -> None: """Test that an error is raised when refresh token and reauth both fail. Args: aresponses: An aresponses server. authenticated_simplisafe_server: A authenticated API connection. invalid_refresh_token_response: An API response payload. """ async with authenticated_simplisafe_server: authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", "get", response=aresponses.Response(text="Unauthorized", status=401), ) authenticated_simplisafe_server.add( "auth.simplisafe.com", "/oauth/token", "post", response=aiohttp.web_response.json_response( invalid_refresh_token_response, status=403 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) # Manually set the expiration datetime to force a refresh token flow: simplisafe._token_last_refreshed = utcnow() - timedelta(seconds=30) with pytest.raises(InvalidCredentialsError): await simplisafe.async_get_systems() aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_401_refresh_token_success( api_token_response: dict[str, Any], aresponses: ResponsesMockServer, authenticated_simplisafe_server: ResponsesMockServer, v2_settings_response: dict[str, Any], v2_subscriptions_response: dict[str, Any], ) -> None: """Test that a successful refresh token carries out the original request. Args: api_token_response: An API response payload. aresponses: An aresponses server. authenticated_simplisafe_server: A authenticated API connection. v2_settings_response: An API response payload. v2_subscriptions_response: An API response payload. """ async with authenticated_simplisafe_server: authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", "get", response=aresponses.Response(text="Unauthorized", status=401), ) api_token_response["access_token"] = "jjhhgg66" # noqa: S105 api_token_response["refresh_token"] = "aabbcc11" # noqa: S105 authenticated_simplisafe_server.add( "auth.simplisafe.com", "/oauth/token", "post", response=aiohttp.web_response.json_response(api_token_response, status=200), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", "get", response=aiohttp.web_response.json_response( v2_subscriptions_response, status=200 ), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/settings", "get", response=aiohttp.web_response.json_response( v2_settings_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) # Manually set the expiration datetime to force a refresh token flow: simplisafe._token_last_refreshed = utcnow() - timedelta(seconds=30) # If this succeeds without throwing an exception, the retry is successful: await simplisafe.async_get_systems() assert simplisafe.access_token == "jjhhgg66" # noqa: S105 assert simplisafe.refresh_token == "aabbcc11" # noqa: S105 aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_403_bad_credentials( aresponses: ResponsesMockServer, invalid_authorization_code_response: dict[str, Any], ) -> None: """Test that an InvalidCredentialsError is raised with a 403. Args: aresponses: An aresponses server. invalid_authorization_code_response: An API response payload. """ aresponses.add( "auth.simplisafe.com", "/oauth/token", "post", response=aiohttp.web_response.json_response( invalid_authorization_code_response, status=403 ), ) async with aiohttp.ClientSession() as session: with pytest.raises(InvalidCredentialsError): await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_client_async_from_authorization_code( api_token_response: dict[str, Any], aresponses: ResponsesMockServer, auth_check_response: dict[str, Any], ) -> None: """Test creating a client from an authorization code. Args: api_token_response: An API response payload. aresponses: An aresponses server. auth_check_response: An API response payload. """ aresponses.add( "auth.simplisafe.com", "/oauth/token", "post", response=aiohttp.web_response.json_response(api_token_response, status=200), ) aresponses.add( "api.simplisafe.com", "/v1/api/authCheck", "get", response=aiohttp.web_response.json_response(auth_check_response, status=200), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) assert simplisafe.access_token == TEST_ACCESS_TOKEN assert simplisafe.refresh_token == TEST_REFRESH_TOKEN aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_client_async_from_authorization_code_http_error( aresponses: ResponsesMockServer, ) -> None: """Test an HTTP error while creating a client from an authorization code. Args: aresponses: An aresponses server. """ aresponses.add( "auth.simplisafe.com", "/oauth/token", "post", response=aresponses.Response(text="Gateway Timeout", status=504), ) async with aiohttp.ClientSession() as session: with pytest.raises(RequestError): await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_client_async_from_authorization_code_unknown_error() -> None: """Test an unknown error while creating a client from an authorization code.""" with patch("simplipy.API._async_api_request", AsyncMock(side_effect=Exception)): async with aiohttp.ClientSession() as session: with pytest.raises(SimplipyError): await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) @pytest.mark.asyncio async def test_client_async_from_refresh_token( api_token_response: dict[str, Any], aresponses: ResponsesMockServer, auth_check_response: dict[str, Any], ) -> None: """Test creating a client from a refresh token. Args: api_token_response: An API response payload. aresponses: An aresponses server. auth_check_response: An API response payload. """ aresponses.add( "auth.simplisafe.com", "/oauth/token", "post", response=aiohttp.web_response.json_response(api_token_response, status=200), ) aresponses.add( "api.simplisafe.com", "/v1/api/authCheck", "get", response=aiohttp.web_response.json_response(auth_check_response, status=200), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_refresh_token( TEST_REFRESH_TOKEN, session=session ) assert simplisafe.access_token == TEST_ACCESS_TOKEN assert simplisafe.refresh_token == TEST_REFRESH_TOKEN aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_client_async_from_refresh_token_http_error( aresponses: ResponsesMockServer, ) -> None: """Test an HTTP error while creating a client from an refesh_token. Args: aresponses: An aresponses server. """ aresponses.add( "auth.simplisafe.com", "/oauth/token", "post", response=aresponses.Response(text="Gateway Timeout", status=504), ) async with aiohttp.ClientSession() as session: with pytest.raises(RequestError): await API.async_from_refresh_token(TEST_REFRESH_TOKEN, session=session) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_client_async_from_refresh_token_unknown_error() -> None: """Test an unknown error while creating a client from a refresh token.""" with patch("simplipy.API._async_api_request", AsyncMock(side_effect=Exception)): async with aiohttp.ClientSession() as session: with pytest.raises(SimplipyError): await API.async_from_refresh_token(TEST_REFRESH_TOKEN, session=session) @pytest.mark.asyncio async def test_refresh_token_callback( api_token_response: dict[str, Any], aresponses: ResponsesMockServer, authenticated_simplisafe_server: ResponsesMockServer, v2_settings_response: dict[str, Any], v2_subscriptions_response: dict[str, Any], ) -> None: """Test that callbacks are executed correctly. Args: api_token_response: An API response payload. aresponses: An aresponses server. authenticated_simplisafe_server: A authenticated API connection. v2_settings_response: An API response payload. v2_subscriptions_response: An API response payload. """ async with authenticated_simplisafe_server: authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", "get", response=aresponses.Response(text="Unauthorized", status=401), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/settings", "get", response=aresponses.Response(text="Unauthorized", status=401), ) api_token_response["access_token"] = "jjhhgg66" # noqa: S105 api_token_response["refresh_token"] = "aabbcc11" # noqa: S105 authenticated_simplisafe_server.add( "auth.simplisafe.com", "/oauth/token", "post", response=aiohttp.web_response.json_response(api_token_response, status=200), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", "get", response=aiohttp.web_response.json_response( v2_subscriptions_response, status=200 ), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/settings", "get", response=aiohttp.web_response.json_response( v2_settings_response, status=200 ), ) mock_callback_1 = Mock() mock_callback_2 = Mock() async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) # Manually set the expiration datetime to force a refresh token flow: simplisafe._token_last_refreshed = utcnow() - timedelta(seconds=30) # We'll hang onto one callback: simplisafe.add_refresh_token_callback(mock_callback_1) assert mock_callback_1.call_count == 0 # ..and delete the a second one before ever using it: remove = simplisafe.add_refresh_token_callback(mock_callback_2) remove() await simplisafe.async_get_systems() await asyncio.sleep(1) mock_callback_1.assert_called_once_with("aabbcc11") assert mock_callback_1.call_count == 1 assert mock_callback_2.call_count == 0 aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_request_retry( api_token_response: dict[str, Any], aresponses: ResponsesMockServer, authenticated_simplisafe_server: ResponsesMockServer, v2_settings_response: dict[str, Any], v2_subscriptions_response: dict[str, Any], ) -> None: """Test that request retries work. Args: api_token_response: An API response payload. aresponses: An aresponses server. authenticated_simplisafe_server: A authenticated API connection. v2_settings_response: An API response payload. v2_subscriptions_response: An API response payload. """ async with authenticated_simplisafe_server: authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", "get", response=aresponses.Response(text="Conflict", status=409), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", "get", response=aresponses.Response(text="Conflict", status=409), ) authenticated_simplisafe_server.add( "auth.simplisafe.com", "/oauth/token", "post", response=aiohttp.web_response.json_response(api_token_response, status=200), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", "get", response=aiohttp.web_response.json_response( v2_subscriptions_response, status=200 ), ) authenticated_simplisafe_server.add( "api.simplisafe.com", f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/settings", "get", response=aiohttp.web_response.json_response( v2_settings_response, status=200 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) simplisafe.disable_request_retries() with pytest.raises(RequestError): await simplisafe.async_get_systems() simplisafe.enable_request_retries() # If this succeeds without throwing an exception, the retry is successful: await simplisafe.async_get_systems() aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_string_response(aresponses: ResponsesMockServer) -> None: """Test that a quoted stringn response is handled correctly. Args: aresponses: An aresponses server. """ aresponses.add( "auth.simplisafe.com", "/oauth/token", "post", response=aresponses.Response(text='"Unauthorized"', status=401), ) async with aiohttp.ClientSession() as session: with pytest.raises(InvalidCredentialsError): await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session, # Set so that our tests don't take too long: request_retries=1, ) aresponses.assert_plan_strictly_followed() simplisafe-python-2024.01.0/tests/test_camera.py000066400000000000000000000065701455300150500215040ustar00rootroot00000000000000"""Define tests for the Camera objects.""" from __future__ import annotations from typing import cast import aiohttp import pytest from aresponses import ResponsesMockServer from simplipy import API from simplipy.device.camera import CameraTypes from simplipy.system.v3 import SystemV3 from .common import ( TEST_AUTHORIZATION_CODE, TEST_CAMERA_ID, TEST_CAMERA_ID_2, TEST_CODE_VERIFIER, TEST_SYSTEM_ID, ) @pytest.mark.asyncio async def test_properties( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test that camera properties are created properly. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v3: A authenticated API connection. """ async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) camera = system.cameras[TEST_CAMERA_ID] assert camera.name == "Camera" assert camera.serial == TEST_CAMERA_ID assert camera.camera_settings["cameraName"] == "Camera" assert camera.status == "online" assert camera.subscription_enabled assert not camera.shutter_open_when_off assert not camera.shutter_open_when_home assert camera.shutter_open_when_away assert camera.camera_type == CameraTypes.CAMERA error_camera = system.cameras[TEST_CAMERA_ID_2] assert error_camera.camera_type == CameraTypes.UNKNOWN aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_video_urls( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test that camera video URL is configured properly. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v3: A authenticated API connection. """ async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) camera = system.cameras[TEST_CAMERA_ID] assert camera.video_url() == ( f"https://media.simplisafe.com/v1/{TEST_CAMERA_ID}/flv?x=1280&" "audioEncoding=AAC" ) assert camera.video_url(width=720) == ( f"https://media.simplisafe.com/v1/{TEST_CAMERA_ID}/flv?x=720" "&audioEncoding=AAC" ) assert camera.video_url(width=720, audio_encoding="OPUS") == ( f"https://media.simplisafe.com/v1/{TEST_CAMERA_ID}/flv?x=720&" "audioEncoding=OPUS" ) assert camera.video_url(audio_encoding="OPUS") == ( f"https://media.simplisafe.com/v1/{TEST_CAMERA_ID}/flv?x=1280&" "audioEncoding=OPUS" ) assert camera.video_url(additional_param="1") == ( f"https://media.simplisafe.com/v1/{TEST_CAMERA_ID}/flv?x=1280&" "audioEncoding=AAC&additional_param=1" ) aresponses.assert_plan_strictly_followed() simplisafe-python-2024.01.0/tests/test_lock.py000066400000000000000000000225271455300150500212040ustar00rootroot00000000000000"""Define tests for the Lock objects.""" # pylint: disable=protected-access from __future__ import annotations from datetime import timedelta from typing import Any, cast from unittest.mock import Mock import aiohttp import pytest from aresponses import ResponsesMockServer from simplipy import API from simplipy.device.lock import LockStates from simplipy.errors import InvalidCredentialsError from simplipy.system.v3 import SystemV3 from simplipy.util.dt import utcnow from .common import ( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, TEST_LOCK_ID, TEST_LOCK_ID_2, TEST_LOCK_ID_3, TEST_SUBSCRIPTION_ID, TEST_SYSTEM_ID, ) @pytest.mark.asyncio async def test_lock_unlock( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test locking and unlocking the lock. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v3: A authenticated API connection. """ async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/doorlock/{TEST_SUBSCRIPTION_ID}/{TEST_LOCK_ID}/state", "post", response=aresponses.Response(text=None, status=200), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/doorlock/{TEST_SUBSCRIPTION_ID}/{TEST_LOCK_ID}/state", "post", response=aresponses.Response(text=None, status=200), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/doorlock/{TEST_SUBSCRIPTION_ID}/{TEST_LOCK_ID}/state", "post", response=aresponses.Response(text=None, status=200), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) lock = system.locks[TEST_LOCK_ID] state = lock.state assert state == LockStates.LOCKED await lock.async_unlock() state = lock.state assert state == LockStates.UNLOCKED await lock.async_lock() state = lock.state assert state == LockStates.LOCKED aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_jammed( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test that a jammed lock shows the correct state. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v3: A authenticated API connection. """ async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) lock = system.locks[TEST_LOCK_ID_2] assert lock.state is LockStates.JAMMED aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_no_state_change_on_failure( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, invalid_refresh_token_response: dict[str, Any], ) -> None: """Test that the lock doesn't change state on error. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v3: A authenticated API connection. invalid_refresh_token_response: An API response payload. """ async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/doorlock/{TEST_SUBSCRIPTION_ID}/{TEST_LOCK_ID}/state", "post", response=aresponses.Response(text="Unauthorized", status=401), ) authenticated_simplisafe_server_v3.add( "auth.simplisafe.com", "/oauth/token", "post", response=aiohttp.web_response.json_response( invalid_refresh_token_response, status=401 ), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session, ) # Manually set the expiration datetime to force a refresh token flow: simplisafe._token_last_refreshed = utcnow() - timedelta(seconds=30) systems = await simplisafe.async_get_systems() system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) lock = system.locks[TEST_LOCK_ID] assert lock.state == LockStates.LOCKED with pytest.raises(InvalidCredentialsError): await lock.async_unlock() assert lock.state == LockStates.LOCKED aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_properties( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test that lock properties are created properly. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v3: A authenticated API connection. """ async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) lock = system.locks[TEST_LOCK_ID] assert not lock.disabled assert not lock.error assert not lock.lock_low_battery assert not lock.low_battery assert not lock.offline assert not lock.pin_pad_low_battery assert not lock.pin_pad_offline assert lock.state is LockStates.LOCKED aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_unknown_state( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, caplog: Mock, ) -> None: """Test handling a generic error during update. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v3: A authenticated API connection. caplog: A mocked logging utility. """ async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) lock = system.locks[TEST_LOCK_ID_3] assert lock.state == LockStates.UNKNOWN assert any("Unknown raw lock state" in e.message for e in caplog.records) aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_update( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, v3_sensors_response: dict[str, Any], ) -> None: """Test updating the lock. Args: aresponses: An aresponses server. authenticated_simplisafe_server_v3: A authenticated API connection. v3_sensors_response: An API response payload. """ async with authenticated_simplisafe_server_v3: authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/doorlock/{TEST_SUBSCRIPTION_ID}/{TEST_LOCK_ID}/state", "post", response=aresponses.Response(text=None, status=200), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/doorlock/{TEST_SUBSCRIPTION_ID}/{TEST_LOCK_ID}/state", "post", response=aresponses.Response(text=None, status=200), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/sensors", "get", response=aiohttp.web_response.json_response( v3_sensors_response, status=200 ), ) authenticated_simplisafe_server_v3.add( "api.simplisafe.com", f"/v1/doorlock/{TEST_SUBSCRIPTION_ID}/{TEST_LOCK_ID}/state", "post", response=aresponses.Response(text=None, status=200), ) async with aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) systems = await simplisafe.async_get_systems() system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) lock = system.locks[TEST_LOCK_ID] state = lock.state assert state == LockStates.LOCKED await lock.async_unlock() state = lock.state assert state == LockStates.UNLOCKED await lock.async_update() state = lock.state assert state == LockStates.LOCKED aresponses.assert_plan_strictly_followed() simplisafe-python-2024.01.0/tests/test_media.py000066400000000000000000000101031455300150500213160ustar00rootroot00000000000000"""Define tests for motion detection media fetching.""" from __future__ import annotations from typing import Any import aiohttp import pytest from aresponses import ResponsesMockServer from simplipy import API from simplipy.errors import SimplipyError from .common import TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER # Used in a testcase that counts requests to simulate a delayed # media fetch COUNT = 0 # pylint: disable=global-statement @pytest.mark.asyncio async def test_media_file_fetching( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test the media fetching method.""" my_string = "this is an image" content = my_string.encode("utf-8") authenticated_simplisafe_server_v3.add( "remix.us-east-1.prd.cam.simplisafe.com", "/v1/preview/normal", "get", aresponses.Response(body=content, status=200), ) authenticated_simplisafe_server_v3.add( "remix.us-east-1.prd.cam.simplisafe.com", "/v1/preview/timeout", "get", aresponses.Response(status=404), repeat=5, ) # pylint: disable-next=unused-argument def delayed(request: Any) -> aresponses.Response: """Return a 404 a few times, then a 200.""" global COUNT # pylint: disable=global-statement if COUNT >= 3: return aresponses.Response(body=content, status=200) COUNT = COUNT + 1 return aresponses.Response(status=404) authenticated_simplisafe_server_v3.add( "remix.us-east-1.prd.cam.simplisafe.com", "/v1/preview/delayed", "get", response=delayed, repeat=5, ) async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) # simple fetch res = await simplisafe.async_media( url="https://remix.us-east-1.prd.cam.simplisafe.com/v1/preview/normal" ) assert res == content # timeout with error with pytest.raises(SimplipyError): await simplisafe.async_media( url="https://remix.us-east-1.prd.cam.simplisafe.com/v1/preview/timeout" ) # test retries res = await simplisafe.async_media( url="https://remix.us-east-1.prd.cam.simplisafe.com/v1/preview/delayed" ) assert res == content aresponses.assert_plan_strictly_followed() @pytest.mark.asyncio async def test_media_file_enabe_disable_retires( aresponses: ResponsesMockServer, authenticated_simplisafe_server_v3: ResponsesMockServer, ) -> None: """Test the ability to enable/disable retries.""" my_string = "this is an image" content = my_string.encode("utf-8") authenticated_simplisafe_server_v3.add( "remix.us-east-1.prd.cam.simplisafe.com", "/v1/preview/timeout", "get", aresponses.Response(status=404), repeat=2, ) authenticated_simplisafe_server_v3.add( "remix.us-east-1.prd.cam.simplisafe.com", "/v1/preview/timeout", "get", aresponses.Response(body=content, status=200), repeat=1, ) async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: simplisafe = await API.async_from_auth( TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session ) # With retries disabled, the first attempt will raise an error simplisafe.disable_request_retries() with pytest.raises(SimplipyError): await simplisafe.async_media( url="https://remix.us-east-1.prd.cam.simplisafe.com/v1/preview/timeout" ) # When re-enabled, there will be one 404 followed by a 200, no error simplisafe.enable_request_retries() res = await simplisafe.async_media( url="https://remix.us-east-1.prd.cam.simplisafe.com/v1/preview/timeout" ) assert res == content aresponses.assert_plan_strictly_followed() simplisafe-python-2024.01.0/tests/test_websocket.py000066400000000000000000000305411455300150500222350ustar00rootroot00000000000000"""Define tests for the System object.""" from __future__ import annotations import asyncio import logging from collections import deque from datetime import datetime, timedelta, timezone from time import time from typing import Any from unittest.mock import AsyncMock, Mock import pytest from aiohttp.client_exceptions import ( ClientError, ServerDisconnectedError, WSServerHandshakeError, ) from aiohttp.client_reqrep import ClientResponse, RequestInfo from aiohttp.http_websocket import WSMsgType from simplipy.const import LOGGER from simplipy.device import DeviceTypes from simplipy.errors import ( CannotConnectError, ConnectionFailedError, InvalidMessageError, WebsocketError, ) from simplipy.websocket import ( EVENT_DISARMED_BY_KEYPAD, Watchdog, WebsocketClient, websocket_event_from_payload, ) from .common import create_ws_message @pytest.mark.asyncio async def test_callbacks( caplog: Mock, mock_api: Mock, ws_message_event: dict[str, Any], ws_messages: deque ) -> None: """Test that callbacks are executed correctly. Args: caplog: A mocked logging utility. mock_api: A mocked API client. ws_message_event: A websocket event payload. ws_messages: A queue. """ caplog.set_level(logging.INFO) mock_connect_callback = Mock() mock_disconnect_callback = Mock() mock_event_callback = Mock() async def async_mock_connect_callback() -> None: """Define a mock async connect callback.""" LOGGER.info("We are connected!") client = WebsocketClient(mock_api) client.add_connect_callback(mock_connect_callback) client.add_connect_callback(async_mock_connect_callback) client.add_disconnect_callback(mock_disconnect_callback) client.add_event_callback(mock_event_callback) assert mock_connect_callback.call_count == 0 assert mock_disconnect_callback.call_count == 0 assert mock_event_callback.call_count == 0 await client.async_connect() assert client.connected await asyncio.sleep(1) assert mock_connect_callback.call_count == 1 assert any("We are connected!" in e.message for e in caplog.records) ws_messages.append(create_ws_message(ws_message_event)) await client.async_listen() await asyncio.sleep(1) expected_event = websocket_event_from_payload(ws_message_event) mock_event_callback.assert_called_once_with(expected_event) await client.async_disconnect() assert not client.connected @pytest.mark.asyncio @pytest.mark.parametrize( "error", [ ClientError, ServerDisconnectedError, WSServerHandshakeError(Mock(RequestInfo), (Mock(ClientResponse),)), ], ) async def test_cannot_connect( error: BaseException, mock_api: Mock, ws_client_session: AsyncMock ) -> None: """Test being unable to connect to the websocket. Args: error: The error to raise. mock_api: A mocked API client. ws_client_session: A mocked websocket client session. """ ws_client_session.ws_connect.side_effect = error client = WebsocketClient(mock_api) with pytest.raises(CannotConnectError): await client.async_connect() assert not client.connected @pytest.mark.asyncio async def test_connect_disconnect(mock_api: Mock) -> None: """Test connecting and disconnecting the client. Args: mock_api: A mocked API client. """ client = WebsocketClient(mock_api) await client.async_connect() assert client.connected # Attempt to connect again, which should just return: await client.async_connect() await client.async_disconnect() assert not client.connected def test_create_event(ws_message_event: dict[str, Any]) -> None: """Test creating an event object. Args: ws_message_event: A websocket event payload. """ event = websocket_event_from_payload(ws_message_event) assert event.event_type == EVENT_DISARMED_BY_KEYPAD assert event.info == "System Disarmed by Master PIN" assert event.system_id == 12345 assert event.timestamp == datetime(2021, 9, 29, 23, 14, 46, tzinfo=timezone.utc) assert event.changed_by == "Master PIN" assert event.sensor_name == "" assert event.sensor_serial == "abcdef12" assert event.sensor_type == DeviceTypes.KEYPAD assert event.media_urls is None def test_create_motion_event(ws_motion_event: dict[str, Any]) -> None: """Test creating a motion event object. Args: ws_motion_event: A websocket motion event payload with media urls. """ event = websocket_event_from_payload(ws_motion_event) assert event.media_urls is not None assert event.media_urls["image_url"] == "https://image-url{&width}" assert event.media_urls["clip_url"] == "https://clip-url" assert event.media_urls["hls_url"] == "https://hls-url" @pytest.mark.asyncio async def test_listen_invalid_message_data( mock_api: Mock, ws_message_event: dict[str, Any], ws_messages: deque ) -> None: """Test websocket message data that should raise on listen. Args: mock_api: A mocked API client. ws_message_event: A websocket event payload. ws_messages: A queue. """ client = WebsocketClient(mock_api) await client.async_connect() assert client.connected ws_message = create_ws_message(ws_message_event) ws_message.json.side_effect = ValueError("Boom") ws_messages.append(ws_message) with pytest.raises(InvalidMessageError): await client.async_listen() @pytest.mark.asyncio async def test_listen(mock_api: Mock) -> None: """Test listening to the websocket server. Args: mock_api: A mocked API client. """ client = WebsocketClient(mock_api) await client.async_connect() assert client.connected # If this succeeds without throwing an exception, listening was successful: asyncio.create_task(client.async_listen()) await client.async_disconnect() assert not client.connected @pytest.mark.asyncio @pytest.mark.parametrize( "message_type", [WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING] ) async def test_listen_disconnect_message_types( message_type: WSMsgType, mock_api: Mock, ws_client: AsyncMock, ws_message_event: dict[str, Any], ws_messages: deque, ) -> None: """Test different websocket message types that stop listen. Args: message_type: The message type from the websocket. mock_api: A mocked API client. ws_client: A mocked websocket client. ws_message_event: A websocket event payload. ws_messages: A queue. """ client = WebsocketClient(mock_api) await client.async_connect() assert client.connected ws_message = create_ws_message(ws_message_event) ws_message.type = message_type ws_messages.append(ws_message) # This should break out of the listen loop before handling the received message; # otherwise there will be an error: await client.async_listen() # Assert that we received a message: ws_client.receive.assert_awaited() @pytest.mark.asyncio @pytest.mark.parametrize( "message_type, exception", [ (WSMsgType.BINARY, InvalidMessageError), (WSMsgType.ERROR, ConnectionFailedError), ], ) async def test_listen_error_message_types( exception: WebsocketError, message_type: WSMsgType, mock_api: Mock, ws_message_event: dict[str, Any], ws_messages: deque, ) -> None: """Test different websocket message types that should raise on listen. Args: exception: The exception being raised. message_type: The message type from the websocket. mock_api: A mocked API client. ws_message_event: A websocket event payload. ws_messages: A queue. """ client = WebsocketClient(mock_api) await client.async_connect() assert client.connected ws_message = create_ws_message(ws_message_event) ws_message.type = message_type ws_messages.append(ws_message) with pytest.raises(exception): # type: ignore[call-overload] await client.async_listen() @pytest.mark.asyncio async def test_reconnect(mock_api: Mock) -> None: """Test reconnecting to the websocket. Args: mock_api: A mocked API client. """ client = WebsocketClient(mock_api) await client.async_connect() assert client.connected await client.async_reconnect() @pytest.mark.asyncio async def test_remove_callback_callback(mock_api: Mock) -> None: """Test that a removed callback doesn't get executed. Args: mock_api: A mocked API client. """ mock_callback = Mock() client = WebsocketClient(mock_api) remove = client.add_connect_callback(mock_callback) remove() await client.async_connect() assert client.connected assert mock_callback.call_count == 0 await client.async_disconnect() assert not client.connected def test_unknown_event(caplog: Mock, ws_message_event: dict[str, Any]) -> None: """Test that an unknown event type is handled correctly. Args: caplog: A mocked logging utility. ws_message_event: A websocket event payload. """ ws_message_event["data"]["eventCid"] = 9999 event = websocket_event_from_payload(ws_message_event) assert event.event_type is None assert any( "Encountered unknown websocket event type" in e.message for e in caplog.records ) def test_unknown_sensor_type_in_event( caplog: Mock, ws_message_event: dict[str, Any] ) -> None: """Test that an unknown sensor type in a websocket event is handled correctly. Args: caplog: A mocked logging utility. ws_message_event: A websocket event payload. """ ws_message_event["data"]["sensorType"] = 999 event = websocket_event_from_payload(ws_message_event) assert event.sensor_type is None assert any("Encountered unknown device type" in e.message for e in caplog.records) @pytest.mark.asyncio async def test_watchdog_async_trigger(caplog: Mock) -> None: """Test that the watchdog works with a coroutine as a trigger. Args: caplog: A mocked logging utility. """ caplog.set_level(logging.INFO) async def mock_trigger() -> None: """Define a mock trigger.""" LOGGER.info("Triggered mock_trigger") watchdog = Watchdog(mock_trigger, timeout=timedelta(seconds=0)) watchdog.trigger() assert any("Websocket watchdog triggered" in e.message for e in caplog.records) await asyncio.sleep(1) assert any("Websocket watchdog expired" in e.message for e in caplog.records) assert any("Triggered mock_trigger" in e.message for e in caplog.records) @pytest.mark.asyncio async def test_watchdog_cancel(caplog: Mock) -> None: """Test that canceling the watchdog resets and stops it. Args: caplog: A mocked logging utility. """ caplog.set_level(logging.INFO) async def mock_trigger() -> None: """Define a mock trigger.""" LOGGER.info("Triggered mock_trigger") # We test this by ensuring that, although the watchdog has a 5-second timeout, # canceling it ensures that task is stopped: start = time() watchdog = Watchdog(mock_trigger, timeout=timedelta(seconds=5)) watchdog.trigger() await asyncio.sleep(1) watchdog.cancel() end = time() assert (end - start) < 5 assert not any("Triggered mock_trigger" in e.message for e in caplog.records) @pytest.mark.asyncio async def test_watchdog_quick_trigger(caplog: Mock) -> None: """Test that quick triggering of the watchdog resets the timer task. Args: caplog: A mocked logging utility. """ caplog.set_level(logging.INFO) mock_trigger = Mock() watchdog = Watchdog(mock_trigger, timeout=timedelta(seconds=1)) watchdog.trigger() await asyncio.sleep(1) watchdog.trigger() await asyncio.sleep(1) assert mock_trigger.call_count == 2 @pytest.mark.asyncio async def test_watchdog_sync_trigger(caplog: Mock) -> None: """Test that the watchdog works with a normal function as a trigger. Args: caplog: A mocked logging utility. """ caplog.set_level(logging.INFO) mock_trigger = Mock() watchdog = Watchdog(mock_trigger, timeout=timedelta(seconds=0)) watchdog.trigger() assert any("Websocket watchdog triggered" in e.message for e in caplog.records) await asyncio.sleep(1) assert any("Websocket watchdog expired" in e.message for e in caplog.records) assert mock_trigger.call_count == 1