├── .codeclimate.yml ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── labels.yml ├── pull_request_template.md ├── release-drafter.yml └── workflows │ ├── codeql.yml │ ├── labels.yml │ ├── lock.yml │ ├── publish.yml │ ├── release-drafter.yml │ ├── requirements.txt │ ├── stale.yml │ ├── static-analysis.yml │ └── test.yml ├── .gitignore ├── .mise.toml ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── Makefile ├── api.md ├── camera.md ├── conf.py ├── images │ ├── ss-auth-code-in-console.png │ ├── ss-auth-code-in-network.png │ ├── ss-login-screen.png │ └── ss-verification-confirmed.png ├── index.md ├── intro.md ├── lock.md ├── make.bat ├── sensor.md ├── system.md ├── usage.md └── websocket.md ├── examples ├── __init__.py ├── test_client_by_auth.py ├── test_client_by_refresh_token.py └── test_websocket.py ├── poetry.lock ├── pyproject.toml ├── script ├── auth ├── docs ├── release └── setup ├── simplipy ├── __init__.py ├── api.py ├── const.py ├── device │ ├── __init__.py │ ├── camera.py │ ├── lock.py │ └── sensor │ │ ├── __init__.py │ │ ├── v2.py │ │ └── v3.py ├── errors.py ├── py.typed ├── system │ ├── __init__.py │ ├── v2.py │ └── v3.py ├── util │ ├── __init__.py │ ├── auth.py │ ├── dt.py │ └── string.py └── websocket.py └── tests ├── __init__.py ├── common.py ├── conftest.py ├── fixtures ├── api_token_response.json ├── auth_check_response.json ├── events_response.json ├── invalid_authorization_code_response.json ├── invalid_refresh_token_response.json ├── latest_event_response.json ├── subscriptions_response.json ├── unavailable_endpoint_response.json ├── v2_deleted_pins_response.json ├── v2_pins_response.json ├── v2_settings_response.json ├── v2_state_response.json ├── v3_sensors_response.json ├── v3_settings_response.json ├── v3_state_response.json ├── ws_message_event_data.json ├── ws_message_hello_data.json ├── ws_message_subscribed_data.json └── ws_motion_event_data.json ├── sensor ├── __init__.py ├── test_base.py ├── test_v2.py └── test_v3.py ├── system ├── __init__.py ├── test_base.py ├── test_v2.py └── test_v3.py ├── test_api.py ├── test_camera.py ├── test_lock.py ├── test_media.py └── test_websocket.py /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | duplication: 4 | enabled: true 5 | config: 6 | languages: 7 | - python 8 | fixme: 9 | enabled: true 10 | radon: 11 | enabled: true 12 | ratings: 13 | paths: 14 | - "**.py" 15 | exclude_paths: 16 | - dist/ 17 | - docs/ 18 | - tests/ 19 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203,E266,E501,F811,W503 3 | max-line-length = 80 4 | max-complexity = 18 5 | per-file-ignores = tests/*:DAR,S101 6 | select = B,B9,LK,C,D,E,F,I,S,W 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | **Describe the bug** 7 | A clear and concise description of what the bug is. 8 | 9 | **To Reproduce** 10 | Steps to reproduce the behavior: 11 | 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | **Is your feature request related to a problem? Please describe.** 7 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 8 | 9 | **Describe the solution you'd like** 10 | A clear and concise description of what you want to happen. 11 | 12 | **Additional context** 13 | Add any other context or screenshots about the feature request here. 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: pip 5 | directory: "/" 6 | schedule: 7 | interval: daily 8 | time: "06:00" 9 | 10 | - package-ecosystem: pip 11 | directory: "/.github/workflows" 12 | schedule: 13 | interval: daily 14 | time: "06:00" 15 | 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: daily 20 | time: "06:00" 21 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "breaking-change" 3 | color: ee0701 4 | description: "A breaking change for existing users" 5 | - name: "bug" 6 | color: ee0701 7 | description: "Bugs or issues which will cause a problem for users" 8 | - name: "documentation" 9 | color: 0052cc 10 | description: "Project documentation" 11 | - name: "enhancement" 12 | color: 1d76db 13 | description: "Enhancement of the code, not introducing new features." 14 | - name: "maintenance" 15 | color: 2af79e 16 | description: "Generic library tasks" 17 | - name: "dependencies" 18 | color: 1d76db 19 | description: "Upgrade or downgrade of project dependencies" 20 | 21 | - name: "in-progress" 22 | color: fbca04 23 | description: "Issue is currently being resolved by a developer" 24 | - name: "stale" 25 | color: fef2c0 26 | description: "There has not been activity on this issue or PR for some time" 27 | - name: "no-stale" 28 | color: fef2c0 29 | description: "This issue or PR is exempted from the stale bot" 30 | 31 | - name: "security" 32 | color: ee0701 33 | description: "Marks a security issue that needs to be resolved ASAP" 34 | - name: "incomplete" 35 | color: fef2c0 36 | description: "Marks a PR or issue that is missing information" 37 | - name: "invalid" 38 | color: fef2c0 39 | description: "Marks a PR or issue that is missing information" 40 | 41 | - name: "help-wanted" 42 | color: 0e8a16 43 | description: "Needs a helping hang or expertise in order to resolve" 44 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **Describe what the PR does:** 2 | 3 | **Does this fix a specific issue?** 4 | 5 | Fixes https://github.com/bachya/simplisafe-python/issues/ 6 | 7 | **Checklist:** 8 | 9 | - [ ] Confirm that one or more new tests are written for the new functionality. 10 | - [ ] Run tests and ensure everything passes (with 100% test coverage). 11 | - [ ] Update `README.md` with any new documentation. 12 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | categories: 3 | - title: "🚨 Breaking Changes" 4 | labels: 5 | - "breaking-change" 6 | 7 | - title: "🚀 Features" 8 | labels: 9 | - "enhancement" 10 | 11 | - title: "🐛 Bug Fixes" 12 | labels: 13 | - "bug" 14 | 15 | - title: "📕 Documentation" 16 | labels: 17 | - "documentation" 18 | 19 | - title: "🧰 Maintenance" 20 | labels: 21 | - "dependencies" 22 | - "maintenance" 23 | - "tooling" 24 | 25 | change-template: "- $TITLE (#$NUMBER)" 26 | name-template: "$NEXT_PATCH_VERSION" 27 | tag-template: "$NEXT_PATCH_VERSION" 28 | template: | 29 | $CHANGES 30 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CodeQL 3 | 4 | "on": 5 | push: 6 | branches: 7 | - dev 8 | - main 9 | 10 | pull_request: 11 | branches: 12 | - dev 13 | - main 14 | 15 | workflow_dispatch: 16 | 17 | schedule: 18 | - cron: "30 1 * * 0" 19 | 20 | jobs: 21 | codeql: 22 | name: Scanning 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: ⤵️ Check out code from GitHub 26 | uses: actions/checkout@v4 27 | 28 | - name: 🏗 Initialize CodeQL 29 | uses: github/codeql-action/init@v3 30 | 31 | - name: 🚀 Perform CodeQL Analysis 32 | uses: github/codeql-action/analyze@v3 33 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sync Labels 3 | 4 | "on": 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - .github/labels.yml 10 | 11 | workflow_dispatch: 12 | 13 | jobs: 14 | labels: 15 | name: ♻️ Sync labels 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: ⤵️ Check out code from GitHub 19 | uses: actions/checkout@v4 20 | 21 | - name: 🚀 Run Label Syncer 22 | uses: micnncim/action-label-syncer@v1.3.0 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/lock.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lock Closed Issues and PRs 3 | 4 | "on": 5 | schedule: 6 | - cron: "0 9 * * *" 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lock: 12 | name: 🔒 Lock! 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: dessant/lock-threads@v5.0.1 16 | with: 17 | github-token: ${{ github.token }} 18 | issue-inactive-days: "30" 19 | issue-lock-reason: "" 20 | pr-inactive-days: "1" 21 | pr-lock-reason: "" 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Publish to PyPI 3 | 4 | "on": 5 | push: 6 | tags: 7 | - "*" 8 | 9 | jobs: 10 | publish_to_pypi: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: ⤵️ Check out code from GitHub 15 | uses: actions/checkout@v4 16 | 17 | - name: 🏗 Set up Python 3.12 18 | id: python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.12" 22 | 23 | - name: 🚀 Publish to PyPi 24 | run: | 25 | pip install poetry 26 | poetry publish --build -u __token__ -p ${{ secrets.PYPI_API_KEY }} 27 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release Drafter 3 | 4 | "on": 5 | push: 6 | branches: 7 | - main 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update_release_draft: 13 | name: ✏️ Draft Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: 🚀 Run Release Drafter 17 | uses: release-drafter/release-drafter@v6.0.0 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/requirements.txt: -------------------------------------------------------------------------------- 1 | poetry==1.8.4 2 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Stale 3 | 4 | "on": 5 | schedule: 6 | - cron: "0 8 * * *" 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | stale: 12 | name: 🧹 Clean up stale issues and PRs 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: 🚀 Run stale 16 | uses: actions/stale@v9 17 | with: 18 | repo-token: ${{ secrets.GITHUB_TOKEN }} 19 | days-before-stale: 30 20 | days-before-close: 7 21 | remove-stale-when-updated: true 22 | stale-issue-label: "stale" 23 | exempt-issue-labels: "no-stale,help-wanted" 24 | stale-issue-message: > 25 | There hasn't been any activity on this issue recently, so it 26 | has been marked as stale. 27 | 28 | Please make sure to update to the latest version and 29 | check if that solves the issue. Let us know if that works for you 30 | by leaving a comment. 31 | 32 | This issue will be closed if no further activity occurs. Thanks! 33 | stale-pr-label: "stale" 34 | exempt-pr-labels: "no-stale" 35 | stale-pr-message: > 36 | There hasn't been any activity on this pull request recently, so 37 | it has automatically been marked as stale and will be closed if 38 | no further action occurs within 7 days. Thank you for your 39 | contributions. 40 | -------------------------------------------------------------------------------- /.github/workflows/static-analysis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Linting and Static Analysis 3 | 4 | "on": 5 | pull_request: 6 | branches: 7 | - dev 8 | - main 9 | 10 | push: 11 | branches: 12 | - dev 13 | - main 14 | 15 | jobs: 16 | lint: 17 | name: "Linting & Static Analysis" 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: ⤵️ Check out code from GitHub 23 | uses: actions/checkout@v4 24 | 25 | - name: 🏗 Set up Python 3.12 26 | id: setup-python 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: "3.12" 30 | 31 | - name: ⤵️ Get pip cache directory 32 | id: pip-cache 33 | run: | 34 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 35 | 36 | - name: ⤵️ Establish pip cache 37 | uses: actions/cache@v4 38 | with: 39 | path: ${{ steps.pip-cache.outputs.dir }} 40 | key: "${{ runner.os }}-pip-\ 41 | ${{ hashFiles('.github/workflows/requirements.txt') }}" 42 | restore-keys: | 43 | ${{ runner.os }}-pip- 44 | 45 | - name: 🏗 Install workflow dependencies 46 | run: | 47 | pip install -r .github/workflows/requirements.txt 48 | poetry config virtualenvs.create true 49 | poetry config virtualenvs.in-project true 50 | 51 | - name: ⤵️ Establish poetry cache 52 | uses: actions/cache@v4 53 | with: 54 | path: .venv 55 | key: "venv-${{ steps.setup-python.outputs.python-version }}-\ 56 | ${{ hashFiles('poetry.lock') }}" 57 | restore-keys: | 58 | venv-${{ steps.setup-python.outputs.python-version }}- 59 | 60 | - name: 🏗 Install package dependencies 61 | run: | 62 | poetry install --no-interaction 63 | 64 | - name: 🚀 Run pre-commit hooks 65 | uses: pre-commit/action@v3.0.1 66 | env: 67 | SKIP: no-commit-to-branch,pytest 68 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests and Coverage 3 | 4 | "on": 5 | pull_request: 6 | branches: 7 | - dev 8 | - main 9 | 10 | push: 11 | branches: 12 | - dev 13 | - main 14 | 15 | jobs: 16 | test: 17 | name: Tests 18 | 19 | runs-on: ubuntu-latest 20 | 21 | strategy: 22 | matrix: 23 | python-version: 24 | - "3.10" 25 | - "3.11" 26 | - "3.12" 27 | 28 | steps: 29 | - name: ⤵️ Check out code from GitHub 30 | uses: actions/checkout@v4 31 | 32 | - name: 🏗 Set up Python 33 | id: setup-python 34 | uses: actions/setup-python@v5 35 | with: 36 | python-version: ${{ matrix.python-version }} 37 | 38 | - name: ⤵️ Get pip cache directory 39 | id: pip-cache 40 | run: | 41 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 42 | 43 | - name: ⤵️ Establish pip cache 44 | uses: actions/cache@v4 45 | with: 46 | path: ${{ steps.pip-cache.outputs.dir }} 47 | key: "${{ runner.os }}-pip-\ 48 | ${{ hashFiles('.github/workflows/requirements.txt') }}" 49 | restore-keys: | 50 | ${{ runner.os }}-pip- 51 | 52 | - name: 🏗 Install workflow dependencies 53 | run: | 54 | pip install -r .github/workflows/requirements.txt 55 | poetry config virtualenvs.create true 56 | poetry config virtualenvs.in-project true 57 | 58 | - name: ⤵️ Establish poetry cache 59 | uses: actions/cache@v4 60 | with: 61 | path: .venv 62 | key: "venv-${{ steps.setup-python.outputs.python-version }}-\ 63 | ${{ hashFiles('poetry.lock') }}" 64 | restore-keys: | 65 | venv-${{ steps.setup-python.outputs.python-version }}- 66 | 67 | - name: 🏗 Install package dependencies 68 | run: | 69 | poetry install --no-interaction 70 | 71 | - name: 🚀 Run pytest 72 | run: poetry run pytest --cov simplipy tests 73 | 74 | - name: ⬆️ Upload coverage artifact 75 | uses: actions/upload-artifact@v4 76 | with: 77 | name: coverage-${{ matrix.python-version }} 78 | path: .coverage 79 | include-hidden-files: true 80 | 81 | coverage: 82 | name: Code Coverage 83 | 84 | needs: test 85 | 86 | runs-on: ubuntu-latest 87 | 88 | steps: 89 | - name: ⤵️ Check out code from GitHub 90 | uses: actions/checkout@v4 91 | 92 | - name: ⬇️ Download coverage data 93 | uses: actions/download-artifact@v4 94 | 95 | - name: 🏗 Set up Python 3.12 96 | id: setup-python 97 | uses: actions/setup-python@v5 98 | with: 99 | python-version: "3.12" 100 | 101 | - name: ⤵️ Get pip cache directory 102 | id: pip-cache 103 | run: | 104 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 105 | 106 | - name: ⤵️ Establish pip cache 107 | uses: actions/cache@v4 108 | with: 109 | path: ${{ steps.pip-cache.outputs.dir }} 110 | key: "${{ runner.os }}-pip-\ 111 | ${{ hashFiles('.github/workflows/requirements.txt') }}" 112 | restore-keys: | 113 | ${{ runner.os }}-pip- 114 | 115 | - name: 🏗 Install workflow dependencies 116 | run: | 117 | pip install -r .github/workflows/requirements.txt 118 | poetry config virtualenvs.create true 119 | poetry config virtualenvs.in-project true 120 | 121 | - name: ⤵️ Establish poetry cache 122 | uses: actions/cache@v4 123 | with: 124 | path: .venv 125 | key: "venv-${{ steps.setup-python.outputs.python-version }}-\ 126 | ${{ hashFiles('poetry.lock') }}" 127 | restore-keys: | 128 | venv-${{ steps.setup-python.outputs.python-version }}- 129 | 130 | - name: 🏗 Install package dependencies 131 | run: | 132 | poetry install --no-interaction 133 | 134 | - name: 🚀 Process coverage results 135 | run: | 136 | poetry run coverage combine coverage*/.coverage* 137 | poetry run coverage xml -i 138 | 139 | - name: 📊 Upload coverage report to codecov.io 140 | uses: codecov/codecov-action@v4 141 | with: 142 | token: ${{ secrets.CODECOV_TOKEN }} 143 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | .coverage 3 | .mypy_cache 4 | .nox 5 | .tox 6 | .venv 7 | __pycache__ 8 | coverage.xml 9 | docs/_build 10 | tags 11 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | python = { version="3.12", virtualenv=".venv" } 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: blacken-docs 6 | name: ☕️ Format documentation using black 7 | language: system 8 | files: '\.(rst|md|markdown|py|tex)$' 9 | entry: poetry run blacken-docs 10 | require_serial: true 11 | - id: check-ast 12 | name: 🐍 Checking Python AST 13 | language: system 14 | types: [python] 15 | entry: poetry run check-ast 16 | - id: check-case-conflict 17 | name: 🔠 Checking for case conflicts 18 | language: system 19 | entry: poetry run check-case-conflict 20 | - id: check-docstring-first 21 | name: ℹ️ Checking docstrings are first 22 | language: system 23 | types: [python] 24 | entry: poetry run check-docstring-first 25 | - id: check-executables-have-shebangs 26 | name: 🧐 Checking that executables have shebangs 27 | language: system 28 | types: [text, executable] 29 | entry: poetry run check-executables-have-shebangs 30 | stages: [commit, push, manual] 31 | - id: check-json 32 | name: { Checking JSON files 33 | language: system 34 | types: [json] 35 | entry: poetry run check-json 36 | - id: check-merge-conflict 37 | name: 💥 Checking for merge conflicts 38 | language: system 39 | types: [text] 40 | entry: poetry run check-merge-conflict 41 | - id: check-symlinks 42 | name: 🔗 Checking for broken symlinks 43 | language: system 44 | types: [symlink] 45 | entry: poetry run check-symlinks 46 | - id: check-toml 47 | name: ✅ Checking TOML files 48 | language: system 49 | types: [toml] 50 | entry: poetry run check-toml 51 | - id: check-yaml 52 | name: ✅ Checking YAML files 53 | language: system 54 | types: [yaml] 55 | entry: poetry run check-yaml 56 | - id: codespell 57 | name: ✅ Checking code for misspellings 58 | language: system 59 | types: [text] 60 | exclude: ^poetry\.lock$ 61 | entry: poetry run codespell -L resset 62 | - id: debug-statements 63 | name: 🪵 Checking for debug statements and imports (Python) 64 | language: system 65 | types: [python] 66 | entry: poetry run debug-statement-hook 67 | - id: detect-private-key 68 | name: 🕵️ Detecting private keys 69 | language: system 70 | types: [text] 71 | entry: poetry run detect-private-key 72 | - id: end-of-file-fixer 73 | name: 🔚 Checking end of files 74 | language: system 75 | types: [text] 76 | entry: poetry run end-of-file-fixer 77 | stages: [commit, push, manual] 78 | - id: fix-byte-order-marker 79 | name: 🚏 Checking UTF-8 byte order marker 80 | language: system 81 | types: [text] 82 | entry: poetry run fix-byte-order-marker 83 | - id: format 84 | name: ☕️ Formatting code using ruff 85 | language: system 86 | types: [python] 87 | entry: poetry run ruff format 88 | - id: isort 89 | name: 🔀 Sorting all imports with isort 90 | language: system 91 | types: [python] 92 | entry: poetry run isort 93 | - id: mypy 94 | name: 🆎 Performing static type checking using mypy 95 | language: system 96 | types: [python] 97 | entry: poetry run mypy 98 | require_serial: true 99 | - id: no-commit-to-branch 100 | name: 🛑 Checking for commit to protected branch 101 | language: system 102 | entry: poetry run no-commit-to-branch 103 | pass_filenames: false 104 | always_run: true 105 | args: 106 | - --branch=dev 107 | - --branch=main 108 | - id: poetry 109 | name: 📜 Checking pyproject with Poetry 110 | language: system 111 | entry: poetry check 112 | pass_filenames: false 113 | always_run: true 114 | - id: pylint 115 | name: 🌟 Starring code with pylint 116 | language: system 117 | types: [python] 118 | entry: poetry run pylint 119 | exclude: ^docs\/conf.py$ 120 | - id: pyupgrade 121 | name: 🆙 Checking for upgradable syntax with pyupgrade 122 | language: system 123 | types: [python] 124 | entry: poetry run pyupgrade 125 | args: [--py39-plus, --keep-runtime-typing] 126 | - id: ruff 127 | name: 👔 Enforcing style guide with ruff 128 | language: system 129 | types: [python] 130 | entry: poetry run ruff check --fix 131 | exclude: ^docs\/conf.py$ 132 | - id: trailing-whitespace 133 | name: ✄ Trimming trailing whitespace 134 | language: system 135 | types: [text] 136 | entry: poetry run trailing-whitespace-fixer 137 | stages: [commit, push, manual] 138 | - id: vulture 139 | name: 🔍 Finding unused Python code with Vulture 140 | language: system 141 | types: [python] 142 | entry: poetry run vulture 143 | pass_filenames: false 144 | require_serial: true 145 | - id: yamllint 146 | name: 🎗 Checking YAML files with yamllint 147 | language: system 148 | types: [yaml] 149 | entry: poetry run yamllint 150 | 151 | - repo: https://github.com/pre-commit/mirrors-prettier 152 | rev: "v3.0.0-alpha.4" 153 | hooks: 154 | - id: prettier 155 | name: 💄 Ensuring files are prettier 156 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | build: 5 | os: ubuntu-20.04 6 | tools: 7 | python: "3.10" 8 | jobs: 9 | post_install: 10 | - pip install poetry==1.3.2 11 | - poetry config virtualenvs.create false 12 | - poetry install --with doc 13 | 14 | sphinx: 15 | configuration: docs/conf.py 16 | fail_on_warning: true 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | _NOTE:_ this file is here for posterity; going forward, release info can be found here: 4 | https://github.com/bachya/simplisafe-python/releases 5 | 6 | ## 3.0.0 7 | 8 | - Convert library to use `asyncio` 9 | - Added full test sweet (with 100% coverage) 10 | - Complete overhaul of documentation 11 | - Added Makefile with common tasks 12 | - Added Pipfile for dependency management 13 | - Added CodeClimate and CodeCov badging/tracking 14 | - Updated `setup.py` to common standard 15 | - Added contribution instructions 16 | - Added author recognition 17 | 18 | ## 2.0.2 19 | 20 | - Raise exception when setup doesn't complete 21 | 22 | ## 2.0.1 23 | 24 | - Allow users without monthly monitoring to get system status 25 | 26 | ## 2.0.0 27 | 28 | - Moving to the new API 29 | 30 | ## 1.0.5 31 | 32 | - Get status from "Dashboard" request instead of "Locations" request because simplisafe was returning 'error' 33 | 34 | ## 1.0.4 35 | 36 | - Don't log error when user doesn't have a freeze sensor 37 | 38 | ## 1.0.3 39 | 40 | - Added protection against incorrect data from SimpliSafe back-end 41 | 42 | ## 1.0.2 43 | 44 | - Return status of login corrected spelling of credentials 45 | 46 | ## 1.0.1 47 | 48 | - Fixed logging in system 49 | 50 | ## 1.0.0 51 | 52 | - Complete re-write 53 | 54 | ## 0.0.1 55 | 56 | - Initial support 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 William Scanlon, 2018-2024 Aaron Bach 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚨 simplisafe-python: A Python3, async interface to the SimpliSafe™ API 2 | 3 | [![CI][ci-badge]][ci] 4 | [![PyPI][pypi-badge]][pypi] 5 | [![Version][version-badge]][version] 6 | [![License][license-badge]][license] 7 | [![Code Coverage][codecov-badge]][codecov] 8 | [![Maintainability][maintainability-badge]][maintainability] 9 | 10 | Buy Me A Coffee 11 | 12 | `simplisafe-python` (hereafter referred to as `simplipy`) is a Python3, 13 | `asyncio`-driven interface to the unofficial [SimpliSafe™][simplisafe] API. With it, 14 | users can get data on their system (including available sensors), set the system state, 15 | and more. 16 | 17 | # Documentation 18 | 19 | You can find complete documentation [here][docs]. 20 | 21 | # Contributing 22 | 23 | Thanks to all of [our contributors][contributors] so far! 24 | 25 | 1. [Check for open features/bugs][issues] or [initiate a discussion on one][new-issue]. 26 | 2. [Fork the repository][fork]. 27 | 3. (_optional, but highly recommended_) Create a virtual environment: `python3 -m venv .venv` 28 | 4. (_optional, but highly recommended_) Enter the virtual environment: `source ./.venv/bin/activate` 29 | 5. Install the dev environment: `script/setup` 30 | 6. Code your new feature or bug fix on a new branch. 31 | 7. Write tests that cover your new functionality. 32 | 8. Run tests and ensure 100% code coverage: `poetry run pytest --cov simplipy tests` 33 | 9. Update `README.md` with any new documentation. 34 | 10. Submit a pull request! 35 | 36 | [ci-badge]: https://img.shields.io/github/actions/workflow/status/bachya/simplisafe-python/test.yml 37 | [ci]: https://github.com/bachya/simplisafe-python/actions 38 | [codecov-badge]: https://codecov.io/gh/bachya/simplisafe-python/branch/dev/graph/badge.svg 39 | [codecov]: https://codecov.io/gh/bachya/simplisafe-python 40 | [contributors]: https://github.com/bachya/simplisafe-python/graphs/contributors 41 | [docs]: https://simplisafe-python.readthedocs.io 42 | [fork]: https://github.com/bachya/simplisafe-python/fork 43 | [issues]: https://github.com/bachya/simplisafe-python/issues 44 | [license-badge]: https://img.shields.io/pypi/l/simplisafe-python.svg 45 | [license]: https://github.com/bachya/simplisafe-python/blob/main/LICENSE 46 | [maintainability-badge]: https://api.codeclimate.com/v1/badges/f46d8b1dcfde6a2f683d/maintainability 47 | [maintainability]: https://codeclimate.com/github/bachya/simplisafe-python/maintainability 48 | [new-issue]: https://github.com/bachya/simplisafe-python/issues/new 49 | [pypi-badge]: https://img.shields.io/pypi/v/simplisafe-python.svg 50 | [pypi]: https://pypi.python.org/pypi/simplisafe-python 51 | [simplisafe]: https://simplisafe.com 52 | [version-badge]: https://img.shields.io/pypi/pyversions/simplisafe-python.svg 53 | [version]: https://pypi.python.org/pypi/simplisafe-python 54 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ```{toctree} 4 | :maxdepth: 3 5 | ``` 6 | 7 | ```{eval-rst} 8 | .. module:: simplipy 9 | ``` 10 | 11 | ## API 12 | 13 | ```{eval-rst} 14 | .. automodule:: simplipy.api 15 | :members: 16 | ``` 17 | 18 | ## Websocket Communication 19 | 20 | ```{eval-rst} 21 | .. autoclass:: simplipy.websocket.WebsocketClient 22 | :members: 23 | ``` 24 | 25 | ```{eval-rst} 26 | .. autoclass:: simplipy.websocket.WebsocketEvent 27 | :members: 28 | :undoc-members: 29 | ``` 30 | 31 | ## Devices 32 | 33 | ```{eval-rst} 34 | .. autoclass:: simplipy.device.Device 35 | :members: 36 | ``` 37 | 38 | ```{eval-rst} 39 | .. autoclass:: simplipy.device.DeviceTypes 40 | :members: 41 | :undoc-members: 42 | ``` 43 | 44 | ```{eval-rst} 45 | .. autoclass:: simplipy.device.DeviceV3 46 | :members: 47 | ``` 48 | 49 | ## Lock 50 | 51 | ```{eval-rst} 52 | .. autoclass:: simplipy.device.lock.Lock 53 | :members: 54 | ``` 55 | 56 | ```{eval-rst} 57 | .. autoclass:: simplipy.device.lock.LockStates 58 | :members: 59 | :undoc-members: 60 | ``` 61 | 62 | ## Sensors 63 | 64 | ```{eval-rst} 65 | .. autoclass:: simplipy.device.sensor.v2.SensorV2 66 | :members: 67 | ``` 68 | 69 | ```{eval-rst} 70 | .. autoclass:: simplipy.device.sensor.v3.SensorV3 71 | :members: 72 | ``` 73 | 74 | ## Systems 75 | 76 | ```{eval-rst} 77 | .. autoclass:: simplipy.system.System 78 | :members: 79 | ``` 80 | 81 | ```{eval-rst} 82 | .. autoclass:: simplipy.system.v2.SystemV2 83 | :members: 84 | ``` 85 | 86 | ```{eval-rst} 87 | .. autoclass:: simplipy.system.v3.SystemV3 88 | :members: 89 | ``` 90 | 91 | ```{eval-rst} 92 | .. autoclass:: simplipy.system.SystemNotification 93 | :members: 94 | :undoc-members: 95 | ``` 96 | 97 | ```{eval-rst} 98 | .. autoclass:: simplipy.system.SystemStates 99 | :members: 100 | :undoc-members: 101 | ``` 102 | 103 | ## Utilities 104 | 105 | ```{eval-rst} 106 | .. automodule:: simplipy.util 107 | :members: 108 | ``` 109 | 110 | ### `auth` 111 | 112 | ```{eval-rst} 113 | .. automodule:: simplipy.util.auth 114 | :members: 115 | ``` 116 | 117 | ### `dt` 118 | 119 | ```{eval-rst} 120 | .. automodule:: simplipy.util.dt 121 | :members: 122 | ``` 123 | 124 | ### `string` 125 | 126 | ```{eval-rst} 127 | .. automodule:: simplipy.util.string 128 | :members: 129 | ``` 130 | 131 | ## Errors 132 | 133 | ```{eval-rst} 134 | .. automodule:: simplipy.errors 135 | :members: 136 | ``` 137 | -------------------------------------------------------------------------------- /docs/camera.md: -------------------------------------------------------------------------------- 1 | # Cameras 2 | 3 | {meth}`Camera ` objects correspond to SimpliSafe™ "SimpliCam" 4 | cameras and doorbells (only available for V3 systems) and allow users to retrieve 5 | information on them, including URLs to view short-lived streams of the camera. 6 | 7 | ## Core Properties 8 | 9 | All {meth}`Camera ` objects come with a standard set of properties: 10 | 11 | ```python 12 | for serial, camera in system.cameras.items(): 13 | # Return the cammera's UUID: 14 | serial 15 | # >>> 1234ABCD 16 | 17 | # ...or through the property: 18 | camera.serial 19 | # >>> 1234ABCD 20 | 21 | # Return all camera settings data: 22 | camera.camera_settings 23 | # >>> {"cameraName": "Camera", "pictureQuality": "720p", ...} 24 | 25 | # Return the type of camera this object represents: 26 | camera.camera_type 27 | # >>> doorbell 28 | 29 | # Return the camera name: 30 | camera.name 31 | # >>> My Doorbell 32 | 33 | # Return whether the privacy shutter is open when the 34 | # alarm is armed in away mode: 35 | camera.shutter_open_when_off 36 | # >>> False 37 | 38 | # Return whether the privacy shutter is open when the 39 | # alarm is armed in home mode: 40 | camera.shutter_open_when_home 41 | # >>> False 42 | 43 | # Return whether the privacy shutter is open when the 44 | # alarm is disarmed: 45 | camera.shutter_open_when_off 46 | # >>> False 47 | 48 | # Return the camera status: 49 | camera.status 50 | # >>> online 51 | 52 | # Return the camera subscription status: 53 | camera.subscription_enabled 54 | # >>> True 55 | ``` 56 | 57 | ## Getting the Camera Video URL 58 | 59 | ```python 60 | url = camera.video_url() 61 | # >>> https://media.simplisafe.com/v1/... 62 | ``` 63 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Define docs configuration.""" 2 | # Configuration file for the Sphinx documentation builder. 3 | # 4 | # This file only contains a selection of the most common options. For a full 5 | # list see the documentation: 6 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 7 | 8 | # -- Path setup -------------------------------------------------------------- 9 | 10 | # If extensions (or modules to document with autodoc) are in another directory, 11 | # add these directories to sys.path here. If the directory is relative to the 12 | # documentation root, use os.path.abspath to make it absolute, like shown here. 13 | # 14 | # import os 15 | # import sys 16 | # sys.path.insert(0, os.path.abspath('.')) 17 | from datetime import datetime 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = "simplisafe-python" 22 | copyright = f"{datetime.today().year}, Aaron Bach" 23 | author = "Aaron Bach" 24 | 25 | # The full version, including alpha/beta/rc tags 26 | release = "2024.01.0" 27 | 28 | 29 | # -- General configuration --------------------------------------------------- 30 | 31 | master_doc = "index" 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = [ 37 | "myst_parser", 38 | "sphinx.ext.autodoc", 39 | "sphinx.ext.autosectionlabel", 40 | "sphinx.ext.napoleon", 41 | "sphinx_rtd_theme", 42 | ] 43 | 44 | # Add any paths that contain templates here, relative to this directory. 45 | templates_path = ["_templates"] 46 | 47 | # List of patterns, relative to source directory, that match files and 48 | # directories to ignore when looking for source files. 49 | # This pattern also affects html_static_path and html_extra_path. 50 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 51 | 52 | 53 | # -- Options for HTML output ------------------------------------------------- 54 | 55 | # The theme to use for HTML and HTML Help pages. See the documentation for 56 | # a list of builtin themes. 57 | # 58 | html_theme = "sphinx_rtd_theme" 59 | 60 | # Theme options are theme-specific and customize the look and feel of a theme 61 | # further. For a list of options available for each theme, see the 62 | # documentation. 63 | # 64 | html_theme_options = {"display_version": True, "style_external_links": True} 65 | 66 | # Add any paths that contain custom static files (such as style sheets) here, 67 | # relative to this directory. They are copied after the builtin static files, 68 | # so a file named "default.css" will overwrite the builtin "default.css". 69 | # html_static_path = ["_static"] 70 | 71 | # -- Options for autodoc ------------------------------------------------- 72 | 73 | autodoc_default_options = {"member-order": "bysource"} 74 | autosectionlabel_prefix_document = True 75 | -------------------------------------------------------------------------------- /docs/images/ss-auth-code-in-console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachya/simplisafe-python/ff16fe52aeb4ba2485d9a867d90b18879b37c68b/docs/images/ss-auth-code-in-console.png -------------------------------------------------------------------------------- /docs/images/ss-auth-code-in-network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachya/simplisafe-python/ff16fe52aeb4ba2485d9a867d90b18879b37c68b/docs/images/ss-auth-code-in-network.png -------------------------------------------------------------------------------- /docs/images/ss-login-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachya/simplisafe-python/ff16fe52aeb4ba2485d9a867d90b18879b37c68b/docs/images/ss-login-screen.png -------------------------------------------------------------------------------- /docs/images/ss-verification-confirmed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachya/simplisafe-python/ff16fe52aeb4ba2485d9a867d90b18879b37c68b/docs/images/ss-verification-confirmed.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # simplisafe-python 2 | 3 | `simplisafe-python` (hereafter referred to as `simplipy`) is a Python3, 4 | `asyncio`-driven interface to the unofficial SimpliSafe™ API. With it, users can 5 | get data on their system (including available sensors), set the system state, 6 | and more. 7 | 8 | ```{toctree} 9 | :maxdepth: 3 10 | 11 | intro 12 | usage 13 | system 14 | sensor 15 | lock 16 | camera 17 | websocket 18 | api 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | `simplisafe-python` (hereafter referred to as `simplipy`) is a Python3, 4 | `asyncio`-driven interface to the unofficial SimpliSafe™ API. With it, users can 5 | get data on their system (including available sensors), set the system state, 6 | and more. 7 | 8 | **NOTE:** SimpliSafe™ has no official API; therefore, this library may stop 9 | working at any time without warning. 10 | 11 | **SPECIAL THANKS:** Original inspiration was obtained from 12 | [`easysafe-python`][easysafe-python]; thanks to Scott Newman for all the hard work! 13 | 14 | [easysafe-python]: https://github.com/greencoder/easysafe-python 15 | -------------------------------------------------------------------------------- /docs/lock.md: -------------------------------------------------------------------------------- 1 | # Locks 2 | 3 | {meth}`Lock ` objects correspond to SimpliSafe™ locks (only 4 | available for V3 systems) and allow users to retrieve information on them and alter 5 | their state by locking/unlocking them. 6 | 7 | ## Core Properties 8 | 9 | All {meth}`Lock ` objects come with a standard set of properties: 10 | 11 | ```python 12 | for serial, lock in system.locks.items(): 13 | # Return the lock's name: 14 | lock.name 15 | # >>> Kitchen Window 16 | 17 | # Return the lock's serial number through the index: 18 | serial 19 | # >>> 1234ABCD 20 | 21 | # ...or through the property: 22 | lock.serial 23 | # >>> 1234ABCD 24 | 25 | # Return the state of the lock: 26 | lock.state 27 | # >>> simplipy.lock.LockStates.LOCKED 28 | 29 | # Return whether the lock is in an error state: 30 | lock.error 31 | # >>> False 32 | 33 | # Return whether the lock has a low battery: 34 | lock.low_battery 35 | # >>> False 36 | 37 | # Return whether the lock is offline: 38 | lock.offline 39 | # >>> False 40 | 41 | # Return a settings dictionary for the lock: 42 | lock.settings 43 | # >>> {"autoLock": 3, "away": 1, "home": 1} 44 | 45 | # Return whether the lock is disabled: 46 | lock.disabled 47 | # >>> False 48 | 49 | # Return whether the lock's battery is low: 50 | lock.lock_low_battery 51 | # >>> False 52 | 53 | # Return whether the pin pad's battery is low: 54 | lock.pin_pad_low_battery 55 | # >>> False 56 | 57 | # Return whether the pin pad is offline: 58 | lock.pin_pad_offline 59 | # >>> False 60 | ``` 61 | 62 | ## Locking/Unlocking 63 | 64 | Locking and unlocking a lock is accomplished via two coroutines: 65 | 66 | ```python 67 | for serial, lock in system.locks.items(): 68 | await lock.async_lock() 69 | await lock.async_unlock() 70 | ``` 71 | 72 | ## Updating the Lock 73 | 74 | To retrieve the sensor's latest state/properties/etc., simply: 75 | 76 | ```python 77 | await lock.async_update(cached=True) 78 | ``` 79 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/sensor.md: -------------------------------------------------------------------------------- 1 | # Sensors 2 | 3 | Sensor objects provide information about the SimpliSafe™ sensors to which they relate. 4 | 5 | Like their {meth}`System ` cousins, two types of objects can be 6 | returned: 7 | 8 | - {meth}`SensorV2 `: an object to view V2 (classic) 9 | SimpliSafe™ sensors 10 | - {meth}`SensorV3 `: an object to view V3 (new, released in 11 | 2018\) SimpliSafe™ sensors 12 | 13 | Once again, `simplipy` provides a common interface to 14 | these objects; however, there are some properties that are either (a) specific 15 | to one version or (b) return a different meaning based on the version. These 16 | differences are outlined below. 17 | 18 | ## Core Properties 19 | 20 | All `Sensor` objects come with a standard set of properties 21 | 22 | ```python 23 | for serial, sensor in system.sensors.items(): 24 | # Return the sensor's name: 25 | sensor.name 26 | # >>> Kitchen Window 27 | 28 | # Return the sensor's serial number through the index: 29 | serial 30 | # >>> 1234ABCD 31 | 32 | # ...or through the property: 33 | sensor.serial 34 | # >>> 1234ABCD 35 | 36 | # Return the sensor's type: 37 | sensor.type 38 | # >>> simplipy.DeviceTypes.GLASS_BREAK 39 | 40 | # Return whether the sensor is in an error state: 41 | sensor.error 42 | # >>> False 43 | 44 | # Return whether the sensor has a low battery: 45 | sensor.low_battery 46 | # >>> False 47 | 48 | # Return whether the sensor has been triggered 49 | # (open/closed, etc.): 50 | sensor.triggered 51 | # >>> False 52 | ``` 53 | 54 | ## V2 Properties 55 | 56 | ```python 57 | # Return the sensor's data as a currently 58 | # non-understood integer: 59 | sensor.data 60 | # >>> 0 61 | 62 | # Return the sensor's settings as a currently 63 | # non-understood integer: 64 | sensor.settings 65 | # >>> 1 66 | ``` 67 | 68 | ## V3 Properties 69 | 70 | ```python 71 | # Return whether the sensor is offline: 72 | sensor.offline 73 | # >>> False 74 | 75 | # Return a settings dictionary for the sensor: 76 | sensor.settings 77 | # >>> {"instantTrigger": False, "away2": 1, "away": 1, ...} 78 | 79 | # For temperature sensors, return the current temperature: 80 | sensor.temperature 81 | # >>> 67 82 | ``` 83 | 84 | ## Updating the Sensor 85 | 86 | To retrieve the sensor's latest state/properties/etc., simply: 87 | 88 | ```python 89 | await sensor.async_update(cached=True) 90 | ``` 91 | -------------------------------------------------------------------------------- /docs/system.md: -------------------------------------------------------------------------------- 1 | # Systems 2 | 3 | {meth}`System ` objects are used to retrieve data on and control the state 4 | of SimpliSafe™ systems. Two types of objects can be returned: 5 | 6 | - {meth}`SystemV2 `: an object to control V2 (classic) 7 | SimpliSafe™ systems 8 | - {meth}`SystemV3 `: an object to control V3 (new, released 9 | in 2018) SimpliSafe™ systems 10 | 11 | Despite the differences, `simplipy` provides a common interface to 12 | these objects, meaning many of the same properties and methods are available to 13 | both. 14 | 15 | To get all SimpliSafe™ systems associated with an account: 16 | 17 | ```python 18 | import asyncio 19 | 20 | from aiohttp import ClientSession 21 | import simplipy 22 | 23 | 24 | async def main() -> None: 25 | """Create the aiohttp session and run.""" 26 | async with ClientSession() as session: 27 | api = await simplipy.API.async_from_auth( 28 | "", 29 | "", 30 | session=session, 31 | ) 32 | 33 | # Get a dict of systems with the system ID as the key: 34 | systems = await api.async_get_systems() 35 | # >>> {"1234abc": , ...} 36 | 37 | 38 | asyncio.run(main()) 39 | ``` 40 | 41 | ## Core Properties 42 | 43 | All {meth}`System ` objects come with a standard set of 44 | properties: 45 | 46 | ```python 47 | # Return the street address of the system: 48 | system.address 49 | # >>> 1234 Main Street 50 | 51 | # Return whether the alarm is currently going off: 52 | system.alarm_going_off 53 | # >>> False 54 | 55 | # Return the type of connection the system is using: 56 | system.connection_type 57 | # >>> "cell" 58 | 59 | # Return a list of active notifications: 60 | system.notifications 61 | # >>> [, ...] 62 | 63 | # Return a list of sensors attached to this system 64 | # (detailed later): 65 | system.sensors 66 | # >>> [, ...] 67 | 68 | # Return the system's serial number: 69 | system.serial 70 | # >>> xxxxxxxxxxxxxx 71 | 72 | # Return the current state of the system: 73 | system.state 74 | # >>> simplipy.system.SystemStates.AWAY 75 | 76 | # Return the SimpliSafe™ identifier for this system 77 | # from the key: 78 | system_id 79 | # >>> 1234abc 80 | 81 | # ...or as a property of the system itself: 82 | system.system_id 83 | # >>> 1234abc 84 | 85 | # Return the average of all temperature sensors 86 | # (if they exist): 87 | system.temperature 88 | # >>> 67 89 | 90 | # Return the SimpliSafe™ version: 91 | system.version 92 | # >>> 2 93 | ``` 94 | 95 | ## V3 Properties 96 | 97 | If a {meth}`System ` object should be a V3 system, it will 98 | automatically come with additional properties: 99 | 100 | ```python 101 | # Return the number of seconds an activated alarm 102 | # will sound for: 103 | system.alarm_duration 104 | # >>> 240 105 | 106 | # Return the loudness of the alarm volume: 107 | system.alarm_volume 108 | # >>> 3 109 | 110 | # Return the power rating of the battery backup: 111 | system.battery_backup_power_level 112 | # >>> 5239 113 | 114 | # Return the number of seconds to delay when returning 115 | # to an "away" alarm: 116 | system.entry_delay_away 117 | # >>> 30 118 | 119 | # Return the number of seconds to delay when returning 120 | # to an "home" alarm: 121 | system.entry_delay_home 122 | # >>> 30 123 | 124 | # Return the number of seconds to delay when exiting 125 | # an "away" alarm: 126 | system.exit_delay_away 127 | # >>> 60 128 | 129 | # Return the number of seconds to delay when exiting 130 | # an "home" alarm: 131 | system.exit_delay_home 132 | # >>> 0 133 | 134 | # Return the signal strength of the cell antenna: 135 | system.gsm_strength 136 | # >>> -73 137 | 138 | # Return whether the base station light is on: 139 | system.light 140 | # >>> True 141 | 142 | # Return any active system messages/notifications 143 | system.notifications 144 | # >>> [Message(...)] 145 | 146 | # Return whether the system is offline: 147 | system.offline 148 | # >>> False 149 | 150 | # Return whether the system is experiencing a power 151 | # outage: 152 | system.power_outage 153 | # >>> False 154 | 155 | # Return whether the base station is noticing RF jamming: 156 | system.rf_jamming 157 | # >>> False 158 | 159 | # Return the loudness of the voice prompt: 160 | system.voice_prompt_volume 161 | # >>> 2 162 | 163 | # Return the power rating of the A/C outlet: 164 | system.wall_power_level 165 | # >>> 5239 166 | 167 | # Return the ssid of the base station: 168 | system.wifi_ssid 169 | # >>> "My_SSID" 170 | 171 | # Return the signal strength of the wifi antenna: 172 | system.wifi_strength 173 | # >>> -43 174 | ``` 175 | 176 | V3 systems also come with a {meth}`async_set_properties ` 177 | method to update the following system properties: 178 | 179 | - `alarm_duration` (in seconds): 30-480 180 | - `alarm_volume`: Volume.OFF, Volume.LOW, Volume.MEDIUM, Volume.HIGH 181 | - `chime_volume`: Volume.OFF, Volume.LOW, Volume.MEDIUM, Volume.HIGH 182 | - `entry_delay_away` (in seconds): 30-255 183 | - `entry_delay_home` (in seconds): 0-255 184 | - `exit_delay_away` (in seconds): 45-255 185 | - `exit_delay_home` (in seconds): 0-255 186 | - `light`: True or False 187 | - `voice_prompt_volume`: Volume.OFF, Volume.LOW, Volume.MEDIUM, Volume.HIGH 188 | 189 | Note that the `simplipy.system.v3.Volume` enum class should be used for volume 190 | properties. 191 | 192 | ```python 193 | from simplipy.system.v3 import Volume 194 | 195 | await system.async_set_properties( 196 | { 197 | "alarm_duration": 240, 198 | "alarm_volume": Volume.HIGH, 199 | "chime_volume": Volume.MEDIUM, 200 | "entry_delay_away": 30, 201 | "entry_delay_home": 30, 202 | "exit_delay_away": 60, 203 | "exit_delay_home": 0, 204 | "light": True, 205 | "voice_prompt_volume": Volume.MEDIUM, 206 | } 207 | ) 208 | ``` 209 | 210 | Attempting to call these coroutines with a value beyond these limits will raise a 211 | {meth}`SimplipyError `. 212 | 213 | ## Updating the System 214 | 215 | Refreshing the {meth}`System ` object is done via the 216 | {meth}`update() ` coroutine: 217 | 218 | ```python 219 | await system.async_update() 220 | ``` 221 | 222 | Note that this method can be supplied with four optional parameters (all of which 223 | default to `True`): 224 | 225 | - `include_system`: update the system state and properties 226 | - `include_settings`: update system settings (like PINs) 227 | - `include_entities`: update all sensors/locks/etc. associated with a system 228 | - `cached`: use the last values provides by the base station 229 | 230 | For instance, if a user only wanted to update sensors and wanted to force a new data 231 | refresh: 232 | 233 | ```python 234 | await system.async_update(include_system=False, include_settings=False, cached=False) 235 | ``` 236 | 237 | There are two crucial differences between V2 and V3 systems when updating: 238 | 239 | - V2 systems, which use only 2G cell connectivity, will be slower to update 240 | than V3 systems when those V3 systems are connected to WiFi. 241 | - V2 systems will audibly announce, "Your settings have been synchronized." 242 | when the update completes; V3 systems will not. Unfortunately, this cannot 243 | currently be worked around. 244 | 245 | ## Arming/Disarming 246 | 247 | Arming the system in home/away mode and disarming the system are done via a set 248 | of three coroutines: 249 | 250 | ```python 251 | await system.async_set_away() 252 | await system.async_set_home() 253 | await system.async_set_off() 254 | ``` 255 | 256 | ## Events 257 | 258 | The {meth}`System ` object allows users to view events that have 259 | occurred with their system: 260 | 261 | ```python 262 | from datetime import datetime, timedelta 263 | 264 | yesterday = datetime.now() - timedelta(days=1) 265 | 266 | await system.async_get_events(from_timestamp=yesterday, num_events=2) 267 | # >>> [{"eventId": 123, ...}, {"eventId": 456, ...}] 268 | 269 | await system.async_get_latest_event() 270 | # >>> {"eventId": 987, ...} 271 | ``` 272 | 273 | ## System Notifications 274 | 275 | The `notifications` property of the {meth}`System ` object 276 | contains any active system notifications (in the form of 277 | {meth}`SystemNotification ` objects). 278 | 279 | Notifications remain within `system.notifications` until cleared, which can be 280 | accomplished by: 281 | 282 | 1. Manually clearing them in the SimpliSafe™ web and mobile applications 283 | 2. Using the {meth}`system.clear_notifications ` 284 | coroutine. 285 | 286 | ## PINs 287 | 288 | `simplipy` allows users to easily retrieve, set, reset, and remove PINs 289 | associated with a SimpliSafe™ account: 290 | 291 | ```python 292 | # Get all PINs (retrieving fresh or from the cache): 293 | await system.async_get_pins(cached=False) 294 | # >>> {"master": "1234", "duress": "9876"} 295 | 296 | # Set a new user PIN: 297 | await system.async_set_pin("My New User", "1122") 298 | await system.async_get_pins(cached=False) 299 | # >>> {"master": "1234", "duress": "9876", "My New User": "1122"} 300 | 301 | # Remove a PIN (by value or by label) 302 | await system.async_remove_pin("My New User") 303 | await system.async_get_pins(cached=False) 304 | # >>> {"master": "1234", "duress": "9876"} 305 | 306 | # Set the master PIN (works for the duress PIN, too): 307 | await system.async_set_pin("master", "9865") 308 | await system.async_get_pins(cached=False) 309 | # >>> {"master": "9865", "duress": "9876"} 310 | ``` 311 | 312 | Remember that with V2 systems, many operations – including setting PINs – will cause 313 | the base station to audibly announce "Your settings have been synchronized." 314 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Installation 4 | 5 | ```bash 6 | pip install simplisafe-python 7 | ``` 8 | 9 | ## Python Versions 10 | 11 | `simplisafe-python` is currently supported on: 12 | 13 | - Python 3.10 14 | - Python 3.11 15 | - Python 3.12 16 | 17 | ## SimpliSafe™ Plans 18 | 19 | SimpliSafe™ offers several [monitoring plans][simplisafe-plans]. To date, 20 | `simplisafe-python` is known to work with all plans; if you should find differently, 21 | please consider submitting an [issue][simplisafe-python-issues]. 22 | 23 | ## Accessing the API 24 | 25 | Starting in 2021, SimpliSafe™ began to implement an OAuth-based form of authentication. 26 | To use this library, you must handshake with the SimpliSafe™ API; although this process 27 | cannot be fully accomplished programmatically, the procedure is relatively 28 | straightforward. 29 | 30 | ### Authentication 31 | 32 | `simplipy` comes with a helper script to get you started. To use it, follow these 33 | steps from a command line: 34 | 35 | 1. Clone the `simplipy` Git repo and `cd` into it: 36 | 37 | ```sh 38 | $ git clone https://github.com/bachya/simplisafe-python.git 39 | $ cd simplisafe-python/ 40 | ``` 41 | 42 | 2. Set up and activate a Python virtual environment: 43 | 44 | ```sh 45 | $ python3 -m virtualenv .venv 46 | $ source .venv/bin/activate 47 | ``` 48 | 49 | 3. Initialize the dev environment for `simplipy`: 50 | 51 | ```sh 52 | $ script/setup 53 | ``` 54 | 55 | 4. Run the `auth` script: 56 | 57 | ```sh 58 | $ script/auth 59 | ``` 60 | 61 | 5. Hit the Enter key to open a web browser to the SimpliSafe login page: 62 | 63 | ![The SimpliSafe™ login screen](images/ss-login-screen.png) 64 | 65 | 6. Once you enter your username/password and click "Continue", you will receive a 66 | two-factor authentication request. Depending on your account settings, this will 67 | arrive as either (1) an SMS text message or (2) an email. Follow the provided 68 | instructions regardless of which form you receive. Once you complete the 69 | verification, return to the browser and open its Dev Tools window. Look for an error 70 | (in either the Console or Network tab) that contains a URL starting with 71 | `com.simplisafe.mobile`: 72 | 73 | ``` 74 | com.simplisafe.mobile://auth.simplisafe.com/ios/com.simplisafe.mobile/callback?code= 75 | ``` 76 | 77 | ![The code in the Console Tab](images/ss-auth-code-in-console.png) 78 | ![The code in the Network Tab](images/ss-auth-code-in-network.png) 79 | 80 | **NOTE:** This process is very inconsistent with non-Chromium browsers (Chrome, Edge, 81 | Brave, etc.); if you are unsuccessful at finding the code, try a Chromium-based browser. 82 | 83 | **NOTE:** if you have already logged into SimpliSafe via the browser, you may be sent 84 | straight to the end of the process. This can present a challenge, since opening Dev 85 | Tools in that window won't show the previously logged activity. In this case, open a new 86 | tab, open its Dev Tools window, then copy/paste the URL from the tab opened by 87 | `script/auth` into the new tab to see the Console/Network output. 88 | 89 | 7. Copy the `code` parameter at the end of the `com.simplisafe.mobile` URL, return to your 90 | terminal, and paste it into the prompt. You should now see this message: 91 | 92 | ```sh 93 | You are now ready to use the SimpliSafe API! 94 | Authorization Code: 95 | Code Verifier: 96 | ``` 97 | 98 | These values can now be used to instantiate an {meth}`API ` object. 99 | Remember that this Authorization Code and Code Verifier pair (a) can only be used once 100 | and (b) will expire after a relatively short amount of time. 101 | 102 | ### Creating an API Object 103 | 104 | Once you have an Authorization Code and Code Verifier, you can create an API object like 105 | this: 106 | 107 | ```python 108 | import asyncio 109 | 110 | from aiohttp import ClientSession 111 | from simplipy import API 112 | 113 | 114 | async def main() -> None: 115 | """Create the aiohttp session and run.""" 116 | async with ClientSession() as session: 117 | simplisafe = await API.async_from_auth( 118 | "", 119 | "", 120 | session=session, 121 | ) 122 | 123 | # ... 124 | 125 | 126 | asyncio.run(main()) 127 | ``` 128 | 129 | ### Key API Object Properties 130 | 131 | The {meth}`API ` object contains several sensitive properties to be 132 | aware of: 133 | 134 | ```python 135 | # Return the current access token: 136 | api.access_token 137 | # >>> 7s9yasdh9aeu21211add 138 | 139 | # Return the current refresh token: 140 | api.refresh_token 141 | # >>> 896sad86gudas87d6asd 142 | 143 | # Return the SimpliSafe™ user ID associated with this account: 144 | api.user_id 145 | # >>> 1234567 146 | ``` 147 | 148 | Remember three essential characteristics of refresh tokens: 149 | 150 | 1. Refresh tokens can only be used once. 151 | 2. SimpliSafe™ will invalidate active tokens if you change your password. 152 | 3. Given the unofficial nature of the SimpliSafe™ API, we do not know how long refresh 153 | tokens are valid – we assume they'll last indefinitely, but that information may 154 | change. 155 | 156 | ### Creating a New API Object with the Refresh Token 157 | 158 | It is cumbersome to call 159 | {meth}`API.async_from_auth ` every time you want a new 160 | {meth}`API ` object. Therefore, _after_ initial authentication, call 161 | {meth}`API.async_from_refresh_token `, 162 | passing the {meth}`refresh_token ` from the previous 163 | {meth}`API ` object. A common practice is to save a valid refresh 164 | token to a filesystem/database/etc. and retrieve it later. 165 | 166 | ```python 167 | import asyncio 168 | 169 | from aiohttp import ClientSession 170 | import simplipy 171 | 172 | 173 | async def async_get_refresh_token() -> str: 174 | """Get a refresh token from storage.""" 175 | # ... 176 | 177 | 178 | async def main() -> None: 179 | """Create the aiohttp session and run.""" 180 | async with ClientSession() as session: 181 | refresh_token = await async_get_refresh_token() 182 | api = await simplipy.API.async_from_refresh_token( 183 | refresh_token, session=session 184 | ) 185 | 186 | # ... 187 | 188 | 189 | asyncio.run(main()) 190 | ``` 191 | 192 | After a new {meth}`API ` object is created via 193 | {meth}`API.async_from_refresh_token `, it 194 | comes with its own, new refresh token; this can be used to follow the same 195 | re-authentication process as often as needed. 196 | 197 | ### Refreshing an Access Token During Runtime 198 | 199 | In general, you do not need to worry about refreshing the access token within an 200 | {meth}`API ` object's normal operations; if an 201 | {meth}`API ` object encounters an error that indicates an expired access token, it will automatically attempt to use the refresh token it has. 202 | 203 | However, should you need to refresh an access token manually at runtime, you can use the 204 | {meth}`async_refresh_access_token ` method. 205 | 206 | ### A VERY IMPORTANT NOTE ABOUT TOKENS 207 | 208 | **It is vitally important not to let these tokens leave your control.** If 209 | exposed, savvy attackers could use them to view and alter your system's state. **You 210 | have been warned; proper storage/usage of tokens is solely your responsibility.** 211 | 212 | [simplisafe-plans]: https://support.simplisafe.com/hc/en-us/articles/360023809972-What-are-the-service-plan-options- 213 | [simplisafe-python-issues]: https://github.com/bachya/simplisafe-python/issues 214 | -------------------------------------------------------------------------------- /docs/websocket.md: -------------------------------------------------------------------------------- 1 | # Websocket 2 | 3 | `simplipy` provides a websocket that allows for near-real-time detection of certain 4 | events from a user's SimpliSafe™ system. This websocket can be accessed via the 5 | `websocket` property of the {meth}`API ` object: 6 | 7 | ```python 8 | api.websocket 9 | # >>> 10 | ``` 11 | 12 | ## Connecting 13 | 14 | ```python 15 | await api.websocket.async_connect() 16 | ``` 17 | 18 | Then, once you are connected to the websocket, you can start listening for events: 19 | 20 | ```python 21 | await api.websocket.async_listen() 22 | ``` 23 | 24 | ## Disconnecting 25 | 26 | ```python 27 | await api.websocket.async_disconnect() 28 | ``` 29 | 30 | ## Responding to Events 31 | 32 | Users respond to events by defining callbacks (synchronous functions _or_ coroutines). 33 | The following events exist: 34 | 35 | - `connect`: occurs when the websocket connection is established 36 | - `disconnect`: occurs when the websocket connection is terminated 37 | - `event`: occurs when any data is transmitted from the SimpliSafe™ cloud 38 | 39 | Note that you can register as many callbacks as you'd like. 40 | 41 | ### `connect` 42 | 43 | ```python 44 | async def async_connect_handler(): 45 | await asyncio.sleep(1) 46 | print("I connected to the websocket") 47 | 48 | 49 | def connect_handler(): 50 | print("I connected to the websocket") 51 | 52 | 53 | remove_1 = api.websocket.add_connect_callback(async_connect_handler) 54 | remove_2 = api.websocket.add_connect_callback(connect_handler) 55 | 56 | # remove_1 and remove_2 are functions that, when called, remove the callback. 57 | ``` 58 | 59 | ### `disconnect` 60 | 61 | ```python 62 | async def async_connect_handler(): 63 | await asyncio.sleep(1) 64 | print("I disconnected from the websocket") 65 | 66 | 67 | def connect_handler(): 68 | print("I disconnected from the websocket") 69 | 70 | 71 | remove_1 = api.websocket.add_disconnect_callback(async_connect_handler) 72 | remove_2 = api.websocket.add_disconnect_callback(connect_handler) 73 | 74 | # remove_1 and remove_2 are functions that, when called, remove the callback. 75 | ``` 76 | 77 | ### `event` 78 | 79 | ```python 80 | async def async_connect_handler(event): 81 | await asyncio.sleep(1) 82 | print(f"I received a SimpliSafe™ event: {event}") 83 | 84 | 85 | def connect_handler(): 86 | print(f"I received a SimpliSafe™ event: {event}") 87 | 88 | 89 | remove_1 = api.websocket.add_event_callback(async_connect_handler) 90 | remove_2 = api.websocket.add_event_callback(connect_handler) 91 | 92 | # remove_1 and remove_2 are functions that, when called, remove the callback. 93 | ``` 94 | 95 | #### Response Format 96 | 97 | The `event` argument provided to event callbacks is a 98 | {meth}`simplipy.websocket.WebsocketEvent` object, which comes with several properties: 99 | 100 | - `changed_by`: the PIN that caused the event (in the case of arming/disarming/etc.) 101 | - `event_type`: the type of event (see below) 102 | - `info`: a longer string describing the event 103 | - `sensor_name`: the name of the entity that triggered the event 104 | - `sensor_serial`: the serial number of the entity that triggered the event 105 | - `sensor_type`: the type of the entity that triggered the event 106 | - `system_id`: the SimpliSafe™ system ID 107 | - `timestamp`: the UTC timestamp that the event occurred 108 | - `media_urls`: a dict containing media URLs if the `event_type` is "camera_motion_detected" (see below) 109 | 110 | The `event_type` property will be one of the following values: 111 | 112 | - `alarm_canceled` 113 | - `alarm_triggered` 114 | - `armed_away_by_keypad` 115 | - `armed_away_by_remote` 116 | - `armed_away` 117 | - `armed_home` 118 | - `automatic_test` 119 | - `away_exit_delay_by_keypad` 120 | - `away_exit_delay_by_remote` 121 | - `camera_motion_detected` 122 | - `connection_lost` 123 | - `connection_restored` 124 | - `disarmed_by_keypad` 125 | - `disarmed_by_remote` 126 | - `doorbell_detected` 127 | - `entity_test` 128 | - `entry_detected` 129 | - `home_exit_delay` 130 | - `lock_error` 131 | - `lock_locked` 132 | - `lock_unlocked` 133 | - `motion_detected` 134 | - `power_outage` 135 | - `power_restored` 136 | - `sensor_not_responding` 137 | - `sensor_paired_and_named` 138 | - `sensor_restored` 139 | - `user_initiated_test` 140 | 141 | If the `event_type` is `camera_motion_detected`, then the `event` attribute `media_urls` 142 | will be a dictionary that looks like this: 143 | 144 | ```python 145 | { 146 | "image_url": "https://xxx.us-east-1.prd.cam.simplisafe.com/xxx", 147 | "clip_url": "https://xxx.us-east-1.prd.cam.simplisafe.com/xxx", 148 | } 149 | ``` 150 | 151 | The `image_url` is an absolute URL to a JPEG file. The `clip_url` is an absolute URL to 152 | a short MPEG4 video clip. Both refer to the motion detected by the camera. You can 153 | retrieve the raw bytes of the media files at these URLs with the following method: 154 | 155 | ```python 156 | bytes = await api.async_media(url) 157 | ``` 158 | 159 | If the `event_type` is not `camera_motion_detected`, then `media_urls` will be set to None. 160 | 161 | If you should come across an event type that the library does not know about (and see 162 | a log message about it), please open an issue at 163 | . 164 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | """Define examples.""" 2 | -------------------------------------------------------------------------------- /examples/test_client_by_auth.py: -------------------------------------------------------------------------------- 1 | """Test system functionality with an Auth0 code/verifier.""" 2 | 3 | import asyncio 4 | import logging 5 | import os 6 | 7 | from aiohttp import ClientSession 8 | 9 | from simplipy import API 10 | from simplipy.errors import SimplipyError 11 | 12 | _LOGGER = logging.getLogger() 13 | 14 | SIMPLISAFE_AUTHORIZATION_CODE = os.getenv("SIMPLISAFE_AUTHORIZATION_CODE", "") 15 | SIMPLISAFE_CODE_VERIFIER = os.getenv("SIMPLISAFE_CODE_VERIFIER") 16 | 17 | 18 | async def main() -> None: 19 | """Create the aiohttp session and run the example.""" 20 | async with ClientSession() as session: 21 | logging.basicConfig(level=logging.INFO) 22 | 23 | if not SIMPLISAFE_AUTHORIZATION_CODE or not SIMPLISAFE_CODE_VERIFIER: 24 | _LOGGER.error("Missing authentication info") 25 | return 26 | 27 | try: 28 | simplisafe = await API.async_from_auth( 29 | SIMPLISAFE_AUTHORIZATION_CODE, 30 | SIMPLISAFE_CODE_VERIFIER, 31 | session=session, 32 | ) 33 | systems = await simplisafe.async_get_systems() 34 | for system in systems.values(): 35 | # Print system state: 36 | _LOGGER.info("System state: %s", system.state) 37 | 38 | # Print sensor info: 39 | for serial, sensor in system.sensors.items(): 40 | _LOGGER.info( 41 | "Sensor %s: (name: %s, type: %s, triggered: %s)", 42 | serial, 43 | sensor.name, 44 | sensor.type, 45 | sensor.triggered, 46 | ) 47 | 48 | # Arm/disarm the system: 49 | # await system.async_set_away() 50 | # await system.async_set_home() 51 | # await system.async_set_off() 52 | 53 | except SimplipyError as err: 54 | _LOGGER.error(err) 55 | 56 | 57 | asyncio.run(main()) 58 | -------------------------------------------------------------------------------- /examples/test_client_by_refresh_token.py: -------------------------------------------------------------------------------- 1 | """Test system functionality with an Auth0 code/verifier.""" 2 | 3 | import asyncio 4 | import logging 5 | import os 6 | 7 | from aiohttp import ClientSession 8 | 9 | from simplipy import API 10 | from simplipy.errors import SimplipyError 11 | 12 | _LOGGER = logging.getLogger() 13 | 14 | SIMPLISAFE_REFRESH_TOKEN = os.getenv("SIMPLISAFE_REFRESH_TOKEN", "") 15 | 16 | 17 | async def main() -> None: 18 | """Create the aiohttp session and run the example.""" 19 | async with ClientSession() as session: 20 | logging.basicConfig(level=logging.INFO) 21 | 22 | if not SIMPLISAFE_REFRESH_TOKEN: 23 | _LOGGER.error("Missing refresh token") 24 | return 25 | 26 | try: 27 | simplisafe = await API.async_from_refresh_token( 28 | SIMPLISAFE_REFRESH_TOKEN, session=session 29 | ) 30 | systems = await simplisafe.async_get_systems() 31 | for system in systems.values(): 32 | # Print system state: 33 | _LOGGER.info("System state: %s", system.state) 34 | 35 | # Print sensor info: 36 | for serial, sensor in system.sensors.items(): 37 | _LOGGER.info( 38 | "Sensor %s: (name: %s, type: %s, triggered: %s)", 39 | serial, 40 | sensor.name, 41 | sensor.type, 42 | sensor.triggered, 43 | ) 44 | 45 | # Arm/disarm the system: 46 | # await system.async_set_away() 47 | # await system.async_set_home() 48 | # await system.async_set_off() 49 | 50 | except SimplipyError as err: 51 | _LOGGER.error(err) 52 | 53 | 54 | asyncio.run(main()) 55 | -------------------------------------------------------------------------------- /examples/test_websocket.py: -------------------------------------------------------------------------------- 1 | """Test system functionality with an Auth0 code/verifier.""" 2 | 3 | import asyncio 4 | import logging 5 | import os 6 | 7 | from aiohttp import ClientSession 8 | 9 | from simplipy import API 10 | from simplipy.errors import CannotConnectError, SimplipyError 11 | 12 | _LOGGER = logging.getLogger() 13 | 14 | SIMPLISAFE_REFRESH_TOKEN = os.getenv("SIMPLISAFE_REFRESH_TOKEN", "") 15 | 16 | 17 | async def main() -> None: 18 | """Create the aiohttp session and run the example.""" 19 | async with ClientSession() as session: 20 | logging.basicConfig(level=logging.DEBUG) 21 | if not SIMPLISAFE_REFRESH_TOKEN: 22 | _LOGGER.error( 23 | "You must specify a SIMPLISAFE_REFRESH_TOKEN in the environment." 24 | ) 25 | return 26 | 27 | try: 28 | simplisafe = await API.async_from_refresh_token( 29 | SIMPLISAFE_REFRESH_TOKEN, session=session 30 | ) 31 | 32 | if simplisafe.websocket: 33 | try: 34 | await simplisafe.websocket.async_connect() 35 | except CannotConnectError as err: 36 | _LOGGER.error( 37 | "There was a error while connecting to the server: %s", err 38 | ) 39 | 40 | await simplisafe.websocket.async_listen() 41 | except SimplipyError as err: 42 | _LOGGER.error(err) 43 | except KeyboardInterrupt: 44 | pass 45 | 46 | 47 | asyncio.run(main()) 48 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core>=1.0.0"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.black] 6 | target-version = ["py39"] 7 | 8 | [tool.coverage.report] 9 | exclude_lines = ["raise NotImplementedError", "TYPE_CHECKING", "ImportError"] 10 | fail_under = 100 11 | show_missing = true 12 | 13 | [tool.coverage.run] 14 | omit = ["simplipy/util/auth.py"] 15 | source = ["simplipy"] 16 | 17 | [tool.isort] 18 | known_first_party = "simplipy,examples,tests" 19 | multi_line_output = 3 20 | profile = "black" 21 | 22 | [tool.mypy] 23 | check_untyped_defs = true 24 | disallow_incomplete_defs = true 25 | disallow_subclassing_any = true 26 | disallow_untyped_calls = true 27 | disallow_untyped_decorators = true 28 | disallow_untyped_defs = true 29 | follow_imports = "silent" 30 | ignore_missing_imports = true 31 | no_implicit_optional = true 32 | platform = "linux" 33 | python_version = "3.12" 34 | show_error_codes = true 35 | strict_equality = true 36 | warn_incomplete_stub = true 37 | warn_redundant_casts = true 38 | warn_return_any = true 39 | warn_unreachable = true 40 | warn_unused_configs = true 41 | warn_unused_ignores = true 42 | 43 | [tool.poetry] 44 | name = "simplisafe-python" 45 | version = "2024.01.0" 46 | description = "A Python3, async interface to the SimpliSafe API" 47 | readme = "README.md" 48 | authors = ["Aaron Bach "] 49 | license = "MIT" 50 | repository = "https://github.com/bachya/simplisafe-python" 51 | packages = [ 52 | { include = "simplipy" }, 53 | ] 54 | classifiers = [ 55 | "License :: OSI Approved :: MIT License", 56 | "Programming Language :: Python", 57 | "Programming Language :: Python :: 3", 58 | "Programming Language :: Python :: 3.10", 59 | "Programming Language :: Python :: 3.11", 60 | "Programming Language :: Python :: 3.12", 61 | "Programming Language :: Python :: Implementation :: CPython", 62 | "Programming Language :: Python :: Implementation :: PyPy", 63 | ] 64 | 65 | [tool.poetry.dependencies] 66 | aiohttp = ">=3.9.0b0" 67 | backoff = ">=1.11.1" 68 | certifi = ">=2023.07.22" 69 | python = "^3.10" 70 | voluptuous = ">=0.11.7" 71 | websockets = ">=8.1" 72 | yarl = ">=1.9.2" 73 | 74 | [tool.poetry.group.dev.dependencies] 75 | GitPython = ">=3.1.35" 76 | Pygments = ">=2.15.0" 77 | aresponses = ">=2.1.6,<4.0.0" 78 | asynctest = "^0.13.0" 79 | blacken-docs = "^1.12.1" 80 | codespell = "^2.2.2" 81 | coverage = {version = ">=6.5,<8.0", extras = ["toml"]} 82 | darglint = "^1.8.1" 83 | isort = "^5.10.1" 84 | mypy = "^1.2.0" 85 | pre-commit = ">=2.20,<5.0" 86 | pre-commit-hooks = ">=4.3,<6.0" 87 | pylint = ">=2.15.5,<4.0.0" 88 | pytest = ">=7.2,<9.0" 89 | pytest-aiohttp = "^1.0.0" 90 | pytest-asyncio = ">=0.23.6" 91 | pytest-cov = ">=4,<7" 92 | pyupgrade = "^3.1.0" 93 | pyyaml = "^6.0.1" 94 | requests = ">=2.31.0" 95 | ruff = ">=0.5.1,<0.7.4" 96 | sphinx-rtd-theme = ">=1,<4" 97 | typing-extensions = "^4.8.0" 98 | vulture = "^2.6" 99 | yamllint = "^1.28.0" 100 | 101 | [tool.poetry.group.docs.dependencies] 102 | Sphinx = ">=5.0.1,<9.0.0" 103 | myst-parser = ">=0.18,<4.1" 104 | 105 | [tool.poetry.urls] 106 | "Bug Tracker" = "https://github.com/bachya/simplisafe-python/issues" 107 | Changelog = "https://github.com/bachya/simplisafe-python/releases" 108 | 109 | [tool.pylint.BASIC] 110 | expected-line-ending-format = "LF" 111 | 112 | [tool.pylint.DESIGN] 113 | max-attributes = 20 114 | 115 | [tool.pylint.FORMAT] 116 | max-line-length = 88 117 | 118 | [tool.pylint.MASTER] 119 | ignore = [ 120 | "tests", 121 | ] 122 | load-plugins = [ 123 | "pylint.extensions.bad_builtin", 124 | "pylint.extensions.code_style", 125 | "pylint.extensions.docparams", 126 | "pylint.extensions.docstyle", 127 | "pylint.extensions.empty_comment", 128 | "pylint.extensions.overlapping_exceptions", 129 | "pylint.extensions.typing", 130 | ] 131 | 132 | [tool.pylint."MESSAGES CONTROL"] 133 | # Reasons disabled: 134 | # unnecessary-pass - This can hurt readability 135 | disable = [ 136 | "unnecessary-pass" 137 | ] 138 | 139 | [tool.pylint.REPORTS] 140 | score = false 141 | 142 | [tool.pylint.SIMILARITIES] 143 | # Minimum lines number of a similarity. 144 | # We set this higher because of some cases where V2 and V3 functionality are 145 | # similar, but abstracting them isn't feasible. 146 | min-similarity-lines = 15 147 | 148 | # Ignore comments when computing similarities. 149 | ignore-comments = true 150 | 151 | # Ignore docstrings when computing similarities. 152 | ignore-docstrings = true 153 | 154 | # Ignore imports when computing similarities. 155 | ignore-imports = true 156 | 157 | [tool.vulture] 158 | min_confidence = 80 159 | paths = ["simplipy", "tests"] 160 | verbose = false 161 | -------------------------------------------------------------------------------- /script/auth: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Initiate the SimpliSafe authorization process.""" 3 | 4 | import asyncio 5 | import sys 6 | import webbrowser 7 | 8 | from simplipy.util.auth import ( 9 | get_auth0_code_challenge, 10 | get_auth0_code_verifier, 11 | get_auth_url, 12 | ) 13 | 14 | 15 | async def main() -> None: 16 | """Run.""" 17 | code_verifier = get_auth0_code_verifier() 18 | code_challenge = get_auth0_code_challenge(code_verifier) 19 | auth_url = get_auth_url(code_challenge) 20 | 21 | try: 22 | input("Press to be taken to the SimpliSafe login page... ") 23 | except KeyboardInterrupt: 24 | sys.exit(1) 25 | 26 | webbrowser.open(auth_url) 27 | 28 | auth_code = input("Enter the code received from the SimpliSafe auth webpage: ") 29 | print() 30 | print("You are now ready to use the SimpliSafe API!") 31 | print(f"Authorization Code: {auth_code}") 32 | print(f"Code Verifier: {code_verifier}") 33 | 34 | 35 | asyncio.run(main()) 36 | -------------------------------------------------------------------------------- /script/docs: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | poetry run sphinx-build ./docs ./docs/_build 5 | -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | REPO_PATH="$( dirname "$( cd "$(dirname "$0")" ; pwd -P )" )" 5 | 6 | if [ "$(git rev-parse --abbrev-ref HEAD)" != "dev" ]; then 7 | echo "Refusing to publish a release from a branch other than dev" 8 | exit 1 9 | fi 10 | 11 | if [ -z "$(command -v poetry)" ]; then 12 | echo "Poetry needs to be installed to run this script: pip3 install poetry" 13 | exit 1 14 | fi 15 | 16 | function generate_version { 17 | latest_tag="$(git tag --sort=committerdate | tail -1)" 18 | month="$(date +'%Y.%m')" 19 | 20 | if [[ "$latest_tag" =~ "$month".* ]]; then 21 | patch="$(echo "$latest_tag" | cut -d . -f 3)" 22 | ((patch=patch+1)) 23 | echo "$month.$patch" 24 | else 25 | echo "$month.0" 26 | fi 27 | } 28 | 29 | # Temporarily uninstall pre-commit hooks so that we can push to dev and main: 30 | pre-commit uninstall 31 | 32 | # Pull the latest dev: 33 | git pull origin dev 34 | 35 | # Generate the next version (in the format YEAR.MONTH.RELEASE_NUMER): 36 | new_version=$(generate_version) 37 | 38 | # Update the PyPI package version: 39 | sed -i "" "s/^version = \".*\"/version = \"$new_version\"/g" "$REPO_PATH/pyproject.toml" 40 | git add pyproject.toml 41 | 42 | # Update the docs version: 43 | sed -i "" "s/^release = \".*\"/release = \"$new_version\"/g" "$REPO_PATH/docs/conf.py" 44 | git add docs/conf.py 45 | 46 | # Commit, tag, and push: 47 | git commit -m "Bump version to $new_version" 48 | git tag "$new_version" 49 | git push && git push --tags 50 | 51 | # Merge dev into main: 52 | git checkout main 53 | git merge dev 54 | git push 55 | git checkout dev 56 | 57 | # Re-initialize pre-commit: 58 | pre-commit install 59 | -------------------------------------------------------------------------------- /script/setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if command -v "mise"; then 5 | mise install 6 | fi 7 | 8 | # Install all dependencies: 9 | pip3 install poetry 10 | poetry install 11 | 12 | # Install pre-commit hooks: 13 | pre-commit install 14 | -------------------------------------------------------------------------------- /simplipy/__init__.py: -------------------------------------------------------------------------------- 1 | """Define the simplipy package.""" 2 | 3 | from simplipy.api import API # noqa 4 | -------------------------------------------------------------------------------- /simplipy/const.py: -------------------------------------------------------------------------------- 1 | """Define package constants.""" 2 | 3 | import logging 4 | 5 | LOGGER = logging.getLogger(__package__) 6 | 7 | DEFAULT_USER_AGENT = ( 8 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " 9 | "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15" 10 | ) 11 | -------------------------------------------------------------------------------- /simplipy/device/__init__.py: -------------------------------------------------------------------------------- 1 | """Define a base SimpliSafe device.""" 2 | 3 | from __future__ import annotations 4 | 5 | from enum import Enum 6 | from typing import TYPE_CHECKING, Any, cast 7 | 8 | from simplipy.const import LOGGER 9 | 10 | if TYPE_CHECKING: 11 | from simplipy.system import System 12 | 13 | 14 | class DeviceTypes(Enum): 15 | """Device types based on internal SimpliSafe ID number.""" 16 | 17 | REMOTE = 0 18 | KEYPAD = 1 19 | KEYCHAIN = 2 20 | PANIC_BUTTON = 3 21 | MOTION = 4 22 | ENTRY = 5 23 | GLASS_BREAK = 6 24 | CARBON_MONOXIDE = 7 25 | SMOKE = 8 26 | LEAK = 9 27 | TEMPERATURE = 10 28 | CAMERA = 12 29 | SIREN = 13 30 | SMOKE_AND_CARBON_MONOXIDE = 14 31 | DOORBELL = 15 32 | LOCK = 16 33 | OUTDOOR_CAMERA = 17 34 | MOTION_V2 = 20 35 | OUTDOOR_ALARM_SECURITY_BELL_BOX = 22 36 | LOCK_KEYPAD = 253 37 | UNKNOWN = 99 38 | 39 | 40 | def get_device_type_from_data(device_data: dict[str, Any]) -> DeviceTypes: 41 | """Get the device type of a raw data payload. 42 | 43 | Args: 44 | device_data: An API response payload. 45 | 46 | Returns: 47 | The device type. 48 | """ 49 | try: 50 | return DeviceTypes(device_data["type"]) 51 | except ValueError: 52 | LOGGER.error("Unknown device type: %s", device_data["type"]) 53 | return DeviceTypes.UNKNOWN 54 | 55 | 56 | class Device: 57 | """A base SimpliSafe device. 58 | 59 | Note that this class shouldn't be instantiated directly; it will be instantiated as 60 | appropriate via :meth:`simplipy.API.async_get_systems`. 61 | 62 | Args: 63 | system: A :meth:`simplipy.system.System` object (or one of its subclasses). 64 | device_type: The type of device represented. 65 | serial: The serial number of the device. 66 | """ 67 | 68 | def __init__(self, system: System, device_type: DeviceTypes, serial: str) -> None: 69 | """Initialize. 70 | 71 | Args: 72 | system: A :meth:`simplipy.system.System` object (or one of its subclasses). 73 | device_type: The type of device represented. 74 | serial: The serial number of the device. 75 | """ 76 | self._device_type = device_type 77 | self._serial = serial 78 | self._system = system 79 | 80 | @property 81 | def name(self) -> str: 82 | """Return the device name. 83 | 84 | Returns: 85 | The device name. 86 | """ 87 | return cast(str, self._system.sensor_data[self._serial]["name"]) 88 | 89 | @property 90 | def serial(self) -> str: 91 | """Return the device's serial number. 92 | 93 | Returns: 94 | The device serial number. 95 | """ 96 | return cast(str, self._system.sensor_data[self._serial]["serial"]) 97 | 98 | @property 99 | def type(self) -> DeviceTypes: 100 | """Return the device type. 101 | 102 | Returns: 103 | The device type. 104 | """ 105 | return self._device_type 106 | 107 | def as_dict(self) -> dict[str, Any]: 108 | """Return dictionary version of this device. 109 | 110 | Returns: 111 | Returns a dict representation of this device. 112 | """ 113 | return { 114 | "name": self.name, 115 | "serial": self.serial, 116 | "type": self.type.value, 117 | } 118 | 119 | async def async_update(self, cached: bool = True) -> None: 120 | """Retrieve the latest state/properties for the device. 121 | 122 | The ``cached`` parameter determines whether the SimpliSafe Cloud uses the last 123 | known values retrieved from the base station (``True``) or retrieves new data. 124 | 125 | Args: 126 | cached: Whether to used cached data. 127 | """ 128 | await self._system.async_update( 129 | include_subscription=False, include_settings=False, cached=cached 130 | ) 131 | 132 | 133 | class DeviceV3(Device): 134 | """A base device for V3 systems. 135 | 136 | Note that this class shouldn't be instantiated directly; it will be 137 | instantiated as appropriate via :meth:`simplipy.API.async_get_systems`. 138 | """ 139 | 140 | @property 141 | def error(self) -> bool: 142 | """Return the device's error status. 143 | 144 | Returns: 145 | The device's error status. 146 | """ 147 | return cast( 148 | bool, 149 | self._system.sensor_data[self._serial]["status"].get("malfunction", False), 150 | ) 151 | 152 | @property 153 | def low_battery(self) -> bool: 154 | """Return whether the device's battery is low. 155 | 156 | Returns: 157 | The device's low battery status. 158 | """ 159 | return cast(bool, self._system.sensor_data[self._serial]["flags"]["lowBattery"]) 160 | 161 | @property 162 | def offline(self) -> bool: 163 | """Return whether the device is offline. 164 | 165 | Returns: 166 | The device's offline status. 167 | """ 168 | return cast(bool, self._system.sensor_data[self._serial]["flags"]["offline"]) 169 | 170 | @property 171 | def settings(self) -> dict[str, Any]: 172 | """Return the device's settings. 173 | 174 | Note that these can change based on what device type the device is. 175 | 176 | Returns: 177 | A settings dictionary. 178 | """ 179 | return cast(dict[str, Any], self._system.sensor_data[self._serial]["setting"]) 180 | 181 | def as_dict(self) -> dict[str, Any]: 182 | """Return dictionary version of this device. 183 | 184 | Returns: 185 | A dict representation of this device. 186 | """ 187 | return { 188 | **super().as_dict(), 189 | "error": self.error, 190 | "low_battery": self.low_battery, 191 | "offline": self.offline, 192 | "settings": self.settings, 193 | } 194 | -------------------------------------------------------------------------------- /simplipy/device/camera.py: -------------------------------------------------------------------------------- 1 | """Define SimpliSafe cameras (SimpliCams).""" 2 | 3 | from __future__ import annotations 4 | 5 | from enum import Enum 6 | from typing import TYPE_CHECKING, Any, cast 7 | from urllib.parse import urlencode 8 | 9 | from simplipy.const import LOGGER 10 | from simplipy.device import DeviceV3 11 | 12 | if TYPE_CHECKING: 13 | from simplipy.system.v3 import SystemV3 14 | 15 | DEFAULT_AUDIO_ENCODING = "AAC" 16 | DEFAULT_MEDIA_URL_BASE = "https://media.simplisafe.com/v1" 17 | DEFAULT_VIDEO_WIDTH = 1280 18 | 19 | 20 | class CameraTypes(Enum): 21 | """Define camera types based on internal SimpliSafe ID number.""" 22 | 23 | CAMERA = 0 24 | DOORBELL = 1 25 | OUTDOOR_CAMERA = 2 26 | UNKNOWN = 99 27 | 28 | 29 | MODEL_TO_TYPE = { 30 | "SS001": CameraTypes.CAMERA, 31 | "SS002": CameraTypes.DOORBELL, 32 | "SS003": CameraTypes.CAMERA, 33 | "SSOBCM4": CameraTypes.OUTDOOR_CAMERA, 34 | } 35 | 36 | 37 | class Camera(DeviceV3): 38 | """Define a SimpliCam.""" 39 | 40 | _system: SystemV3 41 | 42 | @property 43 | def camera_settings(self) -> dict[str, Any]: 44 | """Return the camera settings. 45 | 46 | Returns: 47 | A dictionary of camera settings. 48 | """ 49 | return cast( 50 | dict[str, Any], self._system.camera_data[self._serial]["cameraSettings"] 51 | ) 52 | 53 | @property 54 | def camera_type(self) -> CameraTypes: 55 | """Return the type of camera. 56 | 57 | Returns: 58 | The camera type. 59 | """ 60 | try: 61 | return MODEL_TO_TYPE[self._system.camera_data[self._serial]["model"]] 62 | except KeyError: 63 | LOGGER.error( 64 | "Unknown camera type: %s", 65 | self._system.camera_data[self._serial]["model"], 66 | ) 67 | return CameraTypes.UNKNOWN 68 | 69 | @property 70 | def name(self) -> str: 71 | """Return the camera name. 72 | 73 | Returns: 74 | The camera name. 75 | """ 76 | return cast( 77 | str, self._system.camera_data[self._serial]["cameraSettings"]["cameraName"] 78 | ) 79 | 80 | @property 81 | def serial(self) -> str: 82 | """Return the camera's serial number. 83 | 84 | Returns: 85 | The camera serial number. 86 | """ 87 | return self._serial 88 | 89 | @property 90 | def shutter_open_when_away(self) -> bool: 91 | """Return whether the privacy shutter is open in away mode. 92 | 93 | Returns: 94 | The camera's "shutter open when away" status. 95 | """ 96 | val = self._system.camera_data[self._serial]["cameraSettings"]["shutterAway"] 97 | return cast(bool, val == "open") 98 | 99 | @property 100 | def shutter_open_when_home(self) -> bool: 101 | """Return whether the privacy shutter is open in home mode. 102 | 103 | Returns: 104 | The camera's "shutter open when home" status. 105 | """ 106 | val = self._system.camera_data[self._serial]["cameraSettings"]["shutterHome"] 107 | return cast(bool, val == "open") 108 | 109 | @property 110 | def shutter_open_when_off(self) -> bool: 111 | """Return whether the privacy shutter is open when the alarm is disarmed. 112 | 113 | Returns: 114 | The camera's "shutter open when off" status. 115 | """ 116 | val = self._system.camera_data[self._serial]["cameraSettings"]["shutterOff"] 117 | return cast(bool, val == "open") 118 | 119 | @property 120 | def status(self) -> str: 121 | """Return the camera status. 122 | 123 | Returns: 124 | The camera status. 125 | """ 126 | return cast(str, self._system.camera_data[self._serial]["status"]) 127 | 128 | @property 129 | def subscription_enabled(self) -> bool: 130 | """Return the camera subscription status. 131 | 132 | Returns: 133 | The camera subscription status. 134 | """ 135 | return cast( 136 | bool, self._system.camera_data[self._serial]["subscription"]["enabled"] 137 | ) 138 | 139 | def as_dict(self) -> dict[str, Any]: 140 | """Return dictionary version of this device. 141 | 142 | Returns: 143 | A dict representation of this device. 144 | """ 145 | return { 146 | "camera_settings": self.camera_settings, 147 | "camera_type": self.camera_type.value, 148 | "name": self.name, 149 | "serial": self.serial, 150 | "shutter_open_when_away": self.shutter_open_when_away, 151 | "shutter_open_when_home": self.shutter_open_when_home, 152 | "shutter_open_when_off": self.shutter_open_when_off, 153 | "status": self.status, 154 | "subscription_enabled": self.subscription_enabled, 155 | } 156 | 157 | def video_url( 158 | self, 159 | width: int = DEFAULT_VIDEO_WIDTH, 160 | audio_encoding: str = DEFAULT_AUDIO_ENCODING, 161 | **kwargs: Any, 162 | ) -> str: 163 | """Return the camera video URL. 164 | 165 | Args: 166 | width: The video width. 167 | audio_encoding: The audio encoding. 168 | kwargs: Additional parameters. 169 | 170 | Returns: 171 | The camera video URL. 172 | """ 173 | url_params = {"x": width, "audioEncoding": audio_encoding, **kwargs} 174 | return f"{DEFAULT_MEDIA_URL_BASE}/{self.serial}/flv?{urlencode(url_params)}" 175 | -------------------------------------------------------------------------------- /simplipy/device/lock.py: -------------------------------------------------------------------------------- 1 | """Define a SimpliSafe lock.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Awaitable, Callable 6 | from enum import Enum 7 | from typing import TYPE_CHECKING, Any, cast 8 | 9 | from simplipy.const import LOGGER 10 | from simplipy.device import DeviceTypes, DeviceV3 11 | 12 | if TYPE_CHECKING: 13 | from simplipy.system import System 14 | 15 | 16 | class LockStates(Enum): 17 | """States that a lock can be in.""" 18 | 19 | UNLOCKED = 0 20 | LOCKED = 1 21 | JAMMED = 2 22 | UNKNOWN = 99 23 | 24 | 25 | class Lock(DeviceV3): 26 | """A lock that works with V3 systems. 27 | 28 | Note that this class shouldn't be instantiated directly; it will be 29 | instantiated as appropriate via :meth:`simplipy.API.async_get_systems`. 30 | 31 | Args: 32 | request: The request method from the :meth:`simplipy.API` object. 33 | system: A :meth:`simplipy.system.System` object (or one of its subclasses). 34 | device_type: The type of device represented. 35 | serial: The serial number of the device. 36 | """ 37 | 38 | class _InternalStates(Enum): 39 | """Define an enum to map internal lock states to values we understand.""" 40 | 41 | LOCKED = 1 42 | UNLOCKED = 2 43 | 44 | def __init__( 45 | self, 46 | request: Callable[..., Awaitable[dict[str, Any]]], 47 | system: System, 48 | device_type: DeviceTypes, 49 | serial: str, 50 | ) -> None: 51 | """Initialize. 52 | 53 | Args: 54 | request: The request method from the :meth:`simplipy.API` object. 55 | system: A :meth:`simplipy.system.System` object (or one of its subclasses). 56 | device_type: The type of device represented. 57 | serial: The serial number of the device. 58 | """ 59 | super().__init__(system, device_type, serial) 60 | 61 | self._request = request 62 | 63 | @property 64 | def disabled(self) -> bool: 65 | """Return whether the lock is disabled. 66 | 67 | Returns: 68 | The lock's disable status. 69 | """ 70 | return cast( 71 | bool, self._system.sensor_data[self._serial]["status"]["lockDisabled"] 72 | ) 73 | 74 | @property 75 | def lock_low_battery(self) -> bool: 76 | """Return whether the lock's battery is low. 77 | 78 | Returns: 79 | The lock's low battery status. 80 | """ 81 | return cast( 82 | bool, self._system.sensor_data[self._serial]["status"]["lockLowBattery"] 83 | ) 84 | 85 | @property 86 | def pin_pad_low_battery(self) -> bool: 87 | """Return whether the pin pad's battery is low. 88 | 89 | Returns: 90 | The pinpad's low battery status. 91 | """ 92 | return cast( 93 | bool, self._system.sensor_data[self._serial]["status"]["pinPadLowBattery"] 94 | ) 95 | 96 | @property 97 | def pin_pad_offline(self) -> bool: 98 | """Return whether the pin pad is offline. 99 | 100 | Returns: 101 | The pinpad's offline status. 102 | """ 103 | return cast( 104 | bool, self._system.sensor_data[self._serial]["status"]["pinPadOffline"] 105 | ) 106 | 107 | @property 108 | def state(self) -> LockStates: 109 | """Return the current state of the lock. 110 | 111 | Returns: 112 | The lock's state. 113 | """ 114 | if bool(self._system.sensor_data[self._serial]["status"]["lockJamState"]): 115 | return LockStates.JAMMED 116 | 117 | raw_state = self._system.sensor_data[self._serial]["status"]["lockState"] 118 | 119 | try: 120 | internal_state = self._InternalStates(raw_state) 121 | except ValueError: 122 | LOGGER.error("Unknown raw lock state: %s", raw_state) 123 | return LockStates.UNKNOWN 124 | 125 | if internal_state == self._InternalStates.LOCKED: 126 | return LockStates.LOCKED 127 | return LockStates.UNLOCKED 128 | 129 | def as_dict(self) -> dict[str, Any]: 130 | """Return dictionary version of this device. 131 | 132 | Returns: 133 | A dict representation of this device. 134 | """ 135 | return { 136 | **super().as_dict(), 137 | "disabled": self.disabled, 138 | "lock_low_battery": self.lock_low_battery, 139 | "pin_pad_low_battery": self.pin_pad_low_battery, 140 | "pin_pad_offline": self.pin_pad_offline, 141 | "state": self.state.value, 142 | } 143 | 144 | async def async_lock(self) -> None: 145 | """Lock the lock.""" 146 | await self._request( 147 | "post", 148 | f"doorlock/{self._system.system_id}/{self.serial}/state", 149 | json={"state": "lock"}, 150 | ) 151 | 152 | # Update the internal state representation: 153 | self._system.sensor_data[self._serial]["status"]["lockState"] = ( 154 | self._InternalStates.LOCKED.value 155 | ) 156 | 157 | async def async_unlock(self) -> None: 158 | """Unlock the lock.""" 159 | await self._request( 160 | "post", 161 | f"doorlock/{self._system.system_id}/{self.serial}/state", 162 | json={"state": "unlock"}, 163 | ) 164 | 165 | # Update the internal state representation: 166 | self._system.sensor_data[self._serial]["status"]["lockState"] = ( 167 | self._InternalStates.UNLOCKED.value 168 | ) 169 | -------------------------------------------------------------------------------- /simplipy/device/sensor/__init__.py: -------------------------------------------------------------------------------- 1 | """Define sensors.""" 2 | -------------------------------------------------------------------------------- /simplipy/device/sensor/v2.py: -------------------------------------------------------------------------------- 1 | """Define a v2 (old) SimpliSafe sensor.""" 2 | 3 | from typing import cast 4 | 5 | from simplipy.device import Device, DeviceTypes 6 | from simplipy.errors import SimplipyError 7 | 8 | 9 | class SensorV2(Device): 10 | """A V2 (old) sensor. 11 | 12 | Note that this class shouldn't be instantiated directly; it will be 13 | instantiated as appropriate via :meth:`simplipy.API.async_get_systems`. 14 | """ 15 | 16 | @property 17 | def data(self) -> int: 18 | """Return the sensor's current data flag (currently not understood). 19 | 20 | Returns: 21 | The current data flag. 22 | """ 23 | return cast(int, self._system.sensor_data[self._serial]["sensorData"]) 24 | 25 | @property 26 | def error(self) -> bool: 27 | """Return the sensor's error status. 28 | 29 | Returns: 30 | The current error status. 31 | """ 32 | return cast(bool, self._system.sensor_data[self._serial]["error"]) 33 | 34 | @property 35 | def low_battery(self) -> bool: 36 | """Return whether the sensor's battery is low. 37 | 38 | Returns: 39 | The current low battery status. 40 | """ 41 | return cast( 42 | bool, self._system.sensor_data[self._serial].get("battery", "ok") != "ok" 43 | ) 44 | 45 | @property 46 | def settings(self) -> bool: 47 | """Return the sensor's settings. 48 | 49 | Returns: 50 | The current settings. 51 | """ 52 | return cast(bool, self._system.sensor_data[self._serial]["setting"]) 53 | 54 | @property 55 | def trigger_instantly(self) -> bool: 56 | """Return whether the sensor will trigger instantly. 57 | 58 | Returns: 59 | The "instant trigger" settings. 60 | """ 61 | return cast(bool, self._system.sensor_data[self._serial]["instant"]) 62 | 63 | @property 64 | def triggered(self) -> bool: 65 | """Return whether the sensor has been triggered. 66 | 67 | Returns: 68 | The triggered status. 69 | 70 | Raises: 71 | SimplipyError: Raised when the state can't be determined. 72 | """ 73 | if self.type == DeviceTypes.ENTRY: 74 | return cast( 75 | bool, 76 | self._system.sensor_data[self._serial].get("entryStatus", "closed") 77 | == "open", 78 | ) 79 | 80 | raise SimplipyError(f"Cannot determine triggered state for sensor: {self.name}") 81 | -------------------------------------------------------------------------------- /simplipy/device/sensor/v3.py: -------------------------------------------------------------------------------- 1 | """Define a v3 (new) SimpliSafe sensor.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any, cast 6 | 7 | from simplipy.device import DeviceTypes, DeviceV3 8 | 9 | 10 | class SensorV3(DeviceV3): 11 | """A V3 (new) sensor. 12 | 13 | Note that this class shouldn't be instantiated directly; it will be 14 | instantiated as appropriate via :meth:`simplipy.API.async_get_systems`. 15 | """ 16 | 17 | @property 18 | def trigger_instantly(self) -> bool: 19 | """Return whether the sensor will trigger instantly. 20 | 21 | Returns: 22 | The "instant trigger" status. 23 | """ 24 | return ( 25 | self._system.sensor_data[self._serial]["setting"].get( 26 | "instantTrigger", False 27 | ) 28 | is True 29 | ) 30 | 31 | @property 32 | def triggered(self) -> bool: 33 | """Return whether the sensor has been triggered. 34 | 35 | Returns: 36 | The triggered status. 37 | """ 38 | if self.type in ( 39 | DeviceTypes.CARBON_MONOXIDE, 40 | DeviceTypes.ENTRY, 41 | DeviceTypes.GLASS_BREAK, 42 | DeviceTypes.LEAK, 43 | DeviceTypes.MOTION, 44 | DeviceTypes.MOTION_V2, 45 | DeviceTypes.SMOKE, 46 | DeviceTypes.TEMPERATURE, 47 | ): 48 | return ( 49 | self._system.sensor_data[self._serial]["status"].get("triggered") 50 | is True 51 | ) 52 | 53 | if self.type == DeviceTypes.SMOKE_AND_CARBON_MONOXIDE: 54 | return ( 55 | self._system.sensor_data[self._serial]["status"].get( 56 | "coTriggered", False 57 | ) 58 | is True 59 | or self._system.sensor_data[self._serial]["status"].get( 60 | "smokeTriggered", False 61 | ) 62 | is True 63 | ) 64 | 65 | return False 66 | 67 | @property 68 | def temperature(self) -> int: 69 | """Return the temperature of the sensor (as appropriate). 70 | 71 | If the sensor isn't a temperature sensor, an ``AttributeError`` will be raised. 72 | 73 | Returns: 74 | The temperature. 75 | 76 | Raises: 77 | AttributeError: Raised when property is read on a non-temperature device. 78 | """ 79 | if self.type != DeviceTypes.TEMPERATURE: 80 | raise AttributeError("Non-temperature sensor cannot have a temperature") 81 | 82 | return cast( 83 | int, self._system.sensor_data[self._serial]["status"]["temperature"] 84 | ) 85 | 86 | def as_dict(self) -> dict[str, Any]: 87 | """Return dictionary version of this device. 88 | 89 | Returns: 90 | A dict representation of this device. 91 | """ 92 | data: dict[str, Any] = { 93 | **super().as_dict(), 94 | "trigger_instantly": self.trigger_instantly, 95 | "triggered": self.triggered, 96 | } 97 | 98 | if self.type == DeviceTypes.TEMPERATURE: 99 | data["temperature"] = self.temperature 100 | 101 | return data 102 | -------------------------------------------------------------------------------- /simplipy/errors.py: -------------------------------------------------------------------------------- 1 | """Define package errors.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | 8 | class SimplipyError(Exception): 9 | """A base error.""" 10 | 11 | pass 12 | 13 | 14 | class EndpointUnavailableError(SimplipyError): 15 | """An error related to accessing an endpoint that isn't available in the plan.""" 16 | 17 | pass 18 | 19 | 20 | class InvalidCredentialsError(SimplipyError): 21 | """An error related to invalid credentials.""" 22 | 23 | pass 24 | 25 | 26 | class MaxUserPinsExceededError(SimplipyError): 27 | """An error related to exceeding the maximum number of user PINs.""" 28 | 29 | pass 30 | 31 | 32 | class PinError(SimplipyError): 33 | """An error related to invalid PINs or PIN operations.""" 34 | 35 | pass 36 | 37 | 38 | class RequestError(SimplipyError): 39 | """An error related to invalid requests.""" 40 | 41 | pass 42 | 43 | 44 | class WebsocketError(SimplipyError): 45 | """An error related to generic websocket errors.""" 46 | 47 | pass 48 | 49 | 50 | class CannotConnectError(WebsocketError): 51 | """Define a error when the websocket can't be connected to.""" 52 | 53 | pass 54 | 55 | 56 | class ConnectionClosedError(WebsocketError): 57 | """Define a error when the websocket closes unexpectedly.""" 58 | 59 | pass 60 | 61 | 62 | class ConnectionFailedError(WebsocketError): 63 | """Define a error when the websocket connection fails.""" 64 | 65 | pass 66 | 67 | 68 | class InvalidMessageError(WebsocketError): 69 | """Define a error related to an invalid message from the websocket server.""" 70 | 71 | pass 72 | 73 | 74 | class NotConnectedError(WebsocketError): 75 | """Define a error when the websocket isn't properly connected to.""" 76 | 77 | pass 78 | 79 | 80 | DATA_ERROR_MAP: dict[str, type[SimplipyError]] = { 81 | "NoRemoteManagement": EndpointUnavailableError, 82 | "PinError": PinError, 83 | } 84 | 85 | 86 | def raise_on_data_error(data: dict[str, Any] | None) -> None: 87 | """Raise a specific error if the data payload suggests there is one. 88 | 89 | Args: 90 | data: An optional API response payload. 91 | 92 | Raises: 93 | error: A SimplipyError subclass. 94 | """ 95 | if not data: 96 | return 97 | 98 | if (error_type := data.get("type")) not in DATA_ERROR_MAP: 99 | return 100 | 101 | error = DATA_ERROR_MAP[error_type](data["message"]) 102 | raise error 103 | -------------------------------------------------------------------------------- /simplipy/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bachya/simplisafe-python/ff16fe52aeb4ba2485d9a867d90b18879b37c68b/simplipy/py.typed -------------------------------------------------------------------------------- /simplipy/system/v2.py: -------------------------------------------------------------------------------- 1 | """Define a V2 (original) SimpliSafe system.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | from simplipy.const import LOGGER 8 | from simplipy.device import get_device_type_from_data 9 | from simplipy.device.sensor.v2 import SensorV2 10 | from simplipy.system import ( 11 | CONF_DURESS_PIN, 12 | CONF_MASTER_PIN, 13 | DEFAULT_MAX_USER_PINS, 14 | System, 15 | SystemStates, 16 | ) 17 | 18 | 19 | def create_pin_payload(pins: dict[str, Any]) -> dict[str, dict[str, dict[str, str]]]: 20 | """Create the request payload to send for updating PINs. 21 | 22 | Args: 23 | pins: A dictionary of pins. 24 | 25 | Returns: 26 | A SimpliSafe V2 PIN payload. 27 | """ 28 | duress_pin = pins.pop(CONF_DURESS_PIN) 29 | master_pin = pins.pop(CONF_MASTER_PIN) 30 | 31 | payload = { 32 | "pins": {CONF_DURESS_PIN: {"value": duress_pin}, "pin1": {"value": master_pin}} 33 | } 34 | 35 | empty_user_index = len(pins) 36 | for idx, (label, pin) in enumerate(pins.items()): 37 | payload["pins"][f"pin{idx + 2}"] = {"name": label, "value": pin} 38 | 39 | for idx in range(DEFAULT_MAX_USER_PINS - empty_user_index): 40 | payload["pins"][f"pin{str(idx + 2 + empty_user_index)}"] = { 41 | "name": "", 42 | "pin": "", 43 | } 44 | 45 | LOGGER.debug("PIN payload: %s", payload) 46 | 47 | return payload 48 | 49 | 50 | class SystemV2(System): 51 | """Define a V2 (original) system.""" 52 | 53 | async def _async_clear_notifications(self) -> None: 54 | """Clear active notifications.""" 55 | await self._api.async_request( 56 | "delete", f"subscriptions/{self.system_id}/messages" 57 | ) 58 | 59 | async def _async_set_state(self, value: SystemStates) -> None: 60 | """Set the state of the system. 61 | 62 | Args: 63 | value: A :meth:`simplipy.system.SystemStates` object. 64 | """ 65 | await self._api.async_request( 66 | "post", 67 | f"subscriptions/{self.system_id}/state", 68 | params={"state": value.name.lower()}, 69 | ) 70 | 71 | self._state = value 72 | 73 | async def _async_set_updated_pins(self, pins: dict[str, Any]) -> None: 74 | """Post new PINs. 75 | 76 | Args: 77 | pins: A dictionary of PINs. 78 | """ 79 | await self._api.async_request( 80 | "post", 81 | f"subscriptions/{self.system_id}/pins", 82 | json=create_pin_payload(pins), 83 | ) 84 | 85 | async def _async_update_device_data(self, cached: bool = True) -> None: 86 | """Update all device data. 87 | 88 | Args: 89 | cached: Whether to update with cached data. 90 | """ 91 | sensor_resp = await self._api.async_request( 92 | "get", 93 | f"subscriptions/{self.system_id}/settings", 94 | params={"settingsType": "all", "cached": str(cached).lower()}, 95 | ) 96 | 97 | for sensor in sensor_resp.get("settings", {}).get("sensors", []): 98 | if not sensor: 99 | continue 100 | self.sensor_data[sensor["serial"]] = sensor 101 | 102 | async def _async_update_settings_data(self, cached: bool = True) -> None: 103 | """Update all settings data. 104 | 105 | Args: 106 | cached: Whether to update with cached data. 107 | """ 108 | pass 109 | 110 | def generate_device_objects(self) -> None: 111 | """Generate device objects for this system.""" 112 | for serial, data in self.sensor_data.items(): 113 | sensor_type = get_device_type_from_data(data) 114 | self.sensors[serial] = SensorV2(self, sensor_type, serial) 115 | 116 | async def async_get_pins(self, cached: bool = True) -> dict[str, str]: 117 | """Return all of the set PINs, including master and duress. 118 | 119 | The ``cached`` parameter determines whether the SimpliSafe Cloud uses the last 120 | known values retrieved from the base station (``True``) or retrieves new data. 121 | 122 | Args: 123 | cached: Whether to update with cached data. 124 | 125 | Returns: 126 | A dictionary of PINs. 127 | """ 128 | pins_resp = await self._api.async_request( 129 | "get", 130 | f"subscriptions/{self.system_id}/pins", 131 | params={"settingsType": "all", "cached": str(cached).lower()}, 132 | ) 133 | 134 | pins = { 135 | CONF_MASTER_PIN: pins_resp["pins"].pop("pin1")["value"], 136 | CONF_DURESS_PIN: pins_resp["pins"].pop("duress")["value"], 137 | } 138 | 139 | for user_pin in [p for p in pins_resp["pins"].values() if p["value"]]: 140 | pins[user_pin["name"]] = user_pin["value"] 141 | 142 | return pins 143 | -------------------------------------------------------------------------------- /simplipy/util/__init__.py: -------------------------------------------------------------------------------- 1 | """Define utility modules.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from collections.abc import Awaitable, Callable 7 | from typing import Any, Optional 8 | 9 | # pylint: disable=consider-alternative-union-syntax 10 | CallbackType = Callable[..., Optional[Awaitable[None]]] 11 | 12 | 13 | def execute_callback(callback: CallbackType, *args: Any) -> None: 14 | """Schedule a callback to be called. 15 | 16 | The callback is expected to be short-lived, as no sort of task management takes 17 | place – this is a fire-and-forget system. 18 | 19 | Args: 20 | callback: The callback to execute. 21 | *args: Any arguments to pass to the callback. 22 | """ 23 | if asyncio.iscoroutinefunction(callback): 24 | asyncio.create_task(callback(*args)) 25 | else: 26 | callback(*args) 27 | -------------------------------------------------------------------------------- /simplipy/util/auth.py: -------------------------------------------------------------------------------- 1 | """Define some utilities to work with SimpliSafe's authentication mechanism.""" 2 | 3 | from __future__ import annotations 4 | 5 | import base64 6 | import hashlib 7 | import os 8 | import re 9 | import urllib.parse 10 | from uuid import uuid4 11 | 12 | AUTH_URL_HOSTNAME = "auth.simplisafe.com" 13 | AUTH_URL_BASE = f"https://{AUTH_URL_HOSTNAME}" 14 | AUTH_URL_LOGIN = f"{AUTH_URL_BASE}/authorize" 15 | 16 | DEFAULT_AUTH0_CLIENT = ( 17 | "eyJ2ZXJzaW9uIjoiMi4zLjIiLCJuYW1lIjoiQXV0aDAuc3dpZnQiLCJlbnYiOnsic3dpZnQiOiI1LngiLC" 18 | "JpT1MiOiIxNi4zIn19" 19 | ) 20 | DEFAULT_CLIENT_ID = "42aBZ5lYrVW12jfOuu3CQROitwxg9sN5" 21 | DEFAULT_REDIRECT_URI = ( 22 | "com.simplisafe.mobile://auth.simplisafe.com/ios/com.simplisafe.mobile/callback" 23 | ) 24 | DEFAULT_SCOPE = ( 25 | "offline_access email openid https://api.simplisafe.com/scopes/user:platform" 26 | ) 27 | 28 | 29 | def get_auth_url(code_challenge: str, *, device_id: str | None = None) -> str: 30 | """Get a SimpliSafe authorization URL to visit in a browser. 31 | 32 | Args: 33 | code_challenge: A code challenge generated by 34 | :meth:`simplipy.util.auth.get_auth0_code_challenge`. 35 | device_id: A UUID to identify the device getting the auth URL. If not 36 | provided, a random UUID will be generated. 37 | 38 | Returns: 39 | An authorization URL. 40 | """ 41 | params = { 42 | "audience": "https://api.simplisafe.com/", 43 | "auth0Client": DEFAULT_AUTH0_CLIENT, 44 | "client_id": DEFAULT_CLIENT_ID, 45 | "code_challenge": code_challenge, 46 | "code_challenge_method": "S256", 47 | "device": "iPhone", 48 | "device_id": (device_id or str(uuid4())).upper(), 49 | "redirect_uri": DEFAULT_REDIRECT_URI, 50 | "response_type": "code", 51 | "scope": DEFAULT_SCOPE, 52 | } 53 | 54 | return f"{AUTH_URL_LOGIN}?{urllib.parse.urlencode(params)}" 55 | 56 | 57 | def get_auth0_code_challenge(code_verifier: str) -> str: 58 | """Get an Auth0 code challenge from a code verifier. 59 | 60 | Args: 61 | code_verifier: A code challenge generated by 62 | :meth:`simplipy.util.auth.get_auth0_code_verifier`. 63 | 64 | Returns: 65 | A code challenge. 66 | """ 67 | verifier = hashlib.sha256(code_verifier.encode("utf-8")).digest() 68 | challenge = base64.urlsafe_b64encode(verifier).decode("utf-8") 69 | return challenge.replace("=", "") 70 | 71 | 72 | def get_auth0_code_verifier() -> str: 73 | """Get an Auth0 code verifier. 74 | 75 | Returns: 76 | A code verifier. 77 | """ 78 | verifier = base64.urlsafe_b64encode(os.urandom(40)).decode("utf-8") 79 | return re.sub("[^a-zA-Z0-9]+", "", verifier) 80 | -------------------------------------------------------------------------------- /simplipy/util/dt.py: -------------------------------------------------------------------------------- 1 | """Define datetime utilities.""" 2 | 3 | from datetime import datetime 4 | 5 | try: 6 | from datetime import UTC 7 | except ImportError: 8 | # In place for support of Python 3.10 9 | from datetime import timezone 10 | 11 | UTC = timezone.utc 12 | 13 | 14 | def utcnow() -> datetime: 15 | """Return the current UTC time. 16 | 17 | Returns: 18 | A ``datetime.datetime`` object. 19 | """ 20 | return datetime.now(tz=UTC) 21 | 22 | 23 | def utc_from_timestamp(timestamp: float) -> datetime: 24 | """Return a UTC time from a timestamp. 25 | 26 | Args: 27 | timestamp: The epoch to convert. 28 | 29 | Returns: 30 | A parsed ``datetime.datetime`` object. 31 | """ 32 | return datetime.fromtimestamp(timestamp, tz=UTC) 33 | -------------------------------------------------------------------------------- /simplipy/util/string.py: -------------------------------------------------------------------------------- 1 | """Define various string utilities.""" 2 | 3 | import re 4 | 5 | 6 | def convert_to_underscore(string: str) -> str: 7 | """Convert thisString to this_string. 8 | 9 | Args: 10 | string: The string to convert. 11 | 12 | Returns: 13 | A converted string. 14 | """ 15 | first_pass = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", string) 16 | return re.sub("([a-z0-9])([A-Z])", r"\1_\2", first_pass).lower() 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Define package tests.""" 2 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | """Define common test utilities.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import os 7 | from typing import Any 8 | from unittest.mock import Mock 9 | 10 | import aiohttp 11 | 12 | TEST_ACCESS_TOKEN = "abcde12345" # noqa: S105 13 | TEST_ADDRESS = "1234 Main Street" 14 | TEST_AUTHORIZATION_CODE = "123abc" 15 | TEST_CAMERA_ID = "1234567890" 16 | TEST_CAMERA_ID_2 = "1234567891" 17 | TEST_CODE_VERIFIER = "123abc" 18 | TEST_LOCK_ID = "987" 19 | TEST_LOCK_ID_2 = "654" 20 | TEST_LOCK_ID_3 = "321" 21 | TEST_REFRESH_TOKEN = "qrstu98765" # noqa: S105 22 | TEST_SUBSCRIPTION_ID = 12345 23 | TEST_SYSTEM_ID = 12345 24 | TEST_SYSTEM_SERIAL_NO = "1234ABCD" 25 | TEST_USER_ID = 12345 26 | 27 | 28 | def create_ws_message(result: dict[str, Any]) -> Mock: 29 | """Return a mock WSMessage. 30 | 31 | Args: 32 | A JSON payload. 33 | 34 | Returns: 35 | A mocked websocket message. 36 | """ 37 | message = Mock(spec_set=aiohttp.http_websocket.WSMessage) 38 | message.type = aiohttp.http_websocket.WSMsgType.TEXT 39 | message.data = json.dumps(result) 40 | message.json.return_value = result 41 | return message 42 | 43 | 44 | def load_fixture(filename: str) -> str: 45 | """Load a fixture. 46 | 47 | Args: 48 | filename: The filename of the fixtures/ file to load. 49 | 50 | Returns: 51 | A string containing the contents of the file. 52 | """ 53 | path = os.path.join(os.path.dirname(__file__), "fixtures", filename) 54 | with open(path, encoding="utf-8") as fptr: 55 | return fptr.read() 56 | -------------------------------------------------------------------------------- /tests/fixtures/api_token_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "abcde12345", 3 | "refresh_token": "qrstu98765", 4 | "id_token": "vwxyz00000", 5 | "scope": "openid email https://api.simplisafe.com/scopes/user:platform offline_access", 6 | "expires_in": 3600, 7 | "token_type": "Bearer" 8 | } 9 | -------------------------------------------------------------------------------- /tests/fixtures/auth_check_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "userId": 12345, 3 | "isAdmin": false 4 | } 5 | -------------------------------------------------------------------------------- /tests/fixtures/events_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "numEvents": 2, 3 | "lastEventTimestamp": 1534035861, 4 | "events": [ 5 | { 6 | "eventId": 2921814837, 7 | "eventTimestamp": 1534720376, 8 | "eventCid": 3401, 9 | "zoneCid": "0", 10 | "sensorType": 1, 11 | "sensorSerial": "123", 12 | "account": 12345, 13 | "userId": 12345, 14 | "sid": 12345, 15 | "info": "System Armed (Away) by Keypad Garage Keypad", 16 | "pinName": "", 17 | "sensorName": "Garage Keypad", 18 | "messageSubject": "SimpliSafe System Armed (away mode)", 19 | "messageBody": "System Armed (away mode)", 20 | "eventType": "activity", 21 | "timezone": 2, 22 | "locationOffset": -360, 23 | "videoStartedBy": "", 24 | "video": {} 25 | }, 26 | { 27 | "eventId": 2920433155, 28 | "eventTimestamp": 1534702778, 29 | "eventCid": 1400, 30 | "zoneCid": "1", 31 | "sensorType": 1, 32 | "sensorSerial": "456", 33 | "account": 12345, 34 | "userId": 12345, 35 | "sid": 12345, 36 | "info": "System Disarmed by Master PIN", 37 | "pinName": "Master PIN", 38 | "sensorName": "Garage Keypad", 39 | "messageSubject": "SimpliSafe System Disarmed", 40 | "messageBody": "System Disarmed", 41 | "eventType": "activity", 42 | "timezone": 2, 43 | "locationOffset": -360, 44 | "videoStartedBy": "", 45 | "video": {} 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /tests/fixtures/invalid_authorization_code_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": "invalid_grant", 3 | "error_description": "Invalid authorization code" 4 | } 5 | -------------------------------------------------------------------------------- /tests/fixtures/invalid_refresh_token_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": "invalid_grant", 3 | "error_description": "Unknown or invalid refresh token." 4 | } 5 | -------------------------------------------------------------------------------- /tests/fixtures/latest_event_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "numEvents": 50, 3 | "lastEventTimestamp": 1564018073, 4 | "events": [ 5 | { 6 | "eventId": 1234567890, 7 | "eventTimestamp": 1564018073, 8 | "eventCid": 1400, 9 | "zoneCid": "2", 10 | "sensorType": 1, 11 | "sensorSerial": "01010101", 12 | "account": "00011122", 13 | "userId": 12345, 14 | "sid": 12345, 15 | "info": "System Disarmed by PIN 2", 16 | "pinName": "", 17 | "sensorName": "Kitchen", 18 | "messageSubject": "SimpliSafe System Disarmed", 19 | "messageBody": "System Disarmed: Your SimpliSafe security system was ...", 20 | "eventType": "activity", 21 | "timezone": 2, 22 | "locationOffset": -360, 23 | "videoStartedBy": "", 24 | "video": {} 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /tests/fixtures/unavailable_endpoint_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "NoRemoteManagement", 3 | "message": "Subscription does not support remote management", 4 | "code": "078", 5 | "statusCode": 403, 6 | "props": {} 7 | } 8 | -------------------------------------------------------------------------------- /tests/fixtures/v2_deleted_pins_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "pins": { 3 | "pin1": { 4 | "value": "1234" 5 | }, 6 | "pin2": { 7 | "value": "3456", 8 | "name": "Mother" 9 | }, 10 | "pin3": { 11 | "value": "", 12 | "name": "" 13 | }, 14 | "pin4": { 15 | "value": "", 16 | "name": "" 17 | }, 18 | "pin5": { 19 | "value": "", 20 | "name": "" 21 | }, 22 | "duress": { 23 | "value": "9876" 24 | } 25 | }, 26 | "lastUpdated": 1563208180 27 | } 28 | -------------------------------------------------------------------------------- /tests/fixtures/v2_pins_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "pins": { 3 | "pin1": { 4 | "value": "1234" 5 | }, 6 | "pin2": { 7 | "value": "3456", 8 | "name": "Mother" 9 | }, 10 | "pin3": { 11 | "value": "4567", 12 | "name": "Father" 13 | }, 14 | "pin4": { 15 | "value": "", 16 | "name": "" 17 | }, 18 | "pin5": { 19 | "value": "", 20 | "name": "" 21 | }, 22 | "duress": { 23 | "value": "9876" 24 | } 25 | }, 26 | "lastUpdated": 1563208180 27 | } 28 | -------------------------------------------------------------------------------- /tests/fixtures/v2_settings_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "account": 12345, 3 | "type": "all", 4 | "success": true, 5 | "settings": { 6 | "general": { 7 | "dialingPrefix": "", 8 | "light": true, 9 | "doorChime": true, 10 | "voicePrompts": false, 11 | "systemVolume": 35, 12 | "alarmVolume": 100, 13 | "exitDelay": 120, 14 | "entryDelayHome": 120, 15 | "entryDelayAway": 120, 16 | "alarmDuration": 4 17 | }, 18 | "pins": { 19 | "pin1": "1234", 20 | "pin2": "", 21 | "pin3": "", 22 | "pin4": "", 23 | "pin5": "", 24 | "duressPin": "" 25 | }, 26 | "error": { 27 | "keypadBatteryLow": false, 28 | "communicationError": false, 29 | "noDialTone": false, 30 | "dialError": false, 31 | "checksumWrong": false, 32 | "notRegistered": false, 33 | "messageFailed": false, 34 | "outOfRange": false 35 | }, 36 | "sensors": [ 37 | { 38 | "type": 1, 39 | "serial": "195", 40 | "setting": 1, 41 | "instant": false, 42 | "enotify": false, 43 | "sensorStatus": 0, 44 | "sensorData": 0, 45 | "name": "Garage Keypad", 46 | "error": false 47 | }, 48 | { 49 | "type": 1, 50 | "serial": "583", 51 | "setting": 1, 52 | "instant": false, 53 | "enotify": false, 54 | "sensorStatus": 0, 55 | "sensorData": 0, 56 | "name": "Master Bedroom Keypad", 57 | "error": false 58 | }, 59 | { 60 | "type": 2, 61 | "serial": "654", 62 | "setting": 1, 63 | "instant": false, 64 | "enotify": false, 65 | "sensorStatus": 0, 66 | "sensorData": 0, 67 | "name": "Keychain #1", 68 | "error": false 69 | }, 70 | { 71 | "type": 2, 72 | "serial": "429", 73 | "setting": 1, 74 | "instant": false, 75 | "enotify": false, 76 | "sensorStatus": 0, 77 | "sensorData": 0, 78 | "name": "Keychain #1", 79 | "error": false 80 | }, 81 | { 82 | "type": 3, 83 | "serial": "425", 84 | "setting": 1, 85 | "instant": false, 86 | "enotify": false, 87 | "sensorStatus": 2, 88 | "sensorData": 0, 89 | "name": "Front Door Panic", 90 | "error": false 91 | }, 92 | { 93 | "type": 3, 94 | "serial": "874", 95 | "setting": 1, 96 | "instant": false, 97 | "enotify": false, 98 | "sensorStatus": 6, 99 | "sensorData": 0, 100 | "name": "Master Bathroom Panic", 101 | "error": false 102 | }, 103 | { 104 | "type": 3, 105 | "serial": "427", 106 | "setting": 1, 107 | "instant": false, 108 | "enotify": false, 109 | "sensorStatus": 5, 110 | "sensorData": 0, 111 | "name": "Master Bedroom Panic", 112 | "error": false 113 | }, 114 | { 115 | "type": 4, 116 | "serial": "672", 117 | "setting": 2, 118 | "instant": false, 119 | "enotify": false, 120 | "sensorStatus": 0, 121 | "sensorData": 0, 122 | "name": "Main Level Motion", 123 | "error": false 124 | }, 125 | { 126 | "type": 4, 127 | "serial": "119", 128 | "setting": 2, 129 | "instant": false, 130 | "enotify": false, 131 | "sensorStatus": 0, 132 | "sensorData": 0, 133 | "name": "Basement Motion", 134 | "error": false 135 | }, 136 | { 137 | "type": 5, 138 | "serial": "324", 139 | "setting": 1, 140 | "instant": false, 141 | "enotify": false, 142 | "sensorStatus": 2, 143 | "sensorData": 210, 144 | "name": "Master Window #1", 145 | "error": false, 146 | "entryStatus": "closed" 147 | }, 148 | { 149 | "type": 5, 150 | "serial": "609", 151 | "setting": 1, 152 | "instant": false, 153 | "enotify": false, 154 | "sensorStatus": 0, 155 | "sensorData": 130, 156 | "name": "Door to Garage", 157 | "error": false, 158 | "entryStatus": "closed" 159 | }, 160 | { 161 | "type": 5, 162 | "serial": "936", 163 | "setting": 1, 164 | "instant": false, 165 | "enotify": false, 166 | "sensorStatus": 4, 167 | "sensorData": 178, 168 | "name": "Basement Window #1", 169 | "error": false, 170 | "entryStatus": "closed" 171 | }, 172 | { 173 | "type": 5, 174 | "serial": "102", 175 | "setting": 1, 176 | "instant": false, 177 | "enotify": false, 178 | "sensorStatus": 4, 179 | "sensorData": 162, 180 | "name": "Family Room Window #1", 181 | "error": false, 182 | "entryStatus": "closed" 183 | }, 184 | { 185 | "type": 5, 186 | "serial": "419", 187 | "setting": 1, 188 | "instant": false, 189 | "enotify": false, 190 | "sensorStatus": 4, 191 | "sensorData": 114, 192 | "name": "Family Room Window #2", 193 | "error": false, 194 | "entryStatus": "closed" 195 | }, 196 | { 197 | "type": 5, 198 | "serial": "199", 199 | "setting": 1, 200 | "instant": false, 201 | "enotify": false, 202 | "sensorStatus": 2, 203 | "sensorData": 226, 204 | "name": "Master Window #2", 205 | "error": false, 206 | "entryStatus": "closed" 207 | }, 208 | { 209 | "type": 5, 210 | "serial": "610", 211 | "setting": 1, 212 | "instant": false, 213 | "enotify": false, 214 | "sensorStatus": 1, 215 | "sensorData": 210, 216 | "name": "Master Bathroom Window", 217 | "error": false, 218 | "entryStatus": "closed" 219 | }, 220 | { 221 | "type": 5, 222 | "serial": "84", 223 | "setting": 1, 224 | "instant": false, 225 | "enotify": false, 226 | "sensorStatus": 0, 227 | "sensorData": 82, 228 | "name": "Front Door", 229 | "error": false, 230 | "entryStatus": "closed" 231 | }, 232 | { 233 | "type": 5, 234 | "serial": "190", 235 | "setting": 1, 236 | "instant": false, 237 | "enotify": false, 238 | "sensorStatus": 1, 239 | "sensorData": 210, 240 | "name": "Back Patio Door", 241 | "error": false, 242 | "entryStatus": "closed" 243 | }, 244 | { 245 | "type": 5, 246 | "serial": "939", 247 | "setting": 1, 248 | "instant": false, 249 | "enotify": false, 250 | "sensorStatus": 2, 251 | "sensorData": 162, 252 | "name": "Office Window #2", 253 | "error": false, 254 | "entryStatus": "closed" 255 | }, 256 | { 257 | "type": 5, 258 | "serial": "460", 259 | "setting": 1, 260 | "instant": false, 261 | "enotify": false, 262 | "sensorStatus": 3, 263 | "sensorData": 178, 264 | "name": "Basement Window #2", 265 | "error": false, 266 | "entryStatus": "closed" 267 | }, 268 | { 269 | "type": 5, 270 | "serial": "231", 271 | "setting": 1, 272 | "instant": false, 273 | "enotify": false, 274 | "sensorStatus": 2, 275 | "sensorData": 162, 276 | "name": "Office Window #1", 277 | "error": false, 278 | "entryStatus": "closed" 279 | }, 280 | { 281 | "type": 5, 282 | "serial": "271", 283 | "setting": 1, 284 | "instant": false, 285 | "enotify": false, 286 | "sensorStatus": 1, 287 | "sensorData": 242, 288 | "name": "Equipment Room Window", 289 | "error": false, 290 | "entryStatus": "closed" 291 | }, 292 | { 293 | "type": 5, 294 | "serial": "707", 295 | "setting": 1, 296 | "instant": false, 297 | "enotify": false, 298 | "sensorStatus": 6, 299 | "sensorData": 178, 300 | "name": "Basement Bedroom Window", 301 | "error": false, 302 | "entryStatus": "closed" 303 | }, 304 | { 305 | "type": 6, 306 | "serial": "87", 307 | "setting": 1, 308 | "instant": false, 309 | "enotify": false, 310 | "sensorStatus": 3, 311 | "sensorData": 240, 312 | "name": "Office Glass Break", 313 | "error": false, 314 | "battery": "ok" 315 | }, 316 | { 317 | "type": 6, 318 | "serial": "30", 319 | "setting": 1, 320 | "instant": false, 321 | "enotify": false, 322 | "sensorStatus": 2, 323 | "sensorData": 0, 324 | "name": "Equipment Room Glass", 325 | "error": false, 326 | "battery": "ok" 327 | }, 328 | { 329 | "type": 6, 330 | "serial": "205", 331 | "setting": 1, 332 | "instant": false, 333 | "enotify": false, 334 | "sensorStatus": 3, 335 | "sensorData": 224, 336 | "name": "Master Bathroom Glass", 337 | "error": false, 338 | "battery": "ok" 339 | }, 340 | { 341 | "type": 6, 342 | "serial": "143", 343 | "setting": 1, 344 | "instant": false, 345 | "enotify": false, 346 | "sensorStatus": 2, 347 | "sensorData": 240, 348 | "name": "Basement Glass Break", 349 | "error": false, 350 | "battery": "ok" 351 | }, 352 | { 353 | "type": 6, 354 | "serial": "527", 355 | "setting": 1, 356 | "instant": false, 357 | "enotify": false, 358 | "sensorStatus": 1, 359 | "sensorData": 32, 360 | "name": "Basement Bedroom Glass", 361 | "error": false, 362 | "battery": "ok" 363 | }, 364 | { 365 | "type": 6, 366 | "serial": "132", 367 | "setting": 1, 368 | "instant": false, 369 | "enotify": false, 370 | "sensorStatus": 3, 371 | "sensorData": 240, 372 | "name": "Kitchen Glass Break", 373 | "error": false, 374 | "battery": "ok" 375 | }, 376 | { 377 | "type": 6, 378 | "serial": "199", 379 | "setting": 1, 380 | "instant": false, 381 | "enotify": false, 382 | "sensorStatus": 2, 383 | "sensorData": 240, 384 | "name": "Master Glass Break", 385 | "error": false, 386 | "battery": "ok" 387 | }, 388 | { 389 | "type": 9, 390 | "serial": "314", 391 | "setting": 63, 392 | "instant": true, 393 | "enotify": true, 394 | "sensorStatus": 6, 395 | "sensorData": 0, 396 | "name": "Washing Machine", 397 | "error": false 398 | }, 399 | { 400 | "type": 9, 401 | "serial": "372", 402 | "setting": 63, 403 | "instant": true, 404 | "enotify": true, 405 | "sensorStatus": 4, 406 | "sensorData": 0, 407 | "name": "Refrigerator Water", 408 | "error": false 409 | }, 410 | { 411 | "type": 8, 412 | "serial": "620", 413 | "setting": 63, 414 | "instant": true, 415 | "enotify": true, 416 | "sensorStatus": 0, 417 | "sensorData": 0, 418 | "name": "Upstairs Smoke", 419 | "error": false, 420 | "battery": "ok" 421 | }, 422 | { 423 | "type": 8, 424 | "serial": "994", 425 | "setting": 63, 426 | "instant": true, 427 | "enotify": true, 428 | "sensorStatus": 0, 429 | "sensorData": 0, 430 | "name": "Downstairs Smoke", 431 | "error": false, 432 | "battery": "ok" 433 | }, 434 | { 435 | "type": 7, 436 | "serial": "507", 437 | "setting": 63, 438 | "instant": true, 439 | "enotify": true, 440 | "sensorStatus": 0, 441 | "sensorData": 0, 442 | "name": "Upstairs CO", 443 | "error": false, 444 | "battery": "ok" 445 | }, 446 | { 447 | "type": 42, 448 | "serial": "974", 449 | "setting": 63, 450 | "instant": true, 451 | "enotify": true, 452 | "sensorStatus": 0, 453 | "sensorData": 0, 454 | "name": "Downstairs Thingy", 455 | "error": false, 456 | "battery": "ok" 457 | }, 458 | {}, 459 | {}, 460 | {}, 461 | {}, 462 | {} 463 | ] 464 | }, 465 | "lastUpdated": 1521939555, 466 | "lastStatus": "success_set" 467 | } 468 | -------------------------------------------------------------------------------- /tests/fixtures/v2_state_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": true, 3 | "reason": null, 4 | "requestedState": "home", 5 | "lastUpdated": 1534725096, 6 | "exitDelay": 120 7 | } 8 | -------------------------------------------------------------------------------- /tests/fixtures/v3_sensors_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "account": 12345, 3 | "success": true, 4 | "sensors": [ 5 | { 6 | "type": 5, 7 | "serial": "825", 8 | "name": "Fire Door", 9 | "setting": { 10 | "instantTrigger": false, 11 | "away2": 1, 12 | "away": 1, 13 | "home2": 1, 14 | "home": 1, 15 | "off": 0 16 | }, 17 | "status": { 18 | "triggered": false 19 | }, 20 | "flags": { 21 | "swingerShutdown": false, 22 | "lowBattery": false, 23 | "offline": false 24 | } 25 | }, 26 | { 27 | "type": 5, 28 | "serial": "14", 29 | "name": "Front Door", 30 | "setting": { 31 | "instantTrigger": false, 32 | "away2": 1, 33 | "away": 1, 34 | "home2": 1, 35 | "home": 1, 36 | "off": 0 37 | }, 38 | "status": { 39 | "triggered": false 40 | }, 41 | "flags": { 42 | "swingerShutdown": false, 43 | "lowBattery": false, 44 | "offline": false 45 | } 46 | }, 47 | { 48 | "type": 5, 49 | "serial": "185", 50 | "name": "Patio Door", 51 | "setting": { 52 | "instantTrigger": true, 53 | "away2": 1, 54 | "away": 1, 55 | "home2": 1, 56 | "home": 1, 57 | "off": 0 58 | }, 59 | "status": { 60 | "triggered": false 61 | }, 62 | "flags": { 63 | "swingerShutdown": false, 64 | "lowBattery": false, 65 | "offline": false 66 | } 67 | }, 68 | { 69 | "type": 13, 70 | "serial": "236", 71 | "name": "Basement", 72 | "setting": { 73 | "alarmVolume": 3, 74 | "doorChime": 0, 75 | "exitBeeps": 0, 76 | "entryBeeps": 2 77 | }, 78 | "status": {}, 79 | "flags": { 80 | "swingerShutdown": false, 81 | "lowBattery": false, 82 | "offline": false 83 | } 84 | }, 85 | { 86 | "type": 3, 87 | "serial": "789", 88 | "name": "Front Door", 89 | "setting": { 90 | "alarm": 1 91 | }, 92 | "status": {}, 93 | "flags": { 94 | "swingerShutdown": false, 95 | "lowBattery": false, 96 | "offline": false 97 | } 98 | }, 99 | { 100 | "type": 3, 101 | "serial": "822", 102 | "name": "Master BR", 103 | "setting": { 104 | "alarm": 1 105 | }, 106 | "status": {}, 107 | "flags": { 108 | "swingerShutdown": false, 109 | "lowBattery": false, 110 | "offline": false 111 | } 112 | }, 113 | { 114 | "type": 1, 115 | "serial": "972", 116 | "name": "Kitchen", 117 | "setting": { 118 | "lowPowerMode": false, 119 | "alarm": 1 120 | }, 121 | "status": {}, 122 | "flags": { 123 | "swingerShutdown": false, 124 | "lowBattery": false, 125 | "offline": false 126 | } 127 | }, 128 | { 129 | "type": 8, 130 | "serial": "93", 131 | "name": "Upstairs", 132 | "setting": {}, 133 | "status": { 134 | "test": false, 135 | "tamper": false, 136 | "malfunction": false, 137 | "triggered": false 138 | }, 139 | "flags": { 140 | "swingerShutdown": false, 141 | "lowBattery": false, 142 | "offline": false 143 | } 144 | }, 145 | { 146 | "type": 8, 147 | "serial": "650", 148 | "name": "Downstairs", 149 | "setting": {}, 150 | "status": { 151 | "test": false, 152 | "tamper": false, 153 | "malfunction": false, 154 | "triggered": false 155 | }, 156 | "flags": { 157 | "swingerShutdown": false, 158 | "lowBattery": false, 159 | "offline": false 160 | } 161 | }, 162 | { 163 | "type": 6, 164 | "serial": "491", 165 | "name": "Basement N", 166 | "setting": { 167 | "instantTrigger": false, 168 | "away2": 1, 169 | "away": 1, 170 | "home2": 1, 171 | "home": 1, 172 | "off": 0 173 | }, 174 | "status": {}, 175 | "flags": { 176 | "swingerShutdown": false, 177 | "lowBattery": false, 178 | "offline": false 179 | } 180 | }, 181 | { 182 | "type": 6, 183 | "serial": "280", 184 | "name": "Mud Counter", 185 | "setting": { 186 | "instantTrigger": false, 187 | "away2": 1, 188 | "away": 1, 189 | "home2": 1, 190 | "home": 1, 191 | "off": 0 192 | }, 193 | "status": {}, 194 | "flags": { 195 | "swingerShutdown": false, 196 | "lowBattery": false, 197 | "offline": false 198 | } 199 | }, 200 | { 201 | "type": 6, 202 | "serial": "430", 203 | "name": "Basement S", 204 | "setting": { 205 | "instantTrigger": false, 206 | "away2": 1, 207 | "away": 1, 208 | "home2": 1, 209 | "home": 1, 210 | "off": 0 211 | }, 212 | "status": {}, 213 | "flags": { 214 | "swingerShutdown": false, 215 | "lowBattery": false, 216 | "offline": false 217 | } 218 | }, 219 | { 220 | "type": 9, 221 | "serial": "129", 222 | "name": "Laundry", 223 | "setting": { 224 | "alarm": 1 225 | }, 226 | "status": { 227 | "triggered": false 228 | }, 229 | "flags": { 230 | "swingerShutdown": false, 231 | "lowBattery": false, 232 | "offline": false 233 | } 234 | }, 235 | { 236 | "type": 9, 237 | "serial": "975", 238 | "name": "Basement", 239 | "setting": { 240 | "alarm": 1 241 | }, 242 | "status": { 243 | "triggered": false 244 | }, 245 | "flags": { 246 | "swingerShutdown": false, 247 | "lowBattery": false, 248 | "offline": false 249 | } 250 | }, 251 | { 252 | "type": 9, 253 | "serial": "382", 254 | "name": "Fridge", 255 | "setting": { 256 | "alarm": 1 257 | }, 258 | "status": { 259 | "triggered": false 260 | }, 261 | "flags": { 262 | "swingerShutdown": false, 263 | "lowBattery": false, 264 | "offline": false 265 | } 266 | }, 267 | { 268 | "type": 10, 269 | "serial": "320", 270 | "name": "Basement", 271 | "setting": { 272 | "highTemp": 95, 273 | "lowTemp": 41, 274 | "alarm": 1 275 | }, 276 | "status": { 277 | "temperature": 67, 278 | "triggered": false 279 | }, 280 | "flags": { 281 | "swingerShutdown": false, 282 | "lowBattery": false, 283 | "offline": false 284 | } 285 | }, 286 | { 287 | "type": 4, 288 | "serial": "785", 289 | "name": "Upstairs", 290 | "setting": { 291 | "instantTrigger": false, 292 | "away2": 1, 293 | "away": 1, 294 | "home2": 0, 295 | "home": 0, 296 | "off": 0 297 | }, 298 | "status": {}, 299 | "flags": { 300 | "swingerShutdown": false, 301 | "lowBattery": false, 302 | "offline": false 303 | } 304 | }, 305 | { 306 | "type": 4, 307 | "serial": "934", 308 | "name": "Downstairs", 309 | "setting": { 310 | "instantTrigger": false, 311 | "away2": 1, 312 | "away": 1, 313 | "home2": 0, 314 | "home": 0, 315 | "off": 0 316 | }, 317 | "status": {}, 318 | "flags": { 319 | "swingerShutdown": false, 320 | "lowBattery": false, 321 | "offline": false 322 | } 323 | }, 324 | { 325 | "type": 6, 326 | "serial": "634", 327 | "name": "Landing", 328 | "setting": { 329 | "instantTrigger": false, 330 | "away2": 1, 331 | "away": 1, 332 | "home2": 1, 333 | "home": 1, 334 | "off": 0 335 | }, 336 | "status": {}, 337 | "flags": { 338 | "swingerShutdown": false, 339 | "lowBattery": false, 340 | "offline": false 341 | } 342 | }, 343 | { 344 | "type": 6, 345 | "serial": "801", 346 | "name": "Living Room", 347 | "setting": { 348 | "instantTrigger": false, 349 | "away2": 1, 350 | "away": 1, 351 | "home2": 1, 352 | "home": 1, 353 | "off": 0 354 | }, 355 | "status": {}, 356 | "flags": { 357 | "swingerShutdown": false, 358 | "lowBattery": false, 359 | "offline": false 360 | } 361 | }, 362 | { 363 | "type": 6, 364 | "serial": "946", 365 | "name": "Eating Area", 366 | "setting": { 367 | "instantTrigger": false, 368 | "away2": 1, 369 | "away": 1, 370 | "home2": 1, 371 | "home": 1, 372 | "off": 0 373 | }, 374 | "status": {}, 375 | "flags": { 376 | "swingerShutdown": false, 377 | "lowBattery": false, 378 | "offline": false 379 | } 380 | }, 381 | { 382 | "serial": "987", 383 | "type": 16, 384 | "status": { 385 | "pinPadState": 0, 386 | "lockState": 1, 387 | "pinPadOffline": false, 388 | "pinPadLowBattery": false, 389 | "lockDisabled": false, 390 | "lockLowBattery": false, 391 | "calibrationErrDelta": 0, 392 | "calibrationErrZero": 0, 393 | "lockJamState": 0 394 | }, 395 | "name": "Front Door", 396 | "deviceGroupID": 1, 397 | "firmwareVersion": "1.0.0", 398 | "bootVersion": "1.0.0", 399 | "setting": { 400 | "autoLock": 3, 401 | "away": 1, 402 | "home": 1, 403 | "awayToOff": 0, 404 | "homeToOff": 1 405 | }, 406 | "flags": { 407 | "swingerShutdown": false, 408 | "lowBattery": false, 409 | "offline": false 410 | } 411 | }, 412 | { 413 | "serial": "987a", 414 | "type": 253, 415 | "status": {}, 416 | "name": "Front Door", 417 | "deviceGroupID": 1, 418 | "setting": {}, 419 | "flags": { 420 | "swingerShutdown": false, 421 | "lowBattery": false, 422 | "offline": false 423 | } 424 | }, 425 | { 426 | "serial": "654", 427 | "type": 16, 428 | "status": { 429 | "pinPadState": 0, 430 | "lockState": 1, 431 | "pinPadOffline": false, 432 | "pinPadLowBattery": false, 433 | "lockDisabled": false, 434 | "lockLowBattery": false, 435 | "calibrationErrDelta": 0, 436 | "calibrationErrZero": 0, 437 | "lockJamState": 1 438 | }, 439 | "name": "Back Door", 440 | "deviceGroupID": 1, 441 | "firmwareVersion": "1.0.0", 442 | "bootVersion": "1.0.0", 443 | "setting": { 444 | "autoLock": 3, 445 | "away": 1, 446 | "home": 1, 447 | "awayToOff": 0, 448 | "homeToOff": 1 449 | }, 450 | "flags": { 451 | "swingerShutdown": false, 452 | "lowBattery": false, 453 | "offline": false 454 | } 455 | }, 456 | { 457 | "serial": "654a", 458 | "type": 253, 459 | "status": {}, 460 | "name": "Front Door", 461 | "deviceGroupID": 1, 462 | "setting": {}, 463 | "flags": { 464 | "swingerShutdown": false, 465 | "lowBattery": false, 466 | "offline": false 467 | } 468 | }, 469 | { 470 | "serial": "321", 471 | "type": 16, 472 | "status": { 473 | "pinPadState": 0, 474 | "lockState": 42, 475 | "pinPadOffline": false, 476 | "pinPadLowBattery": false, 477 | "lockDisabled": false, 478 | "lockLowBattery": false, 479 | "calibrationErrDelta": 0, 480 | "calibrationErrZero": 0, 481 | "lockJamState": 0 482 | }, 483 | "name": "Side Door", 484 | "deviceGroupID": 1, 485 | "firmwareVersion": "1.0.0", 486 | "bootVersion": "1.0.0", 487 | "setting": { 488 | "autoLock": 3, 489 | "away": 1, 490 | "home": 1, 491 | "awayToOff": 0, 492 | "homeToOff": 1 493 | }, 494 | "flags": { 495 | "swingerShutdown": false, 496 | "lowBattery": false, 497 | "offline": false 498 | } 499 | }, 500 | { 501 | "serial": "321a", 502 | "type": 253, 503 | "status": {}, 504 | "name": "Front Door", 505 | "deviceGroupID": 1, 506 | "setting": {}, 507 | "flags": { 508 | "swingerShutdown": false, 509 | "lowBattery": false, 510 | "offline": false 511 | } 512 | }, 513 | { 514 | "serial": "00000000", 515 | "type": 14, 516 | "name": "Kitchen", 517 | "setting": {}, 518 | "status": { 519 | "coTriggered": false, 520 | "malfunction": false, 521 | "tamper": true, 522 | "lowSensitivity": false, 523 | "endOfLife": false, 524 | "test": false, 525 | "smokeTriggered": false, 526 | "preSmokeAlarm": false 527 | }, 528 | "timestamp": 0, 529 | "rssi": -45, 530 | "WDTCount": 23, 531 | "nonce": 0, 532 | "rebootCnt": 17, 533 | "deviceGroupID": 0, 534 | "flags": { 535 | "offline": true, 536 | "lowBattery": false, 537 | "swingerShutdown": false 538 | } 539 | } 540 | ], 541 | "lastUpdated": 1534626361, 542 | "lastSynced": 1534626361, 543 | "lastStatusUpdate": 1534626358 544 | } 545 | -------------------------------------------------------------------------------- /tests/fixtures/v3_settings_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "account": "12345012", 3 | "settings": { 4 | "normal": { 5 | "wifiSSID": "MY_WIFI", 6 | "alarmDuration": 240, 7 | "alarmVolume": 3, 8 | "doorChime": 2, 9 | "entryDelayAway": 30, 10 | "entryDelayAway2": 30, 11 | "entryDelayHome": 30, 12 | "entryDelayHome2": 30, 13 | "exitDelayAway": 60, 14 | "exitDelayAway2": 60, 15 | "exitDelayHome": 0, 16 | "exitDelayHome2": 0, 17 | "lastUpdated": "2019-07-03T03:24:20.999Z", 18 | "light": true, 19 | "voicePrompts": 2, 20 | "_id": "1197192618725121765212" 21 | }, 22 | "pins": { 23 | "lastUpdated": "2019-07-04T20:47:44.016Z", 24 | "_id": "asd6281526381253123", 25 | "users": [ 26 | { 27 | "_id": "1271279d966212121124c7", 28 | "pin": "3454", 29 | "name": "Test 1" 30 | }, 31 | { 32 | "_id": "1271279d966212121124c6", 33 | "pin": "5424", 34 | "name": "Test 2" 35 | }, 36 | { 37 | "_id": "1271279d966212121124c5", 38 | "pin": "", 39 | "name": "" 40 | }, 41 | { 42 | "_id": "1271279d966212121124c4", 43 | "pin": "", 44 | "name": "" 45 | } 46 | ], 47 | "duress": { 48 | "pin": "9876" 49 | }, 50 | "master": { 51 | "pin": "1234" 52 | } 53 | } 54 | }, 55 | "basestationStatus": { 56 | "lastUpdated": "2019-07-15T15:28:22.961Z", 57 | "rfJamming": false, 58 | "ethernetStatus": 4, 59 | "gsmRssi": -73, 60 | "gsmStatus": 3, 61 | "backupBattery": 5293, 62 | "wallPower": 5933, 63 | "wifiRssi": -49, 64 | "wifiStatus": 1, 65 | "_id": "6128153715231t237123", 66 | "encryptionErrors": [] 67 | }, 68 | "lastUpdated": 1562273264 69 | } 70 | -------------------------------------------------------------------------------- /tests/fixtures/v3_state_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": true, 3 | "reason": null, 4 | "state": "HOME", 5 | "lastUpdated": 1534725096, 6 | "exitDelay": 120 7 | } 8 | -------------------------------------------------------------------------------- /tests/fixtures/ws_message_event_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "eventTimestamp": 1632957286, 3 | "eventCid": 1400, 4 | "zoneCid": "1", 5 | "sensorType": 1, 6 | "sensorSerial": "abcdef12", 7 | "account": "abcdef12", 8 | "userId": 12345, 9 | "sid": 12345, 10 | "info": "System Disarmed by Master PIN", 11 | "pinName": "Master PIN", 12 | "sensorName": "", 13 | "messageSubject": "SimpliSafe System Disarmed", 14 | "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", 15 | "eventType": "activity", 16 | "timezone": 2, 17 | "locationOffset": -360, 18 | "internal": { 19 | "dispatcher": "my_dispatcher" 20 | }, 21 | "senderId": "wifi", 22 | "openCount": 0, 23 | "eventId": 16803409109, 24 | "serviceFeatures": { 25 | "monitoring": true, 26 | "alerts": true, 27 | "online": true, 28 | "hazard": true, 29 | "video": true, 30 | "cameras": 10, 31 | "dispatch": true, 32 | "proInstall": false, 33 | "discount": 0, 34 | "vipCS": false, 35 | "medical": true, 36 | "careVisit": false, 37 | "storageDays": 30 38 | }, 39 | "copsVideoOptIn": false 40 | } 41 | -------------------------------------------------------------------------------- /tests/fixtures/ws_message_hello_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "AbCdEfgHiK", 3 | "timeouts": { 4 | "heartbeat": 45000, 5 | "inactivity": 30000 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/fixtures/ws_message_subscribed_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "namespace": "uid:428836" 3 | } 4 | -------------------------------------------------------------------------------- /tests/fixtures/ws_motion_event_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "eventUuid": "xxx", 3 | "eventTimestamp": 1703882325, 4 | "eventCid": 1170, 5 | "zoneCid": "1", 6 | "sensorType": 17, 7 | "sensorSerial": "f11b6abd", 8 | "account": "abcdef12", 9 | "userId": 12345, 10 | "sid": 12345, 11 | "info": "Back Yard Camera Detected Motion", 12 | "pinName": "", 13 | "sensorName": "Back Yard", 14 | "messageSubject": "Camera Detected Motion", 15 | "messageBody": "Back Yard Camera Detected Motion on 12/29/2023 at 12:38 PM", 16 | "eventType": "activityCam", 17 | "timezone": 3, 18 | "locationOffset": -480, 19 | "internal": { 20 | "dispatcher": "my_dispatcher" 21 | }, 22 | "senderId": "", 23 | "eventId": 38552328826, 24 | "serviceFeatures": { 25 | "monitoring": false, 26 | "alerts": true, 27 | "online": true, 28 | "hazard": false, 29 | "video": true, 30 | "cameras": 10, 31 | "dispatch": false, 32 | "proInstall": false, 33 | "discount": 0, 34 | "vipCS": false, 35 | "medical": false, 36 | "careVisit": false, 37 | "storageDays": 30 38 | }, 39 | "copsVideoOptIn": false, 40 | "videoStartedBy": "6172311af9da430ab2e11c59f11b6abd", 41 | "video": { 42 | "6172311af9da430ab2e11c59f11b6abd": { 43 | "clipId": "26361666077", 44 | "preroll": 3, 45 | "postroll": 7, 46 | "cameraName": "Back Yard", 47 | "eventId": "38552328826", 48 | "sid": 12345, 49 | "timestamp": 1703882325, 50 | "recordingType": "KVS", 51 | "account": "abcdef12", 52 | "region": "us-east-1", 53 | "actualDuration": 0, 54 | "status": "PENDING", 55 | "_links": { 56 | "_self": { 57 | "href": "https://chronicle.us-east-1.prd.cam.simplisafe.com/v1/recordings/26361666077", 58 | "method": "GET" 59 | }, 60 | "preview/mjpg": { 61 | "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}", 62 | "method": "GET", 63 | "templated": true 64 | }, 65 | "snapshot/mjpg": { 66 | "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}", 67 | "method": "GET", 68 | "templated": true 69 | }, 70 | "snapshot/jpg": { 71 | "href": "https://image-url{&width}", 72 | "method": "GET", 73 | "templated": true 74 | }, 75 | "download/mp4": { 76 | "href": "https://clip-url", 77 | "method": "GET" 78 | }, 79 | "share": { 80 | "href": "https://remix.us-east-1.prd.cam.simplisafe.com/v2/share/6172311af9da430ab2e11c59f11b6abd/12345/time/1703882322/1703882332?account=611485993050®ion=us-east-1", 81 | "method": "POST" 82 | }, 83 | "playback/dash": { 84 | "href": "https://mediator.prd.cam.simplisafe.com/v1/recording/6172311af9da430ab2e11c59f11b6abd/12345/time/1703882322/1703882332/dash?account=611485993050®ion=us-east-1", 85 | "method": "GET" 86 | }, 87 | "playback/hls": { 88 | "href": "https://hls-url", 89 | "method": "GET" 90 | } 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/sensor/__init__.py: -------------------------------------------------------------------------------- 1 | """Define tests for sensors.""" 2 | -------------------------------------------------------------------------------- /tests/sensor/test_base.py: -------------------------------------------------------------------------------- 1 | """Define base tests for Sensor objects.""" 2 | 3 | import aiohttp 4 | import pytest 5 | from aresponses import ResponsesMockServer 6 | 7 | from simplipy import API 8 | from simplipy.device import DeviceTypes 9 | from tests.common import TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, TEST_SYSTEM_ID 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_properties_base( 14 | aresponses: ResponsesMockServer, 15 | authenticated_simplisafe_server_v2: ResponsesMockServer, 16 | ) -> None: 17 | """Test that base sensor properties are created properly.""" 18 | async with authenticated_simplisafe_server_v2, aiohttp.ClientSession() as session: 19 | simplisafe = await API.async_from_auth( 20 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 21 | ) 22 | systems = await simplisafe.async_get_systems() 23 | system = systems[TEST_SYSTEM_ID] 24 | sensor = system.sensors["195"] 25 | assert sensor.name == "Garage Keypad" 26 | assert sensor.serial == "195" 27 | assert sensor.type == DeviceTypes.KEYPAD 28 | 29 | aresponses.assert_plan_strictly_followed() 30 | -------------------------------------------------------------------------------- /tests/sensor/test_v2.py: -------------------------------------------------------------------------------- 1 | """Define tests for V2 Sensor objects.""" 2 | 3 | from typing import cast 4 | 5 | import aiohttp 6 | import pytest 7 | from aresponses import ResponsesMockServer 8 | 9 | from simplipy import API 10 | from simplipy.device.sensor.v2 import SensorV2 11 | from simplipy.errors import SimplipyError 12 | from tests.common import TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, TEST_SYSTEM_ID 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_properties_v2( 17 | aresponses: ResponsesMockServer, 18 | authenticated_simplisafe_server_v2: ResponsesMockServer, 19 | ) -> None: 20 | """Test that v2 sensor properties are created properly.""" 21 | async with authenticated_simplisafe_server_v2, aiohttp.ClientSession() as session: 22 | simplisafe = await API.async_from_auth( 23 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 24 | ) 25 | systems = await simplisafe.async_get_systems() 26 | system = systems[TEST_SYSTEM_ID] 27 | keypad: SensorV2 = cast(SensorV2, system.sensors["195"]) 28 | assert keypad.data == 0 29 | assert not keypad.error 30 | assert not keypad.low_battery 31 | assert keypad.settings == 1 32 | 33 | # Ensure that attempting to access the triggered of anything but 34 | # an entry sensor in a V2 system throws an error: 35 | with pytest.raises(SimplipyError): 36 | assert keypad.triggered == 42 37 | 38 | entry_sensor: SensorV2 = cast(SensorV2, system.sensors["609"]) 39 | assert entry_sensor.data == 130 40 | assert not entry_sensor.error 41 | assert not entry_sensor.low_battery 42 | assert entry_sensor.settings == 1 43 | assert not entry_sensor.trigger_instantly 44 | assert not entry_sensor.triggered 45 | 46 | aresponses.assert_plan_strictly_followed() 47 | -------------------------------------------------------------------------------- /tests/sensor/test_v3.py: -------------------------------------------------------------------------------- 1 | """Define tests for v3 Sensor objects.""" 2 | 3 | from typing import cast 4 | 5 | import aiohttp 6 | import pytest 7 | from aresponses import ResponsesMockServer 8 | 9 | from simplipy import API 10 | from simplipy.device.sensor.v3 import SensorV3 11 | from tests.common import TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, TEST_SYSTEM_ID 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_properties_v3( 16 | aresponses: ResponsesMockServer, 17 | authenticated_simplisafe_server_v3: ResponsesMockServer, 18 | ) -> None: 19 | """Test that v3 sensor properties are created properly.""" 20 | async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: 21 | simplisafe = await API.async_from_auth( 22 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 23 | ) 24 | systems = await simplisafe.async_get_systems() 25 | system = systems[TEST_SYSTEM_ID] 26 | 27 | entry_sensor: SensorV3 = cast(SensorV3, system.sensors["825"]) 28 | assert not entry_sensor.error 29 | assert not entry_sensor.low_battery 30 | assert not entry_sensor.offline 31 | assert not entry_sensor.settings["instantTrigger"] 32 | assert not entry_sensor.trigger_instantly 33 | assert not entry_sensor.triggered 34 | 35 | siren: SensorV3 = cast(SensorV3, system.sensors["236"]) 36 | assert not siren.triggered 37 | 38 | temperature_sensor: SensorV3 = cast(SensorV3, system.sensors["320"]) 39 | assert temperature_sensor.temperature == 67 40 | 41 | # Ensure that attempting to access the temperature attribute of a 42 | # non-temperature sensor throws an error: 43 | with pytest.raises(AttributeError): 44 | assert siren.temperature == 42 45 | 46 | aresponses.assert_plan_strictly_followed() 47 | -------------------------------------------------------------------------------- /tests/system/__init__.py: -------------------------------------------------------------------------------- 1 | """Define tests for systems.""" 2 | -------------------------------------------------------------------------------- /tests/system/test_base.py: -------------------------------------------------------------------------------- 1 | """Define base tests for System objects.""" 2 | # pylint: disable=too-many-positional-arguments 3 | 4 | from datetime import datetime 5 | from typing import Any, cast 6 | from unittest.mock import Mock 7 | 8 | import aiohttp 9 | import pytest 10 | from aresponses import ResponsesMockServer 11 | 12 | from simplipy import API 13 | from simplipy.system import SystemStates 14 | from simplipy.system.v3 import SystemV3 15 | from tests.common import ( 16 | TEST_ADDRESS, 17 | TEST_AUTHORIZATION_CODE, 18 | TEST_CODE_VERIFIER, 19 | TEST_SUBSCRIPTION_ID, 20 | TEST_SYSTEM_ID, 21 | TEST_SYSTEM_SERIAL_NO, 22 | TEST_USER_ID, 23 | ) 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_deactivated_system( 28 | aresponses: ResponsesMockServer, 29 | authenticated_simplisafe_server: ResponsesMockServer, 30 | subscriptions_response: dict[str, Any], 31 | ) -> None: 32 | """Test that API.async_get_systems doesn't return deactivated systems. 33 | 34 | Args: 35 | aresponses: An aresponses server. 36 | authenticated_simplisafe_server: A authenticated API connection. 37 | subscriptions_response: An API response payload. 38 | """ 39 | subscriptions_response["subscriptions"][0]["status"]["hasBaseStation"] = 0 40 | 41 | async with authenticated_simplisafe_server: 42 | authenticated_simplisafe_server.add( 43 | "api.simplisafe.com", 44 | f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", 45 | "get", 46 | response=aiohttp.web_response.json_response( 47 | subscriptions_response, status=200 48 | ), 49 | ) 50 | 51 | async with aiohttp.ClientSession() as session: 52 | simplisafe = await API.async_from_auth( 53 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 54 | ) 55 | systems = await simplisafe.async_get_systems() 56 | assert len(systems) == 0 57 | 58 | aresponses.assert_plan_strictly_followed() 59 | 60 | 61 | @pytest.mark.asyncio 62 | async def test_get_events( 63 | aresponses: ResponsesMockServer, 64 | authenticated_simplisafe_server_v2: ResponsesMockServer, 65 | events_response: dict[str, Any], 66 | ) -> None: 67 | """Test getting events from a system. 68 | 69 | Args: 70 | aresponses: An aresponses server. 71 | authenticated_simplisafe_server_v2: A authenticated API connection. 72 | events_response: An API response payload. 73 | """ 74 | async with authenticated_simplisafe_server_v2: 75 | authenticated_simplisafe_server_v2.add( 76 | "api.simplisafe.com", 77 | f"/v1/subscriptions/{TEST_SYSTEM_ID}/events", 78 | "get", 79 | response=aiohttp.web_response.json_response(events_response, status=200), 80 | ) 81 | 82 | async with aiohttp.ClientSession() as session: 83 | simplisafe = await API.async_from_auth( 84 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 85 | ) 86 | systems = await simplisafe.async_get_systems() 87 | system = systems[TEST_SYSTEM_ID] 88 | events = await system.async_get_events(datetime.now(), 2) 89 | assert len(events) == 2 90 | 91 | aresponses.assert_plan_strictly_followed() 92 | 93 | 94 | @pytest.mark.asyncio 95 | async def test_missing_property( # pylint: disable=too-many-arguments 96 | aresponses: ResponsesMockServer, 97 | authenticated_simplisafe_server: ResponsesMockServer, 98 | caplog: Mock, 99 | subscriptions_response: dict[str, Any], 100 | v3_sensors_response: dict[str, Any], 101 | v3_settings_response: dict[str, Any], 102 | ) -> None: 103 | """Test that missing property data is handled correctly. 104 | 105 | Args: 106 | aresponses: An aresponses server. 107 | authenticated_simplisafe_server: A authenticated API connection. 108 | caplog: A mocked logging utility. 109 | subscriptions_response: An API response payload. 110 | v3_sensors_response: An API response payload. 111 | v3_settings_response: An API response payload. 112 | """ 113 | subscriptions_response["subscriptions"][0]["location"]["system"].pop("isOffline") 114 | 115 | async with authenticated_simplisafe_server: 116 | authenticated_simplisafe_server.add( 117 | "api.simplisafe.com", 118 | f"/v1/users/{TEST_USER_ID}/subscriptions", 119 | "get", 120 | response=aiohttp.web_response.json_response( 121 | subscriptions_response, status=200 122 | ), 123 | ) 124 | authenticated_simplisafe_server.add( 125 | "api.simplisafe.com", 126 | f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", 127 | "get", 128 | response=aiohttp.web_response.json_response( 129 | v3_settings_response, status=200 130 | ), 131 | ) 132 | authenticated_simplisafe_server.add( 133 | "api.simplisafe.com", 134 | f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/sensors", 135 | "get", 136 | response=aiohttp.web_response.json_response( 137 | v3_sensors_response, status=200 138 | ), 139 | ) 140 | 141 | async with aiohttp.ClientSession() as session: 142 | simplisafe = await API.async_from_auth( 143 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 144 | ) 145 | systems = await simplisafe.async_get_systems() 146 | system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) 147 | assert system.offline is False 148 | assert any( 149 | "SimpliSafe didn't return data for property: offline" in e.message 150 | for e in caplog.records 151 | ) 152 | 153 | aresponses.assert_plan_strictly_followed() 154 | 155 | 156 | @pytest.mark.asyncio 157 | async def test_missing_system_info( 158 | aresponses: ResponsesMockServer, 159 | authenticated_simplisafe_server: ResponsesMockServer, 160 | caplog: Mock, 161 | subscriptions_response: dict[str, Any], 162 | ) -> None: 163 | """Test that a subscription with missing system data is handled correctly. 164 | 165 | Args: 166 | aresponses: An aresponses server. 167 | authenticated_simplisafe_server: A authenticated API connection. 168 | caplog: A mocked logging utility. 169 | subscriptions_response: An API response payload. 170 | """ 171 | subscriptions_response["subscriptions"][0]["location"]["system"] = {} 172 | 173 | async with authenticated_simplisafe_server: 174 | authenticated_simplisafe_server.add( 175 | "api.simplisafe.com", 176 | f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", 177 | "get", 178 | response=aiohttp.web_response.json_response( 179 | subscriptions_response, status=200 180 | ), 181 | ) 182 | 183 | async with aiohttp.ClientSession() as session: 184 | simplisafe = await API.async_from_auth( 185 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 186 | ) 187 | await simplisafe.async_get_systems() 188 | assert any( 189 | "Skipping subscription with missing system data" in e.message 190 | for e in caplog.records 191 | ) 192 | 193 | aresponses.assert_plan_strictly_followed() 194 | 195 | 196 | @pytest.mark.asyncio 197 | async def test_properties( 198 | aresponses: ResponsesMockServer, 199 | authenticated_simplisafe_server_v2: ResponsesMockServer, 200 | ) -> None: 201 | """Test that base system properties are created properly. 202 | 203 | Args: 204 | aresponses: An aresponses server. 205 | authenticated_simplisafe_server_v2: A authenticated API connection. 206 | """ 207 | async with authenticated_simplisafe_server_v2, aiohttp.ClientSession() as session: 208 | simplisafe = await API.async_from_auth( 209 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 210 | ) 211 | systems = await simplisafe.async_get_systems() 212 | system = systems[TEST_SYSTEM_ID] 213 | assert not system.alarm_going_off 214 | assert system.address == TEST_ADDRESS 215 | assert system.connection_type == "wifi" 216 | assert system.serial == TEST_SYSTEM_SERIAL_NO 217 | assert system.state == SystemStates.OFF 218 | assert system.system_id == TEST_SYSTEM_ID 219 | assert system.temperature == 67 220 | assert system.version == 2 221 | 222 | aresponses.assert_plan_strictly_followed() 223 | 224 | 225 | @pytest.mark.asyncio 226 | async def test_unknown_sensor_type( 227 | aresponses: ResponsesMockServer, 228 | authenticated_simplisafe_server_v2: ResponsesMockServer, 229 | caplog: Mock, 230 | ) -> None: 231 | """Test whether a message is logged upon finding an unknown sensor type. 232 | 233 | Args: 234 | aresponses: An aresponses server. 235 | authenticated_simplisafe_server_v2: A authenticated API connection. 236 | caplog: A mocked logging utility. 237 | """ 238 | async with authenticated_simplisafe_server_v2, aiohttp.ClientSession() as session: 239 | simplisafe = await API.async_from_auth( 240 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 241 | ) 242 | await simplisafe.async_get_systems() 243 | assert any("Unknown device type" in e.message for e in caplog.records) 244 | 245 | aresponses.assert_plan_strictly_followed() 246 | 247 | 248 | @pytest.mark.asyncio 249 | async def test_unknown_system_state( # pylint: disable=too-many-arguments 250 | aresponses: ResponsesMockServer, 251 | authenticated_simplisafe_server: ResponsesMockServer, 252 | caplog: Mock, 253 | subscriptions_response: dict[str, Any], 254 | v3_sensors_response: dict[str, Any], 255 | v3_settings_response: dict[str, Any], 256 | ) -> None: 257 | """Test that an unknown system state is logged. 258 | 259 | Args: 260 | aresponses: An aresponses server. 261 | authenticated_simplisafe_server: A authenticated API connection. 262 | caplog: A mocked logging utility. 263 | subscriptions_response: An API response payload. 264 | v3_sensors_response: An API response payload. 265 | v3_settings_response: An API response payload. 266 | """ 267 | subscriptions_response["subscriptions"][0]["location"]["system"]["alarmState"] = ( 268 | "NOT_REAL_STATE" 269 | ) 270 | 271 | async with authenticated_simplisafe_server: 272 | authenticated_simplisafe_server.add( 273 | "api.simplisafe.com", 274 | f"/v1/users/{TEST_USER_ID}/subscriptions", 275 | "get", 276 | response=aiohttp.web_response.json_response( 277 | subscriptions_response, status=200 278 | ), 279 | ) 280 | authenticated_simplisafe_server.add( 281 | "api.simplisafe.com", 282 | f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/settings/normal", 283 | "get", 284 | response=aiohttp.web_response.json_response( 285 | v3_settings_response, status=200 286 | ), 287 | ) 288 | authenticated_simplisafe_server.add( 289 | "api.simplisafe.com", 290 | f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/sensors", 291 | "get", 292 | response=aiohttp.web_response.json_response( 293 | v3_sensors_response, status=200 294 | ), 295 | ) 296 | 297 | async with aiohttp.ClientSession() as session: 298 | simplisafe = await API.async_from_auth( 299 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 300 | ) 301 | await simplisafe.async_get_systems() 302 | assert any("Unknown raw system state" in e.message for e in caplog.records) 303 | assert any("NOT_REAL_STATE" in e.message for e in caplog.records) 304 | 305 | aresponses.assert_plan_strictly_followed() 306 | -------------------------------------------------------------------------------- /tests/system/test_v2.py: -------------------------------------------------------------------------------- 1 | """Define tests for v2 System objects.""" 2 | 3 | from typing import Any 4 | 5 | import aiohttp 6 | import pytest 7 | from aresponses import ResponsesMockServer 8 | 9 | from simplipy import API 10 | from simplipy.system import SystemStates 11 | from tests.common import ( 12 | TEST_AUTHORIZATION_CODE, 13 | TEST_CODE_VERIFIER, 14 | TEST_SUBSCRIPTION_ID, 15 | TEST_SYSTEM_ID, 16 | TEST_SYSTEM_SERIAL_NO, 17 | ) 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_clear_notifications( 22 | aresponses: ResponsesMockServer, 23 | authenticated_simplisafe_server_v2: ResponsesMockServer, 24 | v2_settings_response: dict[str, Any], 25 | ) -> None: 26 | """Test clearing all active notifications. 27 | 28 | Args: 29 | aresponses: An aresponses server. 30 | authenticated_simplisafe_server_v2: A authenticated API connection. 31 | v2_settings_response: An API response payload. 32 | """ 33 | async with authenticated_simplisafe_server_v2: 34 | authenticated_simplisafe_server_v2.add( 35 | "api.simplisafe.com", 36 | f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/messages", 37 | "delete", 38 | response=aiohttp.web_response.json_response( 39 | v2_settings_response, status=200 40 | ), 41 | ) 42 | 43 | async with aiohttp.ClientSession() as session: 44 | simplisafe = await API.async_from_auth( 45 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 46 | ) 47 | systems = await simplisafe.async_get_systems() 48 | system = systems[TEST_SYSTEM_ID] 49 | await system.async_clear_notifications() 50 | assert system.notifications == [] 51 | 52 | aresponses.assert_plan_strictly_followed() 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_get_pins( 57 | aresponses: ResponsesMockServer, 58 | authenticated_simplisafe_server_v2: ResponsesMockServer, 59 | v2_pins_response: dict[str, Any], 60 | ) -> None: 61 | """Test getting PINs associated with a V2 system. 62 | 63 | Args: 64 | aresponses: An aresponses server. 65 | authenticated_simplisafe_server_v2: A authenticated API connection. 66 | v2_pins_response: An API response payload. 67 | """ 68 | async with authenticated_simplisafe_server_v2: 69 | authenticated_simplisafe_server_v2.add( 70 | "api.simplisafe.com", 71 | f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/pins", 72 | "get", 73 | response=aiohttp.web_response.json_response(v2_pins_response, status=200), 74 | ) 75 | 76 | async with aiohttp.ClientSession() as session: 77 | simplisafe = await API.async_from_auth( 78 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 79 | ) 80 | systems = await simplisafe.async_get_systems() 81 | system = systems[TEST_SYSTEM_ID] 82 | pins = await system.async_get_pins() 83 | 84 | assert len(pins) == 4 85 | assert pins["master"] == "1234" 86 | assert pins["duress"] == "9876" 87 | assert pins["Mother"] == "3456" 88 | assert pins["Father"] == "4567" 89 | 90 | aresponses.assert_plan_strictly_followed() 91 | 92 | 93 | @pytest.mark.asyncio 94 | async def test_async_get_systems( 95 | aresponses: ResponsesMockServer, 96 | authenticated_simplisafe_server_v2: ResponsesMockServer, 97 | ) -> None: 98 | """Test the ability to get systems attached to a v2 account. 99 | 100 | Args: 101 | aresponses: An aresponses server. 102 | authenticated_simplisafe_server_v2: A authenticated API connection. 103 | """ 104 | async with authenticated_simplisafe_server_v2, aiohttp.ClientSession() as session: 105 | simplisafe = await API.async_from_auth( 106 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 107 | ) 108 | systems = await simplisafe.async_get_systems() 109 | assert len(systems) == 1 110 | 111 | system = systems[TEST_SYSTEM_ID] 112 | assert system.serial == TEST_SYSTEM_SERIAL_NO 113 | assert system.system_id == TEST_SYSTEM_ID 114 | assert len(system.sensors) == 35 115 | 116 | aresponses.assert_plan_strictly_followed() 117 | 118 | 119 | @pytest.mark.asyncio 120 | async def test_set_pin( 121 | aresponses: ResponsesMockServer, 122 | authenticated_simplisafe_server_v2: ResponsesMockServer, 123 | v2_pins_response: dict[str, Any], 124 | v2_settings_response: dict[str, Any], 125 | ) -> None: 126 | """Test setting a PIN in a V2 system. 127 | 128 | Args: 129 | aresponses: An aresponses server. 130 | authenticated_simplisafe_server_v2: A authenticated API connection. 131 | v2_pins_response: An API response payload. 132 | v2_settings_response: An API response payload. 133 | """ 134 | async with authenticated_simplisafe_server_v2: 135 | authenticated_simplisafe_server_v2.add( 136 | "api.simplisafe.com", 137 | f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/pins", 138 | "get", 139 | response=aiohttp.web_response.json_response(v2_pins_response, status=200), 140 | ) 141 | authenticated_simplisafe_server_v2.add( 142 | "api.simplisafe.com", 143 | f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/pins", 144 | "get", 145 | response=aiohttp.web_response.json_response(v2_pins_response, status=200), 146 | ) 147 | authenticated_simplisafe_server_v2.add( 148 | "api.simplisafe.com", 149 | f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/pins", 150 | "post", 151 | response=aiohttp.web_response.json_response( 152 | v2_settings_response, status=200 153 | ), 154 | ) 155 | 156 | v2_pins_response["pins"]["pin4"]["value"] = "1275" 157 | v2_pins_response["pins"]["pin4"]["name"] = "whatever" 158 | 159 | authenticated_simplisafe_server_v2.add( 160 | "api.simplisafe.com", 161 | f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/pins", 162 | "get", 163 | response=aiohttp.web_response.json_response(v2_pins_response, status=200), 164 | ) 165 | 166 | async with aiohttp.ClientSession() as session: 167 | simplisafe = await API.async_from_auth( 168 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 169 | ) 170 | systems = await simplisafe.async_get_systems() 171 | system = systems[TEST_SYSTEM_ID] 172 | 173 | latest_pins = await system.async_get_pins() 174 | assert len(latest_pins) == 4 175 | 176 | await system.async_set_pin("whatever", "1275") 177 | new_pins = await system.async_get_pins() 178 | assert len(new_pins) == 5 179 | 180 | aresponses.assert_plan_strictly_followed() 181 | 182 | 183 | @pytest.mark.asyncio 184 | async def test_set_states( 185 | aresponses: ResponsesMockServer, 186 | authenticated_simplisafe_server_v2: ResponsesMockServer, 187 | v2_state_response: dict[str, Any], 188 | ) -> None: 189 | """Test the ability to set the state of a v2 system. 190 | 191 | Args: 192 | aresponses: An aresponses server. 193 | authenticated_simplisafe_server_v2: A authenticated API connection. 194 | v2_state_response: An API response payload. 195 | """ 196 | async with authenticated_simplisafe_server_v2: 197 | v2_state_response["requestedState"] = "away" 198 | 199 | authenticated_simplisafe_server_v2.add( 200 | "api.simplisafe.com", 201 | f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/state", 202 | "post", 203 | response=aiohttp.web_response.json_response(v2_state_response, status=200), 204 | ) 205 | 206 | v2_state_response["requestedState"] = "home" 207 | 208 | authenticated_simplisafe_server_v2.add( 209 | "api.simplisafe.com", 210 | f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/state", 211 | "post", 212 | response=aiohttp.web_response.json_response(v2_state_response, status=200), 213 | ) 214 | 215 | v2_state_response["requestedState"] = "off" 216 | 217 | authenticated_simplisafe_server_v2.add( 218 | "api.simplisafe.com", 219 | f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/state", 220 | "post", 221 | response=aiohttp.web_response.json_response(v2_state_response, status=200), 222 | ) 223 | 224 | async with aiohttp.ClientSession() as session: 225 | simplisafe = await API.async_from_auth( 226 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 227 | ) 228 | systems = await simplisafe.async_get_systems() 229 | system = systems[TEST_SYSTEM_ID] 230 | 231 | await system.async_set_away() 232 | state = system.state 233 | assert state == SystemStates.AWAY 234 | await system.async_set_home() 235 | state = system.state 236 | assert state == SystemStates.HOME 237 | await system.async_set_off() 238 | state = system.state 239 | assert state == SystemStates.OFF 240 | 241 | aresponses.assert_plan_strictly_followed() 242 | 243 | 244 | @pytest.mark.asyncio 245 | async def test_update_system_data( 246 | aresponses: ResponsesMockServer, 247 | authenticated_simplisafe_server_v2: ResponsesMockServer, 248 | v2_settings_response: dict[str, Any], 249 | v2_subscriptions_response: dict[str, Any], 250 | ) -> None: 251 | """Test getting updated data for a v2 system. 252 | 253 | Args: 254 | aresponses: An aresponses server. 255 | authenticated_simplisafe_server_v2: A authenticated API connection. 256 | v2_settings_response: An API response payload. 257 | v2_subscriptions_response: An API response payload. 258 | """ 259 | async with authenticated_simplisafe_server_v2: 260 | authenticated_simplisafe_server_v2.add( 261 | "api.simplisafe.com", 262 | f"/v1/users/{TEST_SUBSCRIPTION_ID}/subscriptions", 263 | "get", 264 | response=aiohttp.web_response.json_response( 265 | v2_subscriptions_response, status=200 266 | ), 267 | ) 268 | authenticated_simplisafe_server_v2.add( 269 | "api.simplisafe.com", 270 | f"/v1/subscriptions/{TEST_SUBSCRIPTION_ID}/settings", 271 | "get", 272 | response=aiohttp.web_response.json_response( 273 | v2_settings_response, status=200 274 | ), 275 | ) 276 | 277 | async with aiohttp.ClientSession() as session: 278 | simplisafe = await API.async_from_auth( 279 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 280 | ) 281 | systems = await simplisafe.async_get_systems() 282 | system = systems[TEST_SYSTEM_ID] 283 | assert system.serial == TEST_SYSTEM_SERIAL_NO 284 | assert system.system_id == TEST_SYSTEM_ID 285 | assert len(system.sensors) == 35 286 | 287 | # If this succeeds without throwing an exception, the update is successful: 288 | await system.async_update() 289 | 290 | aresponses.assert_plan_strictly_followed() 291 | -------------------------------------------------------------------------------- /tests/test_camera.py: -------------------------------------------------------------------------------- 1 | """Define tests for the Camera objects.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import cast 6 | 7 | import aiohttp 8 | import pytest 9 | from aresponses import ResponsesMockServer 10 | 11 | from simplipy import API 12 | from simplipy.device.camera import CameraTypes 13 | from simplipy.system.v3 import SystemV3 14 | 15 | from .common import ( 16 | TEST_AUTHORIZATION_CODE, 17 | TEST_CAMERA_ID, 18 | TEST_CAMERA_ID_2, 19 | TEST_CODE_VERIFIER, 20 | TEST_SYSTEM_ID, 21 | ) 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_properties( 26 | aresponses: ResponsesMockServer, 27 | authenticated_simplisafe_server_v3: ResponsesMockServer, 28 | ) -> None: 29 | """Test that camera properties are created properly. 30 | 31 | Args: 32 | aresponses: An aresponses server. 33 | authenticated_simplisafe_server_v3: A authenticated API connection. 34 | """ 35 | async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: 36 | simplisafe = await API.async_from_auth( 37 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 38 | ) 39 | 40 | systems = await simplisafe.async_get_systems() 41 | system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) 42 | camera = system.cameras[TEST_CAMERA_ID] 43 | assert camera.name == "Camera" 44 | assert camera.serial == TEST_CAMERA_ID 45 | assert camera.camera_settings["cameraName"] == "Camera" 46 | assert camera.status == "online" 47 | assert camera.subscription_enabled 48 | assert not camera.shutter_open_when_off 49 | assert not camera.shutter_open_when_home 50 | assert camera.shutter_open_when_away 51 | assert camera.camera_type == CameraTypes.CAMERA 52 | 53 | error_camera = system.cameras[TEST_CAMERA_ID_2] 54 | assert error_camera.camera_type == CameraTypes.UNKNOWN 55 | 56 | aresponses.assert_plan_strictly_followed() 57 | 58 | 59 | @pytest.mark.asyncio 60 | async def test_video_urls( 61 | aresponses: ResponsesMockServer, 62 | authenticated_simplisafe_server_v3: ResponsesMockServer, 63 | ) -> None: 64 | """Test that camera video URL is configured properly. 65 | 66 | Args: 67 | aresponses: An aresponses server. 68 | authenticated_simplisafe_server_v3: A authenticated API connection. 69 | """ 70 | async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: 71 | simplisafe = await API.async_from_auth( 72 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 73 | ) 74 | 75 | systems = await simplisafe.async_get_systems() 76 | system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) 77 | camera = system.cameras[TEST_CAMERA_ID] 78 | assert camera.video_url() == ( 79 | f"https://media.simplisafe.com/v1/{TEST_CAMERA_ID}/flv?x=1280&" 80 | "audioEncoding=AAC" 81 | ) 82 | assert camera.video_url(width=720) == ( 83 | f"https://media.simplisafe.com/v1/{TEST_CAMERA_ID}/flv?x=720" 84 | "&audioEncoding=AAC" 85 | ) 86 | assert camera.video_url(width=720, audio_encoding="OPUS") == ( 87 | f"https://media.simplisafe.com/v1/{TEST_CAMERA_ID}/flv?x=720&" 88 | "audioEncoding=OPUS" 89 | ) 90 | assert camera.video_url(audio_encoding="OPUS") == ( 91 | f"https://media.simplisafe.com/v1/{TEST_CAMERA_ID}/flv?x=1280&" 92 | "audioEncoding=OPUS" 93 | ) 94 | assert camera.video_url(additional_param="1") == ( 95 | f"https://media.simplisafe.com/v1/{TEST_CAMERA_ID}/flv?x=1280&" 96 | "audioEncoding=AAC&additional_param=1" 97 | ) 98 | 99 | aresponses.assert_plan_strictly_followed() 100 | -------------------------------------------------------------------------------- /tests/test_lock.py: -------------------------------------------------------------------------------- 1 | """Define tests for the Lock objects.""" 2 | 3 | # pylint: disable=protected-access 4 | from __future__ import annotations 5 | 6 | from datetime import timedelta 7 | from typing import Any, cast 8 | from unittest.mock import Mock 9 | 10 | import aiohttp 11 | import pytest 12 | from aresponses import ResponsesMockServer 13 | 14 | from simplipy import API 15 | from simplipy.device.lock import LockStates 16 | from simplipy.errors import InvalidCredentialsError 17 | from simplipy.system.v3 import SystemV3 18 | from simplipy.util.dt import utcnow 19 | 20 | from .common import ( 21 | TEST_AUTHORIZATION_CODE, 22 | TEST_CODE_VERIFIER, 23 | TEST_LOCK_ID, 24 | TEST_LOCK_ID_2, 25 | TEST_LOCK_ID_3, 26 | TEST_SUBSCRIPTION_ID, 27 | TEST_SYSTEM_ID, 28 | ) 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_lock_unlock( 33 | aresponses: ResponsesMockServer, 34 | authenticated_simplisafe_server_v3: ResponsesMockServer, 35 | ) -> None: 36 | """Test locking and unlocking the lock. 37 | 38 | Args: 39 | aresponses: An aresponses server. 40 | authenticated_simplisafe_server_v3: A authenticated API connection. 41 | """ 42 | async with authenticated_simplisafe_server_v3: 43 | authenticated_simplisafe_server_v3.add( 44 | "api.simplisafe.com", 45 | f"/v1/doorlock/{TEST_SUBSCRIPTION_ID}/{TEST_LOCK_ID}/state", 46 | "post", 47 | response=aresponses.Response(text=None, status=200), 48 | ) 49 | 50 | authenticated_simplisafe_server_v3.add( 51 | "api.simplisafe.com", 52 | f"/v1/doorlock/{TEST_SUBSCRIPTION_ID}/{TEST_LOCK_ID}/state", 53 | "post", 54 | response=aresponses.Response(text=None, status=200), 55 | ) 56 | 57 | authenticated_simplisafe_server_v3.add( 58 | "api.simplisafe.com", 59 | f"/v1/doorlock/{TEST_SUBSCRIPTION_ID}/{TEST_LOCK_ID}/state", 60 | "post", 61 | response=aresponses.Response(text=None, status=200), 62 | ) 63 | 64 | async with aiohttp.ClientSession() as session: 65 | simplisafe = await API.async_from_auth( 66 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 67 | ) 68 | systems = await simplisafe.async_get_systems() 69 | system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) 70 | lock = system.locks[TEST_LOCK_ID] 71 | 72 | state = lock.state 73 | assert state == LockStates.LOCKED 74 | await lock.async_unlock() 75 | state = lock.state 76 | assert state == LockStates.UNLOCKED 77 | await lock.async_lock() 78 | state = lock.state 79 | assert state == LockStates.LOCKED 80 | 81 | aresponses.assert_plan_strictly_followed() 82 | 83 | 84 | @pytest.mark.asyncio 85 | async def test_jammed( 86 | aresponses: ResponsesMockServer, 87 | authenticated_simplisafe_server_v3: ResponsesMockServer, 88 | ) -> None: 89 | """Test that a jammed lock shows the correct state. 90 | 91 | Args: 92 | aresponses: An aresponses server. 93 | authenticated_simplisafe_server_v3: A authenticated API connection. 94 | """ 95 | async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: 96 | simplisafe = await API.async_from_auth( 97 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 98 | ) 99 | systems = await simplisafe.async_get_systems() 100 | system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) 101 | lock = system.locks[TEST_LOCK_ID_2] 102 | assert lock.state is LockStates.JAMMED 103 | 104 | aresponses.assert_plan_strictly_followed() 105 | 106 | 107 | @pytest.mark.asyncio 108 | async def test_no_state_change_on_failure( 109 | aresponses: ResponsesMockServer, 110 | authenticated_simplisafe_server_v3: ResponsesMockServer, 111 | invalid_refresh_token_response: dict[str, Any], 112 | ) -> None: 113 | """Test that the lock doesn't change state on error. 114 | 115 | Args: 116 | aresponses: An aresponses server. 117 | authenticated_simplisafe_server_v3: A authenticated API connection. 118 | invalid_refresh_token_response: An API response payload. 119 | """ 120 | async with authenticated_simplisafe_server_v3: 121 | authenticated_simplisafe_server_v3.add( 122 | "api.simplisafe.com", 123 | f"/v1/doorlock/{TEST_SUBSCRIPTION_ID}/{TEST_LOCK_ID}/state", 124 | "post", 125 | response=aresponses.Response(text="Unauthorized", status=401), 126 | ) 127 | authenticated_simplisafe_server_v3.add( 128 | "auth.simplisafe.com", 129 | "/oauth/token", 130 | "post", 131 | response=aiohttp.web_response.json_response( 132 | invalid_refresh_token_response, status=401 133 | ), 134 | ) 135 | 136 | async with aiohttp.ClientSession() as session: 137 | simplisafe = await API.async_from_auth( 138 | TEST_AUTHORIZATION_CODE, 139 | TEST_CODE_VERIFIER, 140 | session=session, 141 | ) 142 | 143 | # Manually set the expiration datetime to force a refresh token flow: 144 | simplisafe._token_last_refreshed = utcnow() - timedelta(seconds=30) 145 | 146 | systems = await simplisafe.async_get_systems() 147 | system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) 148 | lock = system.locks[TEST_LOCK_ID] 149 | assert lock.state == LockStates.LOCKED 150 | 151 | with pytest.raises(InvalidCredentialsError): 152 | await lock.async_unlock() 153 | assert lock.state == LockStates.LOCKED 154 | 155 | aresponses.assert_plan_strictly_followed() 156 | 157 | 158 | @pytest.mark.asyncio 159 | async def test_properties( 160 | aresponses: ResponsesMockServer, 161 | authenticated_simplisafe_server_v3: ResponsesMockServer, 162 | ) -> None: 163 | """Test that lock properties are created properly. 164 | 165 | Args: 166 | aresponses: An aresponses server. 167 | authenticated_simplisafe_server_v3: A authenticated API connection. 168 | """ 169 | async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: 170 | simplisafe = await API.async_from_auth( 171 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 172 | ) 173 | systems = await simplisafe.async_get_systems() 174 | system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) 175 | lock = system.locks[TEST_LOCK_ID] 176 | assert not lock.disabled 177 | assert not lock.error 178 | assert not lock.lock_low_battery 179 | assert not lock.low_battery 180 | assert not lock.offline 181 | assert not lock.pin_pad_low_battery 182 | assert not lock.pin_pad_offline 183 | assert lock.state is LockStates.LOCKED 184 | 185 | aresponses.assert_plan_strictly_followed() 186 | 187 | 188 | @pytest.mark.asyncio 189 | async def test_unknown_state( 190 | aresponses: ResponsesMockServer, 191 | authenticated_simplisafe_server_v3: ResponsesMockServer, 192 | caplog: Mock, 193 | ) -> None: 194 | """Test handling a generic error during update. 195 | 196 | Args: 197 | aresponses: An aresponses server. 198 | authenticated_simplisafe_server_v3: A authenticated API connection. 199 | caplog: A mocked logging utility. 200 | """ 201 | async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: 202 | simplisafe = await API.async_from_auth( 203 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 204 | ) 205 | systems = await simplisafe.async_get_systems() 206 | system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) 207 | lock = system.locks[TEST_LOCK_ID_3] 208 | assert lock.state == LockStates.UNKNOWN 209 | 210 | assert any("Unknown raw lock state" in e.message for e in caplog.records) 211 | 212 | aresponses.assert_plan_strictly_followed() 213 | 214 | 215 | @pytest.mark.asyncio 216 | async def test_update( 217 | aresponses: ResponsesMockServer, 218 | authenticated_simplisafe_server_v3: ResponsesMockServer, 219 | v3_sensors_response: dict[str, Any], 220 | ) -> None: 221 | """Test updating the lock. 222 | 223 | Args: 224 | aresponses: An aresponses server. 225 | authenticated_simplisafe_server_v3: A authenticated API connection. 226 | v3_sensors_response: An API response payload. 227 | """ 228 | async with authenticated_simplisafe_server_v3: 229 | authenticated_simplisafe_server_v3.add( 230 | "api.simplisafe.com", 231 | f"/v1/doorlock/{TEST_SUBSCRIPTION_ID}/{TEST_LOCK_ID}/state", 232 | "post", 233 | response=aresponses.Response(text=None, status=200), 234 | ) 235 | authenticated_simplisafe_server_v3.add( 236 | "api.simplisafe.com", 237 | f"/v1/doorlock/{TEST_SUBSCRIPTION_ID}/{TEST_LOCK_ID}/state", 238 | "post", 239 | response=aresponses.Response(text=None, status=200), 240 | ) 241 | authenticated_simplisafe_server_v3.add( 242 | "api.simplisafe.com", 243 | f"/v1/ss3/subscriptions/{TEST_SUBSCRIPTION_ID}/sensors", 244 | "get", 245 | response=aiohttp.web_response.json_response( 246 | v3_sensors_response, status=200 247 | ), 248 | ) 249 | authenticated_simplisafe_server_v3.add( 250 | "api.simplisafe.com", 251 | f"/v1/doorlock/{TEST_SUBSCRIPTION_ID}/{TEST_LOCK_ID}/state", 252 | "post", 253 | response=aresponses.Response(text=None, status=200), 254 | ) 255 | 256 | async with aiohttp.ClientSession() as session: 257 | simplisafe = await API.async_from_auth( 258 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 259 | ) 260 | systems = await simplisafe.async_get_systems() 261 | system: SystemV3 = cast(SystemV3, systems[TEST_SYSTEM_ID]) 262 | lock = system.locks[TEST_LOCK_ID] 263 | 264 | state = lock.state 265 | assert state == LockStates.LOCKED 266 | await lock.async_unlock() 267 | state = lock.state 268 | assert state == LockStates.UNLOCKED 269 | await lock.async_update() 270 | state = lock.state 271 | assert state == LockStates.LOCKED 272 | 273 | aresponses.assert_plan_strictly_followed() 274 | -------------------------------------------------------------------------------- /tests/test_media.py: -------------------------------------------------------------------------------- 1 | """Define tests for motion detection media fetching.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | import aiohttp 8 | import pytest 9 | from aresponses import ResponsesMockServer 10 | 11 | from simplipy import API 12 | from simplipy.errors import SimplipyError 13 | 14 | from .common import TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER 15 | 16 | # Used in a testcase that counts requests to simulate a delayed 17 | # media fetch 18 | COUNT = 0 # pylint: disable=global-statement 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_media_file_fetching( 23 | aresponses: ResponsesMockServer, 24 | authenticated_simplisafe_server_v3: ResponsesMockServer, 25 | ) -> None: 26 | """Test the media fetching method.""" 27 | 28 | my_string = "this is an image" 29 | content = my_string.encode("utf-8") 30 | 31 | authenticated_simplisafe_server_v3.add( 32 | "remix.us-east-1.prd.cam.simplisafe.com", 33 | "/v1/preview/normal", 34 | "get", 35 | aresponses.Response(body=content, status=200), 36 | ) 37 | 38 | authenticated_simplisafe_server_v3.add( 39 | "remix.us-east-1.prd.cam.simplisafe.com", 40 | "/v1/preview/timeout", 41 | "get", 42 | aresponses.Response(status=404), 43 | repeat=5, 44 | ) 45 | 46 | # pylint: disable-next=unused-argument 47 | def delayed(request: Any) -> aresponses.Response: 48 | """Return a 404 a few times, then a 200.""" 49 | global COUNT # pylint: disable=global-statement 50 | if COUNT >= 3: 51 | return aresponses.Response(body=content, status=200) 52 | COUNT = COUNT + 1 53 | return aresponses.Response(status=404) 54 | 55 | authenticated_simplisafe_server_v3.add( 56 | "remix.us-east-1.prd.cam.simplisafe.com", 57 | "/v1/preview/delayed", 58 | "get", 59 | response=delayed, 60 | repeat=5, 61 | ) 62 | 63 | async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: 64 | simplisafe = await API.async_from_auth( 65 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 66 | ) 67 | 68 | # simple fetch 69 | res = await simplisafe.async_media( 70 | url="https://remix.us-east-1.prd.cam.simplisafe.com/v1/preview/normal" 71 | ) 72 | assert res == content 73 | 74 | # timeout with error 75 | with pytest.raises(SimplipyError): 76 | await simplisafe.async_media( 77 | url="https://remix.us-east-1.prd.cam.simplisafe.com/v1/preview/timeout" 78 | ) 79 | 80 | # test retries 81 | res = await simplisafe.async_media( 82 | url="https://remix.us-east-1.prd.cam.simplisafe.com/v1/preview/delayed" 83 | ) 84 | assert res == content 85 | 86 | aresponses.assert_plan_strictly_followed() 87 | 88 | 89 | @pytest.mark.asyncio 90 | async def test_media_file_enabe_disable_retires( 91 | aresponses: ResponsesMockServer, 92 | authenticated_simplisafe_server_v3: ResponsesMockServer, 93 | ) -> None: 94 | """Test the ability to enable/disable retries.""" 95 | 96 | my_string = "this is an image" 97 | content = my_string.encode("utf-8") 98 | 99 | authenticated_simplisafe_server_v3.add( 100 | "remix.us-east-1.prd.cam.simplisafe.com", 101 | "/v1/preview/timeout", 102 | "get", 103 | aresponses.Response(status=404), 104 | repeat=2, 105 | ) 106 | 107 | authenticated_simplisafe_server_v3.add( 108 | "remix.us-east-1.prd.cam.simplisafe.com", 109 | "/v1/preview/timeout", 110 | "get", 111 | aresponses.Response(body=content, status=200), 112 | repeat=1, 113 | ) 114 | 115 | async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: 116 | simplisafe = await API.async_from_auth( 117 | TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session 118 | ) 119 | 120 | # With retries disabled, the first attempt will raise an error 121 | simplisafe.disable_request_retries() 122 | with pytest.raises(SimplipyError): 123 | await simplisafe.async_media( 124 | url="https://remix.us-east-1.prd.cam.simplisafe.com/v1/preview/timeout" 125 | ) 126 | 127 | # When re-enabled, there will be one 404 followed by a 200, no error 128 | simplisafe.enable_request_retries() 129 | res = await simplisafe.async_media( 130 | url="https://remix.us-east-1.prd.cam.simplisafe.com/v1/preview/timeout" 131 | ) 132 | assert res == content 133 | 134 | aresponses.assert_plan_strictly_followed() 135 | -------------------------------------------------------------------------------- /tests/test_websocket.py: -------------------------------------------------------------------------------- 1 | """Define tests for the System object.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | from collections import deque 8 | from datetime import datetime, timedelta, timezone 9 | from time import time 10 | from typing import Any 11 | from unittest.mock import AsyncMock, Mock 12 | 13 | import pytest 14 | from aiohttp.client_exceptions import ( 15 | ClientError, 16 | ServerDisconnectedError, 17 | WSServerHandshakeError, 18 | ) 19 | from aiohttp.client_reqrep import ClientResponse, RequestInfo 20 | from aiohttp.http_websocket import WSMsgType 21 | 22 | from simplipy.const import LOGGER 23 | from simplipy.device import DeviceTypes 24 | from simplipy.errors import ( 25 | CannotConnectError, 26 | ConnectionFailedError, 27 | InvalidMessageError, 28 | WebsocketError, 29 | ) 30 | from simplipy.websocket import ( 31 | EVENT_DISARMED_BY_KEYPAD, 32 | Watchdog, 33 | WebsocketClient, 34 | websocket_event_from_payload, 35 | ) 36 | 37 | from .common import create_ws_message 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_callbacks( 42 | caplog: Mock, mock_api: Mock, ws_message_event: dict[str, Any], ws_messages: deque 43 | ) -> None: 44 | """Test that callbacks are executed correctly. 45 | 46 | Args: 47 | caplog: A mocked logging utility. 48 | mock_api: A mocked API client. 49 | ws_message_event: A websocket event payload. 50 | ws_messages: A queue. 51 | """ 52 | caplog.set_level(logging.INFO) 53 | 54 | mock_connect_callback = Mock() 55 | mock_disconnect_callback = Mock() 56 | mock_event_callback = Mock() 57 | 58 | async def async_mock_connect_callback() -> None: 59 | """Define a mock async connect callback.""" 60 | LOGGER.info("We are connected!") 61 | 62 | client = WebsocketClient(mock_api) 63 | client.add_connect_callback(mock_connect_callback) 64 | client.add_connect_callback(async_mock_connect_callback) 65 | client.add_disconnect_callback(mock_disconnect_callback) 66 | client.add_event_callback(mock_event_callback) 67 | 68 | assert mock_connect_callback.call_count == 0 69 | assert mock_disconnect_callback.call_count == 0 70 | assert mock_event_callback.call_count == 0 71 | 72 | await client.async_connect() 73 | assert client.connected 74 | await asyncio.sleep(1) 75 | assert mock_connect_callback.call_count == 1 76 | assert any("We are connected!" in e.message for e in caplog.records) 77 | 78 | ws_messages.append(create_ws_message(ws_message_event)) 79 | await client.async_listen() 80 | await asyncio.sleep(1) 81 | expected_event = websocket_event_from_payload(ws_message_event) 82 | mock_event_callback.assert_called_once_with(expected_event) 83 | 84 | await client.async_disconnect() 85 | assert not client.connected 86 | 87 | 88 | @pytest.mark.asyncio 89 | @pytest.mark.parametrize( 90 | "error", 91 | [ 92 | ClientError, 93 | ServerDisconnectedError, 94 | WSServerHandshakeError(Mock(RequestInfo), (Mock(ClientResponse),)), 95 | ], 96 | ) 97 | async def test_cannot_connect( 98 | error: BaseException, mock_api: Mock, ws_client_session: AsyncMock 99 | ) -> None: 100 | """Test being unable to connect to the websocket. 101 | 102 | Args: 103 | error: The error to raise. 104 | mock_api: A mocked API client. 105 | ws_client_session: A mocked websocket client session. 106 | """ 107 | ws_client_session.ws_connect.side_effect = error 108 | client = WebsocketClient(mock_api) 109 | 110 | with pytest.raises(CannotConnectError): 111 | await client.async_connect() 112 | 113 | assert not client.connected 114 | 115 | 116 | @pytest.mark.asyncio 117 | async def test_connect_disconnect(mock_api: Mock) -> None: 118 | """Test connecting and disconnecting the client. 119 | 120 | Args: 121 | mock_api: A mocked API client. 122 | """ 123 | client = WebsocketClient(mock_api) 124 | 125 | await client.async_connect() 126 | assert client.connected 127 | 128 | # Attempt to connect again, which should just return: 129 | await client.async_connect() 130 | 131 | await client.async_disconnect() 132 | assert not client.connected 133 | 134 | 135 | def test_create_event(ws_message_event: dict[str, Any]) -> None: 136 | """Test creating an event object. 137 | 138 | Args: 139 | ws_message_event: A websocket event payload. 140 | """ 141 | event = websocket_event_from_payload(ws_message_event) 142 | assert event.event_type == EVENT_DISARMED_BY_KEYPAD 143 | assert event.info == "System Disarmed by Master PIN" 144 | assert event.system_id == 12345 145 | assert event.timestamp == datetime(2021, 9, 29, 23, 14, 46, tzinfo=timezone.utc) 146 | assert event.changed_by == "Master PIN" 147 | assert event.sensor_name == "" 148 | assert event.sensor_serial == "abcdef12" 149 | assert event.sensor_type == DeviceTypes.KEYPAD 150 | assert event.media_urls is None 151 | 152 | 153 | def test_create_motion_event(ws_motion_event: dict[str, Any]) -> None: 154 | """Test creating a motion event object. 155 | 156 | Args: 157 | ws_motion_event: A websocket motion event payload with media urls. 158 | """ 159 | event = websocket_event_from_payload(ws_motion_event) 160 | assert event.media_urls is not None 161 | assert event.media_urls["image_url"] == "https://image-url{&width}" 162 | assert event.media_urls["clip_url"] == "https://clip-url" 163 | assert event.media_urls["hls_url"] == "https://hls-url" 164 | 165 | 166 | @pytest.mark.asyncio 167 | async def test_listen_invalid_message_data( 168 | mock_api: Mock, ws_message_event: dict[str, Any], ws_messages: deque 169 | ) -> None: 170 | """Test websocket message data that should raise on listen. 171 | 172 | Args: 173 | mock_api: A mocked API client. 174 | ws_message_event: A websocket event payload. 175 | ws_messages: A queue. 176 | """ 177 | client = WebsocketClient(mock_api) 178 | 179 | await client.async_connect() 180 | assert client.connected 181 | 182 | ws_message = create_ws_message(ws_message_event) 183 | ws_message.json.side_effect = ValueError("Boom") 184 | ws_messages.append(ws_message) 185 | 186 | with pytest.raises(InvalidMessageError): 187 | await client.async_listen() 188 | 189 | 190 | @pytest.mark.asyncio 191 | async def test_listen(mock_api: Mock) -> None: 192 | """Test listening to the websocket server. 193 | 194 | Args: 195 | mock_api: A mocked API client. 196 | """ 197 | client = WebsocketClient(mock_api) 198 | 199 | await client.async_connect() 200 | assert client.connected 201 | 202 | # If this succeeds without throwing an exception, listening was successful: 203 | asyncio.create_task(client.async_listen()) 204 | 205 | await client.async_disconnect() 206 | assert not client.connected 207 | 208 | 209 | @pytest.mark.asyncio 210 | @pytest.mark.parametrize( 211 | "message_type", [WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING] 212 | ) 213 | async def test_listen_disconnect_message_types( 214 | message_type: WSMsgType, 215 | mock_api: Mock, 216 | ws_client: AsyncMock, 217 | ws_message_event: dict[str, Any], 218 | ws_messages: deque, 219 | ) -> None: 220 | """Test different websocket message types that stop listen. 221 | 222 | Args: 223 | message_type: The message type from the websocket. 224 | mock_api: A mocked API client. 225 | ws_client: A mocked websocket client. 226 | ws_message_event: A websocket event payload. 227 | ws_messages: A queue. 228 | """ 229 | client = WebsocketClient(mock_api) 230 | 231 | await client.async_connect() 232 | assert client.connected 233 | 234 | ws_message = create_ws_message(ws_message_event) 235 | ws_message.type = message_type 236 | ws_messages.append(ws_message) 237 | 238 | # This should break out of the listen loop before handling the received message; 239 | # otherwise there will be an error: 240 | await client.async_listen() 241 | 242 | # Assert that we received a message: 243 | ws_client.receive.assert_awaited() 244 | 245 | 246 | @pytest.mark.asyncio 247 | @pytest.mark.parametrize( 248 | "message_type, exception", 249 | [ 250 | (WSMsgType.BINARY, InvalidMessageError), 251 | (WSMsgType.ERROR, ConnectionFailedError), 252 | ], 253 | ) 254 | async def test_listen_error_message_types( 255 | exception: WebsocketError, 256 | message_type: WSMsgType, 257 | mock_api: Mock, 258 | ws_message_event: dict[str, Any], 259 | ws_messages: deque, 260 | ) -> None: 261 | """Test different websocket message types that should raise on listen. 262 | 263 | Args: 264 | exception: The exception being raised. 265 | message_type: The message type from the websocket. 266 | mock_api: A mocked API client. 267 | ws_message_event: A websocket event payload. 268 | ws_messages: A queue. 269 | """ 270 | client = WebsocketClient(mock_api) 271 | 272 | await client.async_connect() 273 | assert client.connected 274 | 275 | ws_message = create_ws_message(ws_message_event) 276 | ws_message.type = message_type 277 | ws_messages.append(ws_message) 278 | 279 | with pytest.raises(exception): # type: ignore[call-overload] 280 | await client.async_listen() 281 | 282 | 283 | @pytest.mark.asyncio 284 | async def test_reconnect(mock_api: Mock) -> None: 285 | """Test reconnecting to the websocket. 286 | 287 | Args: 288 | mock_api: A mocked API client. 289 | """ 290 | client = WebsocketClient(mock_api) 291 | 292 | await client.async_connect() 293 | assert client.connected 294 | 295 | await client.async_reconnect() 296 | 297 | 298 | @pytest.mark.asyncio 299 | async def test_remove_callback_callback(mock_api: Mock) -> None: 300 | """Test that a removed callback doesn't get executed. 301 | 302 | Args: 303 | mock_api: A mocked API client. 304 | """ 305 | mock_callback = Mock() 306 | client = WebsocketClient(mock_api) 307 | remove = client.add_connect_callback(mock_callback) 308 | remove() 309 | 310 | await client.async_connect() 311 | assert client.connected 312 | assert mock_callback.call_count == 0 313 | 314 | await client.async_disconnect() 315 | assert not client.connected 316 | 317 | 318 | def test_unknown_event(caplog: Mock, ws_message_event: dict[str, Any]) -> None: 319 | """Test that an unknown event type is handled correctly. 320 | 321 | Args: 322 | caplog: A mocked logging utility. 323 | ws_message_event: A websocket event payload. 324 | """ 325 | ws_message_event["data"]["eventCid"] = 9999 326 | event = websocket_event_from_payload(ws_message_event) 327 | assert event.event_type is None 328 | assert any( 329 | "Encountered unknown websocket event type" in e.message for e in caplog.records 330 | ) 331 | 332 | 333 | def test_unknown_sensor_type_in_event( 334 | caplog: Mock, ws_message_event: dict[str, Any] 335 | ) -> None: 336 | """Test that an unknown sensor type in a websocket event is handled correctly. 337 | 338 | Args: 339 | caplog: A mocked logging utility. 340 | ws_message_event: A websocket event payload. 341 | """ 342 | ws_message_event["data"]["sensorType"] = 999 343 | event = websocket_event_from_payload(ws_message_event) 344 | assert event.sensor_type is None 345 | assert any("Encountered unknown device type" in e.message for e in caplog.records) 346 | 347 | 348 | @pytest.mark.asyncio 349 | async def test_watchdog_async_trigger(caplog: Mock) -> None: 350 | """Test that the watchdog works with a coroutine as a trigger. 351 | 352 | Args: 353 | caplog: A mocked logging utility. 354 | """ 355 | caplog.set_level(logging.INFO) 356 | 357 | async def mock_trigger() -> None: 358 | """Define a mock trigger.""" 359 | LOGGER.info("Triggered mock_trigger") 360 | 361 | watchdog = Watchdog(mock_trigger, timeout=timedelta(seconds=0)) 362 | watchdog.trigger() 363 | assert any("Websocket watchdog triggered" in e.message for e in caplog.records) 364 | 365 | await asyncio.sleep(1) 366 | assert any("Websocket watchdog expired" in e.message for e in caplog.records) 367 | assert any("Triggered mock_trigger" in e.message for e in caplog.records) 368 | 369 | 370 | @pytest.mark.asyncio 371 | async def test_watchdog_cancel(caplog: Mock) -> None: 372 | """Test that canceling the watchdog resets and stops it. 373 | 374 | Args: 375 | caplog: A mocked logging utility. 376 | """ 377 | caplog.set_level(logging.INFO) 378 | 379 | async def mock_trigger() -> None: 380 | """Define a mock trigger.""" 381 | LOGGER.info("Triggered mock_trigger") 382 | 383 | # We test this by ensuring that, although the watchdog has a 5-second timeout, 384 | # canceling it ensures that task is stopped: 385 | start = time() 386 | watchdog = Watchdog(mock_trigger, timeout=timedelta(seconds=5)) 387 | watchdog.trigger() 388 | await asyncio.sleep(1) 389 | watchdog.cancel() 390 | end = time() 391 | assert (end - start) < 5 392 | assert not any("Triggered mock_trigger" in e.message for e in caplog.records) 393 | 394 | 395 | @pytest.mark.asyncio 396 | async def test_watchdog_quick_trigger(caplog: Mock) -> None: 397 | """Test that quick triggering of the watchdog resets the timer task. 398 | 399 | Args: 400 | caplog: A mocked logging utility. 401 | """ 402 | caplog.set_level(logging.INFO) 403 | 404 | mock_trigger = Mock() 405 | 406 | watchdog = Watchdog(mock_trigger, timeout=timedelta(seconds=1)) 407 | watchdog.trigger() 408 | await asyncio.sleep(1) 409 | watchdog.trigger() 410 | await asyncio.sleep(1) 411 | assert mock_trigger.call_count == 2 412 | 413 | 414 | @pytest.mark.asyncio 415 | async def test_watchdog_sync_trigger(caplog: Mock) -> None: 416 | """Test that the watchdog works with a normal function as a trigger. 417 | 418 | Args: 419 | caplog: A mocked logging utility. 420 | """ 421 | caplog.set_level(logging.INFO) 422 | 423 | mock_trigger = Mock() 424 | 425 | watchdog = Watchdog(mock_trigger, timeout=timedelta(seconds=0)) 426 | watchdog.trigger() 427 | assert any("Websocket watchdog triggered" in e.message for e in caplog.records) 428 | 429 | await asyncio.sleep(1) 430 | assert any("Websocket watchdog expired" in e.message for e in caplog.records) 431 | assert mock_trigger.call_count == 1 432 | --------------------------------------------------------------------------------