├── .envrc ├── .github ├── dependabot.yml └── workflows │ ├── bandit.yml │ ├── pypi-publish.yml │ ├── python-package.yml │ └── python-publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── LICENSE.md ├── PROJECT.md ├── README.md ├── conftest.py ├── doc ├── adtlogo.png ├── adtpulse.png └── browser_fingerprint.html ├── example-client.json ├── example-client.py ├── poetry.lock ├── pyadtpulse ├── .vscode │ └── settings.json ├── __init__.py ├── alarm_panel.py ├── const.py ├── exceptions.py ├── fingerprint.json ├── gateway.py ├── pulse_authentication_properties.py ├── pulse_backoff.py ├── pulse_connection.py ├── pulse_connection_properties.py ├── pulse_connection_status.py ├── pulse_query_manager.py ├── pyadtpulse_async.py ├── pyadtpulse_properties.py ├── site.py ├── site_properties.py ├── util.py └── zones.py ├── pylintrc ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── data_files ├── device_1.html ├── device_10.html ├── device_11.html ├── device_16.html ├── device_2.html ├── device_24.html ├── device_25.html ├── device_26.html ├── device_27.html ├── device_28.html ├── device_29.html ├── device_3.html ├── device_30.html ├── device_34.html ├── device_69.html ├── device_70.html ├── gateway.html ├── mfa.html ├── not_signed_in.html ├── orb.html ├── orb_garage.html ├── orb_gateway_offline.html ├── orb_patio_garage.html ├── orb_patio_opened.html ├── signin.html ├── signin_fail.html ├── signin_locked.html ├── summary.html ├── summary_gateway_offline.html └── system.html ├── test_backoff.py ├── test_exceptions.py ├── test_gateway.py ├── test_paa_codium.py ├── test_pap.py ├── test_pqm_codium.py ├── test_pulse_async.py ├── test_pulse_connection.py ├── test_pulse_connection_properties.py ├── test_pulse_connection_status.py ├── test_pulse_query_manager.py ├── test_site_properties.py └── test_zones.py /.envrc: -------------------------------------------------------------------------------- 1 | if [ which runonce &> /dev/null ]; then 2 | DIR=`basename $(pwd)` 3 | 4 | # install any missing requirements 5 | if [ -d .venv ]; then 6 | if [ -f requirements.txt ]; then 7 | runonce -b -n $DIR uv pip install -r requirements.txt & 8 | fi 9 | 10 | if [ -f requirements-dev.txt ]; then 11 | runonce -b -n $DIR uv pip install -r requirements-dev.txt & 12 | fi 13 | fi 14 | 15 | # auto-update pre-commit versions (if > 1 week) 16 | runonce -b -n $DIR -d 7 pre-commit autoupdate & 17 | fi 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "13:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/bandit.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # Bandit is a security linter designed to find common security issues in Python code. 7 | # This action will run Bandit on your codebase. 8 | # The results of the scan will be found under the Security tab of your repository. 9 | 10 | # https://github.com/marketplace/actions/bandit-scan is ISC licensed, by abirismyname 11 | # https://pypi.org/project/bandit/ is Apache v2.0 licensed, by PyCQA 12 | 13 | name: Bandit 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '22 19 * * 3' 22 | 23 | jobs: 24 | bandit: 25 | permissions: 26 | contents: read # for actions/checkout to fetch code 27 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 28 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 29 | 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v2 33 | - name: Bandit Scan 34 | uses: shundor/python-bandit-scan@9cc5aa4a006482b8a7f91134412df6772dbda22c 35 | with: # optional arguments 36 | # exit with 0, even with results found 37 | exit_zero: true # optional, default is DEFAULT 38 | # Github token of the repository (automatically created by Github) 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information. 40 | # File or directory to run bandit on 41 | # path: # optional, default is . 42 | # Report only issues of a given severity level or higher. Can be LOW, MEDIUM or HIGH. Default is UNDEFINED (everything) 43 | # level: # optional, default is UNDEFINED 44 | # Report only issues of a given confidence level or higher. Can be LOW, MEDIUM or HIGH. Default is UNDEFINED (everything) 45 | # confidence: # optional, default is UNDEFINED 46 | # comma-separated list of paths (glob patterns supported) to exclude from scan (note that these are in addition to the excluded paths provided in the config file) (default: .svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.eggs,*.egg) 47 | # excluded_paths: # optional, default is DEFAULT 48 | # comma-separated list of test IDs to skip 49 | # skips: # optional, default is DEFAULT 50 | # path to a .bandit file that supplies command line arguments 51 | # ini_path: # optional, default is DEFAULT 52 | 53 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | # see https://github.com/marketplace/actions/publish-python-poetry-package 2 | 3 | name: Upload Release to PyPi 4 | 5 | on: 6 | release: 7 | types: [published] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | deploy: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Build and publish to PyPi 21 | uses: JRubics/poetry-publish@v2.1 22 | with: 23 | pypi_token: ${{ secrets.PYPI_API_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Test Multiple Python Versions 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.12", "3.13"] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v3 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | python -m pip install flake8 pytest 33 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 34 | if [ -f test-requirements.txt ]; then pip install -r test-requirements.txt; fi 35 | 36 | # Should be using Ruff 37 | # - name: Lint with flake8 38 | # run: | 39 | # # stop the build if there are Python syntax errors or undefined names 40 | # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 41 | # # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 42 | # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 43 | 44 | - name: Test with pytest 45 | run: | 46 | pytest 47 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.log 4 | *.egg-info 5 | 6 | dist 7 | build 8 | 9 | __pycache__ 10 | .mypy_cache 11 | *.swp 12 | .vscode/settings.json 13 | .coverage 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # pre-commit autoupdate 3 | # pre-commit run --all-files 4 | 5 | fail_fast: true 6 | 7 | repos: 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v5.0.0 10 | hooks: 11 | - id: trailing-whitespace 12 | - id: end-of-file-fixer 13 | - id: requirements-txt-fixer 14 | 15 | - repo: https://github.com/astral-sh/ruff-pre-commit 16 | rev: v0.11.12 17 | hooks: 18 | - id: ruff 19 | args: [ --fix ] # run linter 20 | - id: ruff-format # run formatter 21 | 22 | - repo: https://github.com/dosisod/refurb 23 | rev: v2.1.0 24 | hooks: 25 | - id: refurb 26 | 27 | - repo: https://github.com/asottile/pyupgrade 28 | rev: v3.20.0 29 | hooks: 30 | - id: pyupgrade 31 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Debug Tests", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "purpose": ["debug-test"], 13 | "console": "integratedTerminal", 14 | "justMyCode": false 15 | }, 16 | { 17 | "name": "Python: Current File", 18 | "type": "python", 19 | "request": "launch", 20 | "program": "${file}", 21 | "console": "integratedTerminal", 22 | "justMyCode": true 23 | }, 24 | { 25 | "name": "Python: Module", 26 | "type": "python", 27 | "request": "launch", 28 | "module": "example-client", 29 | "justMyCode": true, 30 | "args": [ 31 | "~/pulse.json" 32 | ] 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "basic", 3 | "python.terminal.activateEnvironment": true, 4 | "python.testing.pytestArgs": [ 5 | "tests" 6 | ], 7 | "python.testing.unittestEnabled": false, 8 | "python.testing.pytestEnabled": true 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | // .vscode/tasks.json 5 | { 6 | "version": "2.0.0", 7 | "tasks": [ 8 | { 9 | "label": "Run pytest with coverage", 10 | "type": "shell", 11 | "command": "pytest", 12 | "args": [ 13 | "--cov=pyadtpulse", 14 | "--cov-report=html", 15 | "${workspaceFolder}/tests" 16 | ], 17 | "group": { 18 | "kind": "test", 19 | "isDefault": false 20 | } 21 | }, 22 | { 23 | "label": "Run pytest without coverage", 24 | "type": "shell", 25 | "command": "pytest", 26 | "args": [ 27 | "${workspaceFolder}/tests" 28 | ], 29 | "group": { 30 | "kind": "test", 31 | "isDefault": true 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.2.9 (2024-04-21) 2 | 3 | * ignore query string in check_login_errors(). This should fix a bug where the task was logged out 4 | but not correctly being identified 5 | * remove unnecessary warning in alarm status check 6 | * add arm night 7 | * refactor update_alarm_from_etree() 8 | * bump to newer user agent 9 | * skip sync check if it will back off 10 | * fix linter issue in _initialize_sites 11 | 12 | ## 1.2.8 (2024-03-07) 13 | 14 | * add more detail to "invalid sync check" error logging 15 | * don't exit sync check task on service temporarily unavailable or invalid login 16 | * don't use empty site id for logins 17 | 18 | ## 1.2.7 (2024-02-23) 19 | 20 | * catch site is None on logout to prevent "have you logged in" errors 21 | * speed improvements via aiohttp-zlib-ng 22 | 23 | ## 1.2.6 (2024-02-23) 24 | 25 | Performance improvements including: 26 | 27 | * switch from BeautifulSoup to lxml for faster parsing 28 | * optimize zone parsing to only update zones which have changed 29 | * change wait_for_update() to pass the changed zones/alarm state to caller 30 | 31 | ## 1.2.5 (2024-02-10) 32 | 33 | * don't raise not logged in exception when sync check task logs out 34 | * change full logout interval to approximately every 6 hours 35 | 36 | ## 1.2.4 (2024-02-08) 37 | 38 | * change yarl dependencies 39 | 40 | ## 1.2.3 (2024-02-08) 41 | 42 | * change aiohttp dependencies 43 | 44 | ## 1.2.2 (2024-02-07) 45 | 46 | * add yarl as dependency 47 | 48 | ## 1.2.1 (2024-02-07) 49 | 50 | * add timing loggin for zone/site updates 51 | * do full logout once per day 52 | * have keepalive task wait for sync check task to sleep before logging out 53 | 54 | ## 1.2.0 (2024-01-30) 55 | 56 | * add exceptions and exception handling 57 | * make code more robust for error handling 58 | * refactor code into smaller objects 59 | * add testing framework 60 | * add poetry 61 | 62 | ## 1.1.5 (2023-12-22) 63 | 64 | * fix more zone html parsing due to changes in Pulse v27 65 | 66 | ## 1.1.4 (2023-12-13) 67 | 68 | * fix zone html parsing due to changes in Pulse v27 69 | 70 | ## 1.1.3 (2023-10-11) 71 | 72 | * revert sync check logic to check against last check value. this should hopefully fix the problem of HA alarm status not updating 73 | * use exponential backoff for gateway updates if offline instead of constant 90 seconds 74 | * add jitter to relogin interval 75 | * add quick_relogin/async_quick_relogin to do a quick relogin without requerying devices, exiting tasks 76 | * add more alarm testing in example client 77 | 78 | ## 1.1.2 (2023-10-06) 79 | 80 | * change default poll interval to 2 seconds 81 | * update pyproject.toml 82 | * change source location to github/rlippmann from github/rsnodgrass 83 | * fix gateway attributes not updating 84 | * remove dependency on python_dateutils 85 | * add timestamp to example-client logging 86 | 87 | ## 1.1.1 (2023-10-02) 88 | 89 | * pylint fixes 90 | * set min relogin interval 91 | * set max keepalive interval 92 | * remove poll_interval from pyADTPulse constructor 93 | * expose public methods in ADTPulseConnection object 94 | 95 | ## 1.1 (2023-09-20) 96 | 97 | * bug fixes 98 | * relogin support 99 | * device dataclasses 100 | 101 | ## 1.0 (2023-03-28) 102 | 103 | * async support 104 | * background refresh 105 | * bug fixes 106 | 107 | ## 0.1.0 (2019-12-16) 108 | 109 | * added ability to override the ADT API host (example: Canada endpoint portal-ca.adtpulse.com) 110 | 111 | ## 0.0.6 (2019-09-23) 112 | 113 | * bug fixes and improvements 114 | 115 | ## 0.0.1 (2019-09-19) 116 | 117 | * initial release with minimal error/failure handling 118 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Apache 2.0 License 2 | 3 | Copyright (c) 2019 Ryan Snodgrass 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | -------------------------------------------------------------------------------- /PROJECT.md: -------------------------------------------------------------------------------- 1 | # Project Developer Notes 2 | 3 | #### Distributed New Python Package to PyPi 4 | 5 | python3 setup.py bdist_wheel 6 | python3 -m twine upload dist/* 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyadtpulse - Python interface for ADT Pulse 2 | 3 | Python client interface to the ADT Pulse security system. 4 | 5 | [![PyPi](https://img.shields.io/pypi/v/pyadtpulse.svg)](https://pypi.python.org/pypi/pyadtpulse) 6 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 7 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=WREP29UDAMB6G) 8 | 9 | ## UNSUPPORTED 10 | 11 | **This is an unsupported interface provided only as a basis for others to explore integrating 12 | their ADT system wtih their own tools.** PLEASE FEEL FREE TO CONTRIBUTE CHANGES! Pull requests are accepted. 13 | 14 | While two or three Python clients to ADT Pulse existed, they generally only provided 15 | arm/disarm support and none provided support for ADT Pulse when multiple sites existed 16 | under a single account. This attempts to provide APIs to both all the zones (motion 17 | sensors, door sensors, etc) as well as arming and disarming individual sites. 18 | 19 | NOTE: Since this interacts with the unofficial ADT Pulse AJAX web service, the 20 | behavior is subject to change by ADT without notice. 21 | 22 | ## Developer Note 23 | 24 | NOTE: This package use [pre-commit](https://pre-commit.com/) hooks for maintaining code quality. 25 | Please install pre-commit and enable it for your local git copy before committing. 26 | 27 | ## WARNING 28 | 29 | Do not reauthenticate to the ADT service frequently as ADT's service is not designed for high volume requests. E.g. every 5 minutes, not seconds. Keep your authenticated session to avoid logging in repeatedly. 30 | 31 | ## Installation 32 | 33 | ``` 34 | pip3 install pyadtpulse 35 | ``` 36 | 37 | ## Usage 38 | 39 | Since ADT Pulse automatically logs out other sessions accessing the same account, a best practice is 40 | to **create a new username/password combination for each client** accessing ADT Pulse. 41 | 42 | Additionally, since pyadtpulse currently does not support multiple sites (premises/locations), a 43 | simple approach is to create a separate username/password for each site and configured such that 44 | the username only has access to ONE site. This ensures that clients are always interacting with 45 | that one site (and not accidentally with another site location). 46 | 47 | #### Notes 48 | 49 | - any changes to the name/count of sites are not automatically updated for existing site objects 50 | 51 | ## Examples 52 | 53 | ```python 54 | adt = PyADTPulse(username, password, fingerprint) 55 | 56 | for site in adt.sites: 57 | site.status 58 | site.zones 59 | 60 | site.disarm() 61 | site.arm_away() 62 | site.arm_away(force=True) 63 | ``` 64 | 65 | Async version (preferred for new integrations): 66 | 67 | ```python 68 | adt = PyADTPulse(username, password, fingerprint, do_login=false) 69 | 70 | await adt.async_login() 71 | 72 | for site in adt.sites: 73 | site.status 74 | site.zones 75 | 76 | await site.async_disarm() 77 | await site.async_arm_away() 78 | await site.async_arm_away(force=True) 79 | ``` 80 | 81 | The pyadtpulse object runs background tasks and refreshes its data automatically. 82 | 83 | Certain parameters can be set to control how often certain actions are run. 84 | 85 | Namely: 86 | 87 | ```python 88 | adt.poll_interval = 0.75 # check for updates every 0.75 seconds 89 | adt.relogin_interval = 60 # relogin every 60 minutes 90 | adt.keepalive_interval = 10 # run keepalive (prevent logout) every 10 minutes 91 | ``` 92 | 93 | See [example-client.py](example-client.py) for a working example. 94 | 95 | ## Browser Fingerprinting 96 | 97 | ADT Pulse requires 2 factor authentication to log into their site. When you perform the 2 factor authentication, you will see an option to save the browser to not have to re-authenticate through it. 98 | 99 | Internally, ADT uses some Javascript code to create a browser fingerprint. This (very long) string is used to check that the browser has been saved upon subsequent logins. It is the "fingerprint" parameter required to be passed in to the PyADTPulse object constructor. 100 | 101 | ### Notes: 102 | 103 | The browser fingerprint will change with a browser/OS upgrade. While it is not strictly necessary to create a separate username/password for logging in through pyadtpulse, it is recommended to do so. 104 | 105 | **Warning: If another connection is made to the Pulse portal with the same fingerprint, the first connection will be logged out. For this reason it is recommended to use a browser/machine you would not normally use to log into the Pulse web site to generate the fingerprint.** 106 | 107 | 108 | There are 2 ways to determine this fingerprint: 109 | 110 | 1. Visit [this link](https://rawcdn.githack.com/rlippmann/pyadtpulse/b3a0e7097e22446623d170f0a971726fbedb6a2d/doc/browser_fingerprint.html) using the same browser you used to authenticate with ADT Pulse. This should determine the correct browser fingerprint 111 | 112 | 2. Follow the instructions [here](https://github.com/mrjackyliang/homebridge-adt-pulse#configure-2-factor-authentication) 113 | 114 | ## See Also 115 | 116 | - [ADT Pulse Portal](https://portal.adtpulse.com/) 117 | - [Home Assistant ADT Pulse integration](https://github.com/rsnodgrass/hass-adtpulse/) 118 | - [adt-pulse-mqtt](https://github.com/haruny/adt-pulse-mqtt) – MQTT integration with ADT Pulse alarm panels 119 | 120 | ## Future Enhancements 121 | 122 | Feature ideas: 123 | 124 | - 2 factor authenciation 125 | - Cameras (via Janus) 126 | 127 | Feature ideas, but no plans to implement: 128 | 129 | - support OFFLINE status checking 130 | - support multiple sites (premises/locations) under a single ADT account 131 | ~~- implement lightweight status pings to check if cache needs to be invalidated (every 5 seconds) (https://portal.adtpulse.com/myhome/16.0.0-131/Ajax/SyncCheckServ?t=1568950496392)~~ 132 | - alarm history (/ajax/alarmHistory.jsp) 133 | -------------------------------------------------------------------------------- /doc/adtlogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsnodgrass/pyadtpulse/1eb2f608b72e80c4cad77be2beef110b813e58ae/doc/adtlogo.png -------------------------------------------------------------------------------- /doc/adtpulse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsnodgrass/pyadtpulse/1eb2f608b72e80c4cad77be2beef110b813e58ae/doc/adtpulse.png -------------------------------------------------------------------------------- /doc/browser_fingerprint.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | ADT Pulse Fingerprint 24 | 25 | 26 |

Pulse Browser Fingerprint Detector

27 |

For use with: 28 |

pyadtpulse

29 |

ADT Pulse for Home Assistant

30 |

ADT Pulse for Home Assistant with MQTT

31 |

ADT Pulse for Homebridge

32 |

Others?

33 |

34 |

Your browser fingerprint is:

35 |

36 |

37 | 38 | 39 | -------------------------------------------------------------------------------- /example-client.json: -------------------------------------------------------------------------------- 1 | { 2 | "sleep_interval": 5, 3 | "use_async": true, 4 | "test_alarm": false, 5 | "debug": false, 6 | "debug_locks": false, 7 | "adtpulse_user": "me@myisp.com", 8 | "adtpulse_password": "supersecretpassword", 9 | "adtpulse_fingerprint": "areallyreallyreallyreallyreallylongstring", 10 | "service_host": "https://portal.adtpulse.com", 11 | "relogin_interval": 60, 12 | "keepalive_interval": 10, 13 | "poll_interval": 0.9 14 | } 15 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pyadtpulse/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "basic" 3 | } 4 | -------------------------------------------------------------------------------- /pyadtpulse/__init__.py: -------------------------------------------------------------------------------- 1 | """Base Python Class for pyadtpulse.""" 2 | 3 | import logging 4 | import asyncio 5 | import time 6 | from threading import RLock, Thread 7 | from warnings import warn 8 | 9 | import aiohttp_zlib_ng 10 | import uvloop 11 | 12 | from .const import ( 13 | ADT_DEFAULT_HTTP_USER_AGENT, 14 | ADT_DEFAULT_KEEPALIVE_INTERVAL, 15 | ADT_DEFAULT_RELOGIN_INTERVAL, 16 | DEFAULT_API_HOST, 17 | ) 18 | from .pyadtpulse_async import SYNC_CHECK_TASK_NAME, PyADTPulseAsync 19 | from .util import DebugRLock, set_debug_lock 20 | 21 | aiohttp_zlib_ng.enable_zlib_ng() 22 | LOG = logging.getLogger(__name__) 23 | 24 | 25 | class PyADTPulse(PyADTPulseAsync): 26 | """Base object for ADT Pulse service.""" 27 | 28 | __slots__ = ("_session_thread", "_p_attribute_lock", "_login_exception") 29 | 30 | def __init__( 31 | self, 32 | username: str, 33 | password: str, 34 | fingerprint: str, 35 | service_host: str = DEFAULT_API_HOST, 36 | user_agent=ADT_DEFAULT_HTTP_USER_AGENT["User-Agent"], 37 | do_login: bool = True, 38 | debug_locks: bool = False, 39 | keepalive_interval: int = ADT_DEFAULT_KEEPALIVE_INTERVAL, 40 | relogin_interval: int = ADT_DEFAULT_RELOGIN_INTERVAL, 41 | detailed_debug_logging: bool = False, 42 | ): 43 | self._p_attribute_lock = set_debug_lock( 44 | debug_locks, "pyadtpulse._p_attribute_lockattribute_lock" 45 | ) 46 | warn( 47 | "PyADTPulse is deprecated, please use PyADTPulseAsync instead", 48 | DeprecationWarning, 49 | stacklevel=2, 50 | ) 51 | super().__init__( 52 | username, 53 | password, 54 | fingerprint, 55 | service_host, 56 | user_agent, 57 | debug_locks, 58 | keepalive_interval, 59 | relogin_interval, 60 | detailed_debug_logging, 61 | ) 62 | self._session_thread: Thread | None = None 63 | self._login_exception: Exception | None = None 64 | if do_login: 65 | self.login() 66 | 67 | def __repr__(self) -> str: 68 | """Object representation.""" 69 | return ( 70 | f"<{self.__class__.__name__}: {self._authentication_properties.username}>" 71 | ) 72 | 73 | # ADTPulse API endpoint is configurable (besides default US ADT Pulse endpoint) to 74 | # support testing as well as alternative ADT Pulse endpoints such as 75 | # portal-ca.adtpulse.com 76 | 77 | def _pulse_session_thread(self) -> None: 78 | """ 79 | Pulse the session thread. 80 | 81 | Acquires the attribute lock and creates a background thread for the ADT 82 | Pulse API. The thread runs the synchronous loop `_sync_loop()` until completion. 83 | Once the loop finishes, the thread is closed, the pulse connection's event loop 84 | is set to `None`, and the session thread is set to `None`. 85 | """ 86 | # lock is released in sync_loop() 87 | self._p_attribute_lock.acquire() 88 | 89 | LOG.debug("Creating ADT Pulse background thread") 90 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 91 | loop = asyncio.new_event_loop() 92 | self._pulse_connection_properties.loop = loop 93 | loop.run_until_complete(self._sync_loop()) 94 | 95 | loop.close() 96 | self._pulse_connection_properties.loop = None 97 | self._session_thread = None 98 | 99 | async def _sync_loop(self) -> None: 100 | """ 101 | Asynchronous function that represents the main loop of the synchronization 102 | process. 103 | 104 | This function is responsible for executing the synchronization logic. It starts 105 | by calling the `async_login` method to perform the login operation. After that, 106 | it releases the `_p_attribute_lock` to allow other tasks to access the 107 | attributes. 108 | If the login operation was successful, it waits for the `_timeout_task` to 109 | complete using the `asyncio.wait` function. If the `_timeout_task` is not set, 110 | it raises a `RuntimeError` to indicate that background tasks were not created. 111 | 112 | After the waiting process, it enters a while loop that continues as long as the 113 | `_authenticated` event is set. Inside the loop, it waits for 0.5 seconds using 114 | the `asyncio.sleep` function. This wait allows the logout process to complete 115 | before continuing with the synchronization logic. 116 | """ 117 | try: 118 | await self.async_login() 119 | except Exception as e: 120 | self._login_exception = e 121 | self._p_attribute_lock.release() 122 | if self._login_exception is not None: 123 | return 124 | if self._timeout_task is not None: 125 | task_list = (self._timeout_task,) 126 | try: 127 | await asyncio.wait(task_list) 128 | except asyncio.CancelledError: 129 | pass 130 | except Exception as e: # pylint: disable=broad-except 131 | LOG.exception( 132 | "Received exception while waiting for ADT Pulse service %s", e 133 | ) 134 | else: 135 | # we should never get here 136 | raise RuntimeError("Background pyadtpulse tasks not created") 137 | while self._pulse_connection_status.authenticated_flag.is_set(): 138 | # busy wait until logout is done 139 | await asyncio.sleep(0.5) 140 | 141 | def login(self) -> None: 142 | """Login to ADT Pulse and generate access token. 143 | 144 | Raises: 145 | Exception from async_login 146 | """ 147 | self._p_attribute_lock.acquire() 148 | # probably shouldn't be a daemon thread 149 | self._session_thread = thread = Thread( 150 | target=self._pulse_session_thread, 151 | name="PyADTPulse Session", 152 | daemon=True, 153 | ) 154 | self._p_attribute_lock.release() 155 | 156 | self._session_thread.start() 157 | time.sleep(1) 158 | 159 | # thread will unlock after async_login, so attempt to obtain 160 | # lock to block current thread until then 161 | # if it's still alive, no exception 162 | self._p_attribute_lock.acquire() 163 | self._p_attribute_lock.release() 164 | if not thread.is_alive(): 165 | if self._login_exception is not None: 166 | raise self._login_exception 167 | 168 | def logout(self) -> None: 169 | """Log out of ADT Pulse.""" 170 | loop = self._pulse_connection.check_sync( 171 | "Attempting to call sync logout without sync login" 172 | ) 173 | sync_thread = self._session_thread 174 | 175 | coro = self.async_logout() 176 | asyncio.run_coroutine_threadsafe(coro, loop) 177 | if sync_thread is not None: 178 | sync_thread.join() 179 | 180 | @property 181 | def attribute_lock(self) -> "RLock| DebugRLock": 182 | """Get attribute lock for PyADTPulse object. 183 | 184 | Returns: 185 | RLock: thread Rlock 186 | """ 187 | return self._p_attribute_lock 188 | 189 | @property 190 | def loop(self) -> asyncio.AbstractEventLoop | None: 191 | """Get event loop. 192 | 193 | Returns: 194 | Optional[asyncio.AbstractEventLoop]: the event loop object or 195 | None if no thread is running 196 | """ 197 | return self._pulse_connection_properties.loop 198 | 199 | @property 200 | def updates_exist(self) -> bool: 201 | """Check if updated data exists. 202 | 203 | Returns: 204 | bool: True if updated data exists 205 | """ 206 | with self._p_attribute_lock: 207 | if self._sync_task is None: 208 | loop = self._pulse_connection_properties.loop 209 | if loop is None: 210 | raise RuntimeError( 211 | "ADT pulse sync function updates_exist() " 212 | "called from async session" 213 | ) 214 | coro = self._sync_check_task() 215 | self._sync_task = loop.create_task( 216 | coro, name=f"{SYNC_CHECK_TASK_NAME}: Sync session" 217 | ) 218 | if self._pulse_properties.updates_exist.is_set(): 219 | self._pulse_properties.updates_exist.clear() 220 | return True 221 | return False 222 | 223 | def update(self) -> bool: 224 | """Update ADT Pulse data. 225 | 226 | Returns: 227 | bool: True on success 228 | """ 229 | coro = self.async_update() 230 | return asyncio.run_coroutine_threadsafe( 231 | coro, 232 | self._pulse_connection.check_sync( 233 | "Attempting to run sync update from async login" 234 | ), 235 | ).result() 236 | 237 | async def async_login(self) -> None: 238 | self._pulse_connection_properties.check_async( 239 | "Cannot login asynchronously with a synchronous session" 240 | ) 241 | await super().async_login() 242 | 243 | async def async_logout(self) -> None: 244 | self._pulse_connection_properties.check_async( 245 | "Cannot logout asynchronously with a synchronous session" 246 | ) 247 | await super().async_logout() 248 | 249 | async def async_update(self) -> bool: 250 | self._pulse_connection_properties.check_async( 251 | "Cannot update asynchronously with a synchronous session" 252 | ) 253 | return await super().async_update() 254 | -------------------------------------------------------------------------------- /pyadtpulse/const.py: -------------------------------------------------------------------------------- 1 | """Constants for pyadtpulse.""" 2 | 3 | __version__ = "1.2.11" 4 | 5 | DEFAULT_API_HOST = "https://portal.adtpulse.com" 6 | API_HOST_CA = "https://portal-ca.adtpulse.com" # Canada 7 | 8 | API_PREFIX = "/myhome/" 9 | 10 | ADT_LOGIN_URI = "/access/signin.jsp" 11 | ADT_LOGOUT_URI = "/access/signout.jsp" 12 | ADT_MFA_FAIL_URI = "/mfa/mfaSignIn.jsp?workflow=challenge" 13 | 14 | ADT_SUMMARY_URI = "/summary/summary.jsp" 15 | ADT_ZONES_URI = "/ajax/homeViewDevAjax.jsp" 16 | ADT_ORB_URI = "/ajax/orb.jsp" 17 | ADT_SYSTEM_URI = "/system/system.jsp" 18 | ADT_DEVICE_URI = "/system/device.jsp" 19 | ADT_STATES_URI = "/ajax/currentStates.jsp" 20 | ADT_GATEWAY_URI = "/system/gateway.jsp" 21 | ADT_SYNC_CHECK_URI = "/Ajax/SyncCheckServ" 22 | ADT_TIMEOUT_URI = "/KeepAlive" 23 | # Intervals are all in minutes 24 | ADT_DEFAULT_KEEPALIVE_INTERVAL: int = 5 25 | ADT_DEFAULT_RELOGIN_INTERVAL: int = 120 26 | ADT_MAX_KEEPALIVE_INTERVAL: int = 15 27 | ADT_MIN_RELOGIN_INTERVAL: int = 20 28 | ADT_GATEWAY_STRING = "gateway" 29 | 30 | # ADT sets their keepalive to 1 second, so poll a little more often 31 | # than that 32 | ADT_DEFAULT_POLL_INTERVAL = 2.0 33 | ADT_GATEWAY_MAX_OFFLINE_POLL_INTERVAL = 600.0 34 | ADT_MAX_BACKOFF: float = 5.0 * 60.0 35 | ADT_DEFAULT_HTTP_USER_AGENT = { 36 | "User-Agent": ( 37 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " 38 | "AppleWebKit/537.36 (KHTML, like Gecko) " 39 | "Chrome/122.0.0.0 Safari/537.36" 40 | ) 41 | } 42 | 43 | ADT_DEFAULT_HTTP_ACCEPT_HEADERS = { 44 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9," 45 | "image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7" 46 | } 47 | ADT_DEFAULT_SEC_FETCH_HEADERS = { 48 | "Sec-Fetch-User": "?1", 49 | "Sec-Ch-Ua-Mobile": "?0", 50 | "Sec-Fetch-Site": "same-origin", 51 | "Sec-Fetch-Mode": "navigate", 52 | "Upgrade-Insecure-Requests": "1", 53 | } 54 | ADT_OTHER_HTTP_ACCEPT_HEADERS = { 55 | "Accept": "*/*", 56 | } 57 | ADT_ARM_URI = "/quickcontrol/serv/RunRRACommand" 58 | ADT_ARM_DISARM_URI = "/quickcontrol/armDisarm.jsp" 59 | 60 | ADT_SYSTEM_SETTINGS = "/system/settings.jsp" 61 | 62 | ADT_HTTP_BACKGROUND_URIS = (ADT_ORB_URI, ADT_SYNC_CHECK_URI) 63 | STATE_OK = "OK" 64 | STATE_OPEN = "Open" 65 | STATE_MOTION = "Motion" 66 | STATE_TAMPER = "Tamper" 67 | STATE_ALARM = "Alarm" 68 | STATE_UNKNOWN = "Unknown" 69 | STATE_ONLINE = "Online" 70 | 71 | ADT_SENSOR_DOOR = "doorWindow" 72 | ADT_SENSOR_WINDOW = "glass" 73 | ADT_SENSOR_MOTION = "motion" 74 | ADT_SENSOR_SMOKE = "smoke" 75 | ADT_SENSOR_CO = "co" 76 | ADT_SENSOR_ALARM = "alarm" 77 | 78 | ADT_DEFAULT_LOGIN_TIMEOUT = 30 79 | -------------------------------------------------------------------------------- /pyadtpulse/exceptions.py: -------------------------------------------------------------------------------- 1 | """Pulse exceptions.""" 2 | 3 | import datetime 4 | from time import time 5 | 6 | from .pulse_backoff import PulseBackoff 7 | 8 | 9 | def compute_retry_time(retry_time: float | None) -> str: 10 | """Compute the retry time.""" 11 | if not retry_time: 12 | return "indefinitely" 13 | return str(datetime.datetime.fromtimestamp(retry_time)) 14 | 15 | 16 | class PulseExceptionWithBackoff(Exception): 17 | """Exception with backoff.""" 18 | 19 | def __init__(self, message: str, backoff: PulseBackoff): 20 | """Initialize exception.""" 21 | super().__init__(message) 22 | self.backoff = backoff 23 | self.backoff.increment_backoff() 24 | 25 | def __str__(self): 26 | """Return a string representation of the exception.""" 27 | return f"{self.__class__.__name__}: {self.args[0]}" 28 | 29 | def __repr__(self): 30 | """Return a string representation of the exception.""" 31 | return f"{self.__class__.__name__}(message='{self.args[0]}', backoff={self.backoff})" 32 | 33 | 34 | class PulseExceptionWithRetry(PulseExceptionWithBackoff): 35 | """Exception with backoff 36 | 37 | If retry_time is None, or is in the past, then just the backoff count will be incremented. 38 | """ 39 | 40 | def __init__(self, message: str, backoff: PulseBackoff, retry_time: float | None): 41 | """Initialize exception.""" 42 | # super.__init__ will increment the backoff count 43 | super().__init__(message, backoff) 44 | self.retry_time = retry_time 45 | if retry_time and retry_time > time(): 46 | # set the absolute backoff time will remove the backoff count 47 | self.backoff.set_absolute_backoff_time(retry_time) 48 | return 49 | 50 | def __str__(self): 51 | """Return a string representation of the exception.""" 52 | return f"{self.__class__.__name__}: {self.args[0]}" 53 | 54 | def __repr__(self): 55 | """Return a string representation of the exception.""" 56 | return f"{self.__class__.__name__}(message='{self.args[0]}', backoff={self.backoff}, retry_time={self.retry_time})" 57 | 58 | 59 | class PulseConnectionError(Exception): 60 | """Base class for connection errors""" 61 | 62 | 63 | class PulseServerConnectionError(PulseExceptionWithBackoff, PulseConnectionError): 64 | """Server error.""" 65 | 66 | def __init__(self, message: str, backoff: PulseBackoff): 67 | """Initialize Pulse server error exception.""" 68 | super().__init__(f"Pulse server error: {message}", backoff) 69 | 70 | 71 | class PulseClientConnectionError(PulseExceptionWithBackoff, PulseConnectionError): 72 | """Client error.""" 73 | 74 | def __init__(self, message: str, backoff: PulseBackoff): 75 | """Initialize Pulse client error exception.""" 76 | super().__init__(f"Client error connecting to Pulse: {message}", backoff) 77 | 78 | 79 | class PulseServiceTemporarilyUnavailableError( 80 | PulseExceptionWithRetry, PulseConnectionError 81 | ): 82 | """Service temporarily unavailable error. 83 | 84 | For HTTP 503 and 429 errors. 85 | """ 86 | 87 | def __init__(self, backoff: PulseBackoff, retry_time: float | None = None): 88 | """Initialize Pusle service temporarily unavailable error exception.""" 89 | super().__init__( 90 | f"Pulse service temporarily unavailable until {compute_retry_time(retry_time)}", 91 | backoff, 92 | retry_time, 93 | ) 94 | 95 | 96 | class PulseLoginException(Exception): 97 | """Login exceptions. 98 | 99 | Base class for catching all login exceptions.""" 100 | 101 | 102 | class PulseAuthenticationError(PulseLoginException): 103 | """Authentication error.""" 104 | 105 | def __init__(self): 106 | """Initialize Pulse Authentication error exception.""" 107 | super().__init__("Error authenticating to Pulse") 108 | 109 | 110 | class PulseAccountLockedError(PulseExceptionWithRetry, PulseLoginException): 111 | """Account locked error.""" 112 | 113 | def __init__(self, backoff: PulseBackoff, retry: float): 114 | """Initialize Pulse Account locked error exception.""" 115 | super().__init__( 116 | f"Pulse Account is locked until {compute_retry_time(retry)}", backoff, retry 117 | ) 118 | 119 | 120 | class PulseGatewayOfflineError(PulseExceptionWithBackoff): 121 | """Gateway offline error.""" 122 | 123 | def __init__(self, backoff: PulseBackoff): 124 | """Initialize Pulse Gateway offline error exception.""" 125 | super().__init__("Gateway is offline", backoff) 126 | 127 | 128 | class PulseMFARequiredError(PulseLoginException): 129 | """MFA required error.""" 130 | 131 | def __init__(self): 132 | """Initialize Pulse MFA required error exception.""" 133 | super().__init__("Authentication failed because MFA is required") 134 | 135 | 136 | class PulseNotLoggedInError(PulseLoginException): 137 | """Exception to indicate that the application code is not logged in. 138 | 139 | Used for signalling waiters. 140 | """ 141 | 142 | def __init__(self): 143 | """Initialize Pulse Not logged in error exception.""" 144 | super().__init__("Not logged into Pulse") 145 | -------------------------------------------------------------------------------- /pyadtpulse/fingerprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "fingerprint": { 3 | "uaBrowser": { 4 | "name": "Chrome", 5 | "version": "113.0.0.0", 6 | "major": "113" 7 | }, 8 | "uaString": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36", 9 | "uaDevice": { 10 | "model": null, 11 | "type": null, 12 | "vendor": null 13 | }, 14 | "uaEngine": { 15 | "name": "WebKit", 16 | "version": "537.36" 17 | }, 18 | "uaOS": { 19 | "name": "Linux", 20 | "version": "x86_64" 21 | }, 22 | "uaCPU": { 23 | "architecture": "amd64" 24 | }, 25 | "uaPlatform": "Linux x86_64", 26 | "language": "en-US", 27 | "colorDepth": 24, 28 | "pixelRatio": 1, 29 | "screenResolution": "1600x900", 30 | "availableScreenResolution": "1600x846", 31 | "timezone": "America/New_York", 32 | "timezoneOffset": 240, 33 | "localStorage": true, 34 | "sessionStorage": true, 35 | "indexedDb": true, 36 | "addBehavior": false, 37 | "openDatabase": true, 38 | "cpuClass": null, 39 | "platform": "Linux x86_64", 40 | "doNotTrack": "1", 41 | "plugins": "Portable Document Format.application/pdf::pdf,Portable Document Format.application/pdf::pdf,Portable Document Format.application/pdf::pdf,Portable Document Format.application/pdf::pdf,Portable Document Format.application/pdf::pdf", 42 | "canvas": "-198900869", 43 | "webGl": "1955868989", 44 | "adBlock": true, 45 | "userTamperLanguage": false, 46 | "userTamperScreenResolution": false, 47 | "userTamperOS": false, 48 | "userTamperBrowser": false, 49 | "touchSupport": { 50 | "maxTouchPoints": 0, 51 | "touchEvent": false, 52 | "touchStart": false 53 | }, 54 | "cookieSupport": true, 55 | "fonts": "Andale Mono,Arial,Arial Black,Bauhaus 93,Bodoni 72,Bodoni 72 Oldstyle,Bodoni 72 Smallcaps,Bookshelf Symbol 7,Comic Sans MS,Courier,Courier New,English 111 Vivace BT,Georgia,GeoSlab 703 Lt BT,GeoSlab 703 XBd BT,Helvetica,Humanst 521 Cn BT,Impact,Modern No. 20,MS Gothic,MS PGothic,MS PMincho,PMingLiU,SimSun,Times,Times New Roman,Trebuchet MS,Univers CE 55 Medium,Verdana,Wingdings 2,Wingdings 3" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pyadtpulse/gateway.py: -------------------------------------------------------------------------------- 1 | """ADT Pulse Gateway Dataclass.""" 2 | 3 | import logging 4 | import re 5 | from dataclasses import dataclass 6 | from ipaddress import IPv4Address, IPv6Address, ip_address 7 | from threading import RLock 8 | from typing import Any 9 | 10 | from typeguard import typechecked 11 | 12 | from .const import ADT_DEFAULT_POLL_INTERVAL, ADT_GATEWAY_MAX_OFFLINE_POLL_INTERVAL 13 | from .pulse_backoff import PulseBackoff 14 | from .util import parse_pulse_datetime 15 | 16 | LOG = logging.getLogger(__name__) 17 | 18 | STRING_UPDATEABLE_FIELDS = ( 19 | "manufacturer", 20 | "model", 21 | "serial_number", 22 | "firmware_version", 23 | "hardware_version", 24 | "primary_connection_type", 25 | "broadband_connection_status", 26 | "cellular_connection_status", 27 | "cellular_connection_signal_strength", 28 | "broadband_lan_mac", 29 | "device_lan_mac", 30 | ) 31 | 32 | DATETIME_UPDATEABLE_FIELDS = ("next_update", "last_update") 33 | 34 | IPADDR_UPDATEABLE_FIELDS = ( 35 | "broadband_lan_ip_address", 36 | "device_lan_ip_address", 37 | "router_lan_ip_address", 38 | "router_wan_ip_address", 39 | ) 40 | 41 | 42 | @dataclass(slots=True) 43 | class ADTPulseGateway: 44 | """ADT Pulse Gateway information.""" 45 | 46 | manufacturer: str = "Unknown" 47 | _status_text: str = "OFFLINE" 48 | backoff = PulseBackoff( 49 | "Gateway", ADT_DEFAULT_POLL_INTERVAL, ADT_GATEWAY_MAX_OFFLINE_POLL_INTERVAL 50 | ) 51 | _attribute_lock = RLock() 52 | model: str | None = None 53 | serial_number: str | None = None 54 | next_update: int = 0 55 | last_update: int = 0 56 | firmware_version: str | None = None 57 | hardware_version: str | None = None 58 | primary_connection_type: str | None = None 59 | broadband_connection_status: str | None = None 60 | cellular_connection_status: str | None = None 61 | _cellular_connection_signal_strength: float = 0.0 62 | broadband_lan_ip_address: IPv4Address | IPv6Address | None = None 63 | _broadband_lan_mac: str | None = None 64 | device_lan_ip_address: IPv4Address | IPv6Address | None = None 65 | _device_lan_mac: str | None = None 66 | router_lan_ip_address: IPv4Address | IPv6Address | None = None 67 | router_wan_ip_address: IPv4Address | IPv6Address | None = None 68 | 69 | @property 70 | def is_online(self) -> bool: 71 | """Returns whether gateway is online. 72 | 73 | Returns: 74 | bool: True if gateway is online 75 | """ 76 | with self._attribute_lock: 77 | return self._status_text == "ONLINE" 78 | 79 | @is_online.setter 80 | @typechecked 81 | def is_online(self, status: bool) -> None: 82 | """Set gateway status. 83 | 84 | Args: 85 | status (bool): True if gateway is online 86 | """ 87 | with self._attribute_lock: 88 | if status == self.is_online: 89 | return 90 | old_status = self._status_text 91 | self._status_text = "ONLINE" 92 | if not status: 93 | self._status_text = "OFFLINE" 94 | 95 | LOG.info( 96 | "ADT Pulse gateway %s", 97 | self._status_text, 98 | ) 99 | if old_status == "OFFLINE": 100 | self.backoff.reset_backoff() 101 | LOG.debug( 102 | "Gateway poll interval: %d", 103 | ( 104 | self.backoff.initial_backoff_interval 105 | if self._status_text == "ONLINE" 106 | else self.backoff.get_current_backoff_interval() 107 | ), 108 | ) 109 | 110 | @property 111 | def poll_interval(self) -> float: 112 | """Get initial poll interval.""" 113 | with self._attribute_lock: 114 | return self.backoff.initial_backoff_interval 115 | 116 | @poll_interval.setter 117 | @typechecked 118 | def poll_interval(self, new_interval: float) -> None: 119 | with self._attribute_lock: 120 | self.backoff.initial_backoff_interval = new_interval 121 | 122 | @staticmethod 123 | def _check_mac_address(mac_address: str) -> bool: 124 | pattern = r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$" 125 | return re.match(pattern, mac_address) is not None 126 | 127 | @property 128 | def broadband_lan_mac(self) -> str | None: 129 | """Get current gateway MAC address.""" 130 | return self._broadband_lan_mac 131 | 132 | @broadband_lan_mac.setter 133 | @typechecked 134 | def broadband_lan_mac(self, new_mac: str | None) -> None: 135 | """Set gateway MAC address.""" 136 | if new_mac is not None and not self._check_mac_address(new_mac): 137 | raise ValueError("Invalid MAC address") 138 | self._broadband_lan_mac = new_mac 139 | 140 | @property 141 | def device_lan_mac(self) -> str | None: 142 | """Get current gateway MAC address.""" 143 | return self._device_lan_mac 144 | 145 | @device_lan_mac.setter 146 | @typechecked 147 | def device_lan_mac(self, new_mac: str | None) -> None: 148 | """Set gateway MAC address.""" 149 | if new_mac is not None and not self._check_mac_address(new_mac): 150 | raise ValueError("Invalid MAC address") 151 | self._device_lan_mac = new_mac 152 | 153 | @property 154 | def cellular_connection_signal_strength(self) -> float: 155 | """Get current gateway MAC address.""" 156 | return self._cellular_connection_signal_strength 157 | 158 | @cellular_connection_signal_strength.setter 159 | @typechecked 160 | def cellular_connection_signal_strength( 161 | self, new_signal_strength: float | None 162 | ) -> None: 163 | """Set gateway MAC address.""" 164 | if not new_signal_strength: 165 | new_signal_strength = 0.0 166 | self._cellular_connection_signal_strength = new_signal_strength 167 | 168 | def set_gateway_attributes(self, gateway_attributes: dict[str, str]) -> None: 169 | """Set gateway attributes from dictionary. 170 | 171 | Args: 172 | gateway_attributes (dict[str,str]): dictionary of gateway attributes 173 | """ 174 | for i in ( 175 | STRING_UPDATEABLE_FIELDS 176 | + IPADDR_UPDATEABLE_FIELDS 177 | + DATETIME_UPDATEABLE_FIELDS 178 | ): 179 | temp: Any = gateway_attributes.get(i) 180 | if temp == "": 181 | temp = None 182 | if temp is None: 183 | setattr(self, i, None) 184 | continue 185 | if i in IPADDR_UPDATEABLE_FIELDS: 186 | try: 187 | temp = ip_address(temp) 188 | except ValueError: 189 | temp = None 190 | elif i in DATETIME_UPDATEABLE_FIELDS: 191 | try: 192 | temp = int(parse_pulse_datetime(temp).timestamp()) 193 | except ValueError: 194 | temp = None 195 | if hasattr(self, i): 196 | setattr(self, i, temp) 197 | -------------------------------------------------------------------------------- /pyadtpulse/pulse_authentication_properties.py: -------------------------------------------------------------------------------- 1 | """Pulse Authentication Properties.""" 2 | 3 | from re import match 4 | 5 | from typeguard import typechecked 6 | 7 | from .util import set_debug_lock 8 | 9 | 10 | class PulseAuthenticationProperties: 11 | """Pulse Authentication Properties.""" 12 | 13 | __slots__ = ( 14 | "_username", 15 | "_password", 16 | "_fingerprint", 17 | "_paa_attribute_lock", 18 | "_last_login_time", 19 | "_site_id", 20 | ) 21 | 22 | @staticmethod 23 | def check_username(username: str) -> None: 24 | """Check if username is valid. 25 | 26 | Raises ValueError if a login parameter is not valid.""" 27 | if not username: 28 | raise ValueError("Username is mandatory") 29 | pattern = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b" 30 | if not match(pattern, username): 31 | raise ValueError("Username must be an email address") 32 | 33 | @staticmethod 34 | @typechecked 35 | def check_password(password: str) -> None: 36 | """Check if password is valid. 37 | 38 | Raises ValueError if password is not valid. 39 | """ 40 | if not password: 41 | raise ValueError("Password is mandatory") 42 | 43 | @staticmethod 44 | @typechecked 45 | def check_fingerprint(fingerprint: str) -> None: 46 | """Check if fingerprint is valid. 47 | 48 | Raises ValueError if password is not valid. 49 | """ 50 | if not fingerprint: 51 | raise ValueError("Fingerprint is required") 52 | 53 | @typechecked 54 | def __init__( 55 | self, 56 | username: str, 57 | password: str, 58 | fingerprint: str, 59 | debug_locks: bool = False, 60 | ) -> None: 61 | """Initialize Pulse Authentication Properties.""" 62 | self.check_username(username) 63 | self.check_password(password) 64 | self.check_fingerprint(fingerprint) 65 | self._username = username 66 | self._password = password 67 | self._fingerprint = fingerprint 68 | self._paa_attribute_lock = set_debug_lock( 69 | debug_locks, "pyadtpulse.paa_attribute_lock" 70 | ) 71 | self._last_login_time = 0 72 | self._site_id = "" 73 | 74 | @property 75 | def last_login_time(self) -> int: 76 | """Get the last login time.""" 77 | with self._paa_attribute_lock: 78 | return self._last_login_time 79 | 80 | @last_login_time.setter 81 | @typechecked 82 | def last_login_time(self, login_time: int) -> None: 83 | with self._paa_attribute_lock: 84 | self._last_login_time = login_time 85 | 86 | @property 87 | def username(self) -> str: 88 | """Get the username.""" 89 | with self._paa_attribute_lock: 90 | return self._username 91 | 92 | @username.setter 93 | @typechecked 94 | def username(self, username: str) -> None: 95 | self.check_username(username) 96 | with self._paa_attribute_lock: 97 | self._username = username 98 | 99 | @property 100 | def password(self) -> str: 101 | """Get the password.""" 102 | with self._paa_attribute_lock: 103 | return self._password 104 | 105 | @password.setter 106 | @typechecked 107 | def password(self, password: str) -> None: 108 | self.check_password(password) 109 | with self._paa_attribute_lock: 110 | self._password = password 111 | 112 | @property 113 | def fingerprint(self) -> str: 114 | """Get the fingerprint.""" 115 | with self._paa_attribute_lock: 116 | return self._fingerprint 117 | 118 | @fingerprint.setter 119 | @typechecked 120 | def fingerprint(self, fingerprint: str) -> None: 121 | self.check_fingerprint(fingerprint) 122 | with self._paa_attribute_lock: 123 | self._fingerprint = fingerprint 124 | 125 | @property 126 | def site_id(self) -> str: 127 | """Get the site ID.""" 128 | with self._paa_attribute_lock: 129 | return self._site_id 130 | 131 | @site_id.setter 132 | @typechecked 133 | def site_id(self, site_id: str) -> None: 134 | with self._paa_attribute_lock: 135 | self._site_id = site_id 136 | -------------------------------------------------------------------------------- /pyadtpulse/pulse_backoff.py: -------------------------------------------------------------------------------- 1 | """Pulse backoff object.""" 2 | 3 | import asyncio 4 | import datetime 5 | from logging import getLogger 6 | from time import time 7 | 8 | from typeguard import typechecked 9 | 10 | from .const import ADT_MAX_BACKOFF 11 | from .util import set_debug_lock 12 | 13 | LOG = getLogger(__name__) 14 | 15 | 16 | class PulseBackoff: 17 | """Pulse backoff object.""" 18 | 19 | __slots__ = ( 20 | "_b_lock", 21 | "_initial_backoff_interval", 22 | "_max_backoff_interval", 23 | "_backoff_count", 24 | "_expiration_time", 25 | "_name", 26 | "_detailed_debug_logging", 27 | "_threshold", 28 | ) 29 | 30 | @typechecked 31 | def __init__( 32 | self, 33 | name: str, 34 | initial_backoff_interval: float, 35 | max_backoff_interval: float = ADT_MAX_BACKOFF, 36 | threshold: int = 0, 37 | debug_locks: bool = False, 38 | detailed_debug_logging=False, 39 | ) -> None: 40 | """Initialize backoff. 41 | 42 | Args: 43 | name (str): Name of the backoff. 44 | initial_backoff_interval (float): Initial backoff interval in seconds. 45 | max_backoff_interval (float, optional): Maximum backoff interval in seconds. 46 | Defaults to ADT_MAX_BACKOFF. 47 | threshold (int, optional): Threshold for backoff. Defaults to 0. 48 | debug_locks (bool, optional): Enable debug locks. Defaults to False. 49 | detailed_debug_logging (bool, optional): Enable detailed debug logging. 50 | Defaults to False. 51 | """ 52 | self._check_intervals(initial_backoff_interval, max_backoff_interval) 53 | self._b_lock = set_debug_lock(debug_locks, "pyadtpulse._b_lock") 54 | self._initial_backoff_interval = initial_backoff_interval 55 | self._max_backoff_interval = max_backoff_interval 56 | self._backoff_count = 0 57 | self._expiration_time = 0.0 58 | self._name = name 59 | self._detailed_debug_logging = detailed_debug_logging 60 | self._threshold = threshold 61 | 62 | def _calculate_backoff_interval(self) -> float: 63 | """Calculate backoff time.""" 64 | if self._backoff_count == 0: 65 | return 0.0 66 | if self._backoff_count <= (self._threshold + 1): 67 | return self._initial_backoff_interval 68 | return min( 69 | self._initial_backoff_interval 70 | * 2 ** (self._backoff_count - self._threshold - 1), 71 | self._max_backoff_interval, 72 | ) 73 | 74 | @staticmethod 75 | def _check_intervals( 76 | initial_backoff_interval: float, max_backoff_interval: float 77 | ) -> None: 78 | """Check max_backoff_interval is >= initial_backoff_interval 79 | and that both invervals are positive.""" 80 | if initial_backoff_interval <= 0: 81 | raise ValueError("initial_backoff_interval must be greater than 0") 82 | if max_backoff_interval < initial_backoff_interval: 83 | raise ValueError("max_backoff_interval must be >= initial_backoff_interval") 84 | 85 | def get_current_backoff_interval(self) -> float: 86 | """Return current backoff time.""" 87 | with self._b_lock: 88 | return self._calculate_backoff_interval() 89 | 90 | def increment_backoff(self) -> None: 91 | """Increment backoff.""" 92 | with self._b_lock: 93 | self._backoff_count += 1 94 | if self._detailed_debug_logging: 95 | LOG.debug( 96 | "Pulse backoff %s: incremented to %s", 97 | self._name, 98 | self._backoff_count, 99 | ) 100 | 101 | def reset_backoff(self) -> None: 102 | """Reset backoff.""" 103 | with self._b_lock: 104 | if self._expiration_time < time(): 105 | if self._detailed_debug_logging and self._backoff_count != 0: 106 | LOG.debug("Pulse backoff %s reset", self._name) 107 | self._backoff_count = 0 108 | self._expiration_time = 0.0 109 | 110 | @typechecked 111 | def set_absolute_backoff_time(self, backoff_time: float) -> None: 112 | """Set absolute backoff time.""" 113 | curr_time = time() 114 | if backoff_time < curr_time: 115 | raise ValueError("Absolute backoff time must be greater than current time") 116 | with self._b_lock: 117 | if self._detailed_debug_logging: 118 | LOG.debug( 119 | "Pulse backoff %s: set to %s", 120 | self._name, 121 | datetime.datetime.fromtimestamp(backoff_time).strftime( 122 | "%m/%d/%Y %H:%M:%S" 123 | ), 124 | ) 125 | self._expiration_time = backoff_time 126 | self._backoff_count = 0 127 | 128 | async def wait_for_backoff(self) -> None: 129 | """Wait for backoff.""" 130 | with self._b_lock: 131 | curr_time = time() 132 | if self._expiration_time < curr_time: 133 | if self.backoff_count == 0: 134 | return 135 | diff = self._calculate_backoff_interval() 136 | else: 137 | diff = self._expiration_time - curr_time 138 | if diff > 0: 139 | if self._detailed_debug_logging: 140 | LOG.debug("Backoff %s: waiting for %s", self._name, diff) 141 | await asyncio.sleep(diff) 142 | 143 | def will_backoff(self) -> bool: 144 | """Return if backoff is needed.""" 145 | with self._b_lock: 146 | return ( 147 | self._backoff_count > self._threshold or self._expiration_time >= time() 148 | ) 149 | 150 | @property 151 | def backoff_count(self) -> int: 152 | """Return backoff count.""" 153 | with self._b_lock: 154 | return self._backoff_count 155 | 156 | @property 157 | def expiration_time(self) -> float: 158 | """Return backoff expiration time.""" 159 | with self._b_lock: 160 | return self._expiration_time 161 | 162 | @property 163 | def initial_backoff_interval(self) -> float: 164 | """Return initial backoff interval.""" 165 | with self._b_lock: 166 | return self._initial_backoff_interval 167 | 168 | @initial_backoff_interval.setter 169 | @typechecked 170 | def initial_backoff_interval(self, new_interval: float) -> None: 171 | """Set initial backoff interval.""" 172 | with self._b_lock: 173 | self._check_intervals(new_interval, self._max_backoff_interval) 174 | self._initial_backoff_interval = new_interval 175 | 176 | @property 177 | def name(self) -> str: 178 | """Return name.""" 179 | return self._name 180 | 181 | @property 182 | def detailed_debug_logging(self) -> bool: 183 | """Return detailed debug logging.""" 184 | with self._b_lock: 185 | return self._detailed_debug_logging 186 | 187 | @detailed_debug_logging.setter 188 | @typechecked 189 | def detailed_debug_logging(self, new_value: bool) -> None: 190 | """Set detailed debug logging.""" 191 | with self._b_lock: 192 | self._detailed_debug_logging = new_value 193 | -------------------------------------------------------------------------------- /pyadtpulse/pulse_connection.py: -------------------------------------------------------------------------------- 1 | """ADT Pulse connection. End users should probably not call this directly. 2 | 3 | This is the main interface to the http functions to access ADT Pulse. 4 | """ 5 | 6 | import logging 7 | import re 8 | from asyncio import AbstractEventLoop 9 | from time import time 10 | 11 | from lxml import html 12 | from typeguard import typechecked 13 | from yarl import URL 14 | 15 | from .const import ( 16 | ADT_DEFAULT_LOGIN_TIMEOUT, 17 | ADT_LOGIN_URI, 18 | ADT_LOGOUT_URI, 19 | ADT_MFA_FAIL_URI, 20 | ADT_SUMMARY_URI, 21 | ) 22 | from .exceptions import ( 23 | PulseAccountLockedError, 24 | PulseAuthenticationError, 25 | PulseClientConnectionError, 26 | PulseMFARequiredError, 27 | PulseNotLoggedInError, 28 | PulseServerConnectionError, 29 | PulseServiceTemporarilyUnavailableError, 30 | ) 31 | from .pulse_authentication_properties import PulseAuthenticationProperties 32 | from .pulse_backoff import PulseBackoff 33 | from .pulse_connection_properties import PulseConnectionProperties 34 | from .pulse_connection_status import PulseConnectionStatus 35 | from .pulse_query_manager import PulseQueryManager 36 | from .util import make_etree, set_debug_lock 37 | 38 | LOG = logging.getLogger(__name__) 39 | 40 | 41 | SESSION_COOKIES = {"X-mobile-browser": "false", "ICLocal": "en_US"} 42 | 43 | 44 | class PulseConnection(PulseQueryManager): 45 | """ADT Pulse connection related attributes.""" 46 | 47 | __slots__ = ( 48 | "_pc_attribute_lock", 49 | "_authentication_properties", 50 | "_login_backoff", 51 | "_login_in_progress", 52 | ) 53 | 54 | @typechecked 55 | def __init__( 56 | self, 57 | pulse_connection_status: PulseConnectionStatus, 58 | pulse_connection_properties: PulseConnectionProperties, 59 | pulse_authentication: PulseAuthenticationProperties, 60 | debug_locks: bool = False, 61 | ): 62 | """Initialize ADT Pulse connection.""" 63 | 64 | # need to initialize this after the session since we set cookies 65 | # based on it 66 | super().__init__( 67 | pulse_connection_status, 68 | pulse_connection_properties, 69 | debug_locks, 70 | ) 71 | self._pc_attribute_lock = set_debug_lock( 72 | debug_locks, "pyadtpulse.pc_attribute_lock" 73 | ) 74 | self._connection_properties = pulse_connection_properties 75 | self._connection_status = pulse_connection_status 76 | self._authentication_properties = pulse_authentication 77 | self._login_backoff = PulseBackoff( 78 | "Login", 79 | pulse_connection_status._backoff.initial_backoff_interval, 80 | detailed_debug_logging=self._connection_properties.detailed_debug_logging, 81 | ) 82 | self._login_in_progress = False 83 | self._debug_locks = debug_locks 84 | 85 | @typechecked 86 | def check_login_errors( 87 | self, response: tuple[int, str | None, URL | None] 88 | ) -> html.HtmlElement: 89 | """Check response for login errors. 90 | 91 | Will handle setting backoffs and raising exceptions. 92 | 93 | Args: 94 | response (tuple[int, str | None, URL | None]): The response 95 | 96 | Returns: 97 | html.HtmlElement: the parsed response tree 98 | 99 | Raises: 100 | PulseAuthenticationError: if login fails due to incorrect username/password 101 | PulseServerConnectionError: if login fails due to server error 102 | PulseAccountLockedError: if login fails due to account locked 103 | PulseMFARequiredError: if login fails due to MFA required 104 | PulseNotLoggedInError: if login fails due to not logged in 105 | """ 106 | 107 | def extract_seconds_from_string(s: str) -> int: 108 | seconds = 0 109 | match = re.search(r"\d+", s) 110 | if match: 111 | seconds = int(match.group()) 112 | if "minutes" in s: 113 | seconds *= 60 114 | return seconds 115 | 116 | def determine_error_type(): 117 | """Determine what type of error we have from the url and the parsed page. 118 | 119 | Will raise the appropriate exception. 120 | """ 121 | self._login_in_progress = False 122 | url = self._connection_properties.make_url(ADT_LOGIN_URI) 123 | if response_url_string.startswith(url): 124 | error = tree.find(".//div[@id='warnMsgContents']") 125 | if error is not None: 126 | error_text = error.text_content() 127 | LOG.error("Error logging into pulse: %s", error_text) 128 | if "Try again in" in error_text: 129 | if (retry_after := extract_seconds_from_string(error_text)) > 0: 130 | raise PulseAccountLockedError( 131 | self._login_backoff, 132 | retry_after + time(), 133 | ) 134 | elif "You have not yet signed in" in error_text: 135 | raise PulseNotLoggedInError() 136 | elif "Sign In Unsuccessful" in error_text: 137 | raise PulseAuthenticationError() 138 | else: 139 | LOG.error("Unknown error logging into pulse: no message given") 140 | raise PulseNotLoggedInError() 141 | else: 142 | url = self._connection_properties.make_url(ADT_MFA_FAIL_URI) 143 | if url == response_url_string: 144 | raise PulseMFARequiredError() 145 | 146 | tree = make_etree( 147 | response[0], 148 | response[1], 149 | response[2], 150 | logging.ERROR, 151 | "Could not log into ADT Pulse site", 152 | ) 153 | # this probably should have been handled by async_query() 154 | if tree is None: 155 | raise PulseServerConnectionError( 156 | f"Could not log into ADT Pulse site: code {response[0]}: " 157 | f"URL: {response[2]}, response: {response[1]}", 158 | self._login_backoff, 159 | ) 160 | url = self._connection_properties.make_url(ADT_SUMMARY_URI) 161 | response_url_string = str(response[2]) 162 | if url != response_url_string: 163 | determine_error_type() 164 | # if we get here we can't determine the error 165 | # raise a generic authentication error 166 | LOG.error( 167 | "Login received unexpected response from login query: %s", 168 | response_url_string, 169 | ) 170 | raise PulseAuthenticationError() 171 | return tree 172 | 173 | @typechecked 174 | async def async_do_login_query( 175 | self, timeout: int = ADT_DEFAULT_LOGIN_TIMEOUT 176 | ) -> html.HtmlElement | None: 177 | """ 178 | Performs a login query to the Pulse site. 179 | 180 | Will backoff on login failures. 181 | 182 | Will set login in progress flag. 183 | 184 | Args: 185 | timeout (int, optional): The timeout value for the query in seconds. 186 | Defaults to ADT_DEFAULT_LOGIN_TIMEOUT. 187 | 188 | Returns: 189 | tree (html.HtmlElement, optional): the parsed response tree for 190 | summary.jsp, or None if failure 191 | Raises: 192 | ValueError: if login parameters are not correct 193 | PulseAuthenticationError: if login fails due to incorrect username/password 194 | PulseServerConnectionError: if login fails due to server error 195 | PulseServiceTemporarilyUnavailableError: if login fails due to too many requests or 196 | server is temporarily unavailable 197 | PulseAccountLockedError: if login fails due to account locked 198 | PulseMFARequiredError: if login fails due to MFA required 199 | PulseNotLoggedInError: if login fails due to not logged in 200 | (which is probably an internal error) 201 | """ 202 | 203 | if self.login_in_progress: 204 | return None 205 | await self.quick_logout() 206 | # just raise exceptions if we're not going to be able to log in 207 | lockout_time = self._login_backoff.expiration_time 208 | if lockout_time > time(): 209 | raise PulseAccountLockedError(self._login_backoff, lockout_time) 210 | cs_backoff = self._connection_status.get_backoff() 211 | lockout_time = cs_backoff.expiration_time 212 | if lockout_time > time(): 213 | raise PulseServiceTemporarilyUnavailableError(cs_backoff, lockout_time) 214 | self.login_in_progress = True 215 | data = { 216 | "usernameForm": self._authentication_properties.username, 217 | "passwordForm": self._authentication_properties.password, 218 | "fingerprint": self._authentication_properties.fingerprint, 219 | } 220 | if self._authentication_properties.site_id: 221 | data["networkid"] = self._authentication_properties.site_id 222 | await self._login_backoff.wait_for_backoff() 223 | try: 224 | response = await self.async_query( 225 | ADT_LOGIN_URI, 226 | "POST", 227 | extra_params=data, 228 | timeout=timeout, 229 | requires_authentication=False, 230 | ) 231 | except ( 232 | PulseClientConnectionError, 233 | PulseServerConnectionError, 234 | PulseServiceTemporarilyUnavailableError, 235 | ) as e: 236 | LOG.error("Could not log into Pulse site: %s", e) 237 | self.login_in_progress = False 238 | raise 239 | tree = self.check_login_errors(response) 240 | self._connection_status.authenticated_flag.set() 241 | self._authentication_properties.last_login_time = int(time()) 242 | self._login_backoff.reset_backoff() 243 | self.login_in_progress = False 244 | return tree 245 | 246 | @typechecked 247 | async def async_do_logout_query(self, site_id: str | None = None) -> None: 248 | """Performs a logout query to the ADT Pulse site.""" 249 | params = {} 250 | si = "" 251 | self._connection_status.authenticated_flag.clear() 252 | if site_id is not None and site_id != "": 253 | self._authentication_properties.site_id = site_id 254 | si = site_id 255 | params.update({"networkid": si}) 256 | 257 | params.update({"partner": "adt"}) 258 | try: 259 | await self.async_query( 260 | ADT_LOGOUT_URI, 261 | extra_params=params, 262 | timeout=10, 263 | requires_authentication=False, 264 | ) 265 | # FIXME: do we care if this raises exceptions? 266 | except ( 267 | PulseClientConnectionError, 268 | PulseServiceTemporarilyUnavailableError, 269 | PulseServerConnectionError, 270 | ) as e: 271 | LOG.debug("Could not logout from Pulse site: %s", e) 272 | 273 | @property 274 | def is_connected(self) -> bool: 275 | """Check if ADT Pulse is connected.""" 276 | return ( 277 | self._connection_status.authenticated_flag.is_set() 278 | and not self._login_in_progress 279 | ) 280 | 281 | @property 282 | def login_backoff(self) -> PulseBackoff: 283 | """Return backoff object.""" 284 | with self._pc_attribute_lock: 285 | return self._login_backoff 286 | 287 | def check_sync(self, message: str) -> AbstractEventLoop: 288 | """Convenience method to check if running from sync context.""" 289 | return self._connection_properties.check_sync(message) 290 | 291 | @property 292 | def debug_locks(self): 293 | """Return debug locks.""" 294 | return self._debug_locks 295 | 296 | @property 297 | def login_in_progress(self) -> bool: 298 | """Return login in progress.""" 299 | with self._pc_attribute_lock: 300 | return self._login_in_progress 301 | 302 | @login_in_progress.setter 303 | @typechecked 304 | def login_in_progress(self, value: bool) -> None: 305 | """Set login in progress.""" 306 | with self._pc_attribute_lock: 307 | self._login_in_progress = value 308 | 309 | async def quick_logout(self) -> None: 310 | """Quickly logout. 311 | 312 | This just resets the authenticated flag and clears the ClientSession. 313 | """ 314 | LOG.debug("Resetting session") 315 | self._connection_status.authenticated_flag.clear() 316 | await self._connection_properties.clear_session() 317 | 318 | @property 319 | def detailed_debug_logging(self) -> bool: 320 | """Return detailed debug logging.""" 321 | return ( 322 | self._login_backoff.detailed_debug_logging 323 | and self._connection_properties.detailed_debug_logging 324 | and self._connection_status.detailed_debug_logging 325 | ) 326 | 327 | @detailed_debug_logging.setter 328 | @typechecked 329 | def detailed_debug_logging(self, value: bool): 330 | with self._pc_attribute_lock: 331 | self._login_backoff.detailed_debug_logging = value 332 | self._connection_properties.detailed_debug_logging = value 333 | self._connection_status.detailed_debug_logging = value 334 | 335 | def get_login_backoff(self) -> PulseBackoff: 336 | """Return login backoff.""" 337 | return self._login_backoff 338 | -------------------------------------------------------------------------------- /pyadtpulse/pulse_connection_properties.py: -------------------------------------------------------------------------------- 1 | """Pulse connection info.""" 2 | 3 | from asyncio import AbstractEventLoop 4 | from re import search 5 | 6 | from aiohttp import ClientSession 7 | from typeguard import typechecked 8 | 9 | from .const import ( 10 | ADT_DEFAULT_HTTP_ACCEPT_HEADERS, 11 | ADT_DEFAULT_HTTP_USER_AGENT, 12 | ADT_DEFAULT_SEC_FETCH_HEADERS, 13 | API_HOST_CA, 14 | API_PREFIX, 15 | DEFAULT_API_HOST, 16 | ) 17 | from .util import set_debug_lock 18 | 19 | 20 | class PulseConnectionProperties: 21 | """Pulse connection info.""" 22 | 23 | __slots__ = ( 24 | "_api_host", 25 | "_session", 26 | "_user_agent", 27 | "_loop", 28 | "_api_version", 29 | "_pci_attribute_lock", 30 | "_detailed_debug_logging", 31 | "_debug_locks", 32 | ) 33 | 34 | @staticmethod 35 | @typechecked 36 | def check_service_host(service_host: str) -> None: 37 | """Check if service host is valid.""" 38 | if service_host is None or service_host == "": 39 | raise ValueError("Service host is mandatory") 40 | if service_host not in (DEFAULT_API_HOST, API_HOST_CA): 41 | raise ValueError( 42 | f"Service host must be one of {DEFAULT_API_HOST}" f" or {API_HOST_CA}" 43 | ) 44 | 45 | @staticmethod 46 | def get_api_version(response_path: str) -> str | None: 47 | """Regex used to exctract the API version. 48 | 49 | Use for testing. 50 | """ 51 | version: str | None = None 52 | if not response_path: 53 | return None 54 | m = search(f"{API_PREFIX}(.+)/[a-z]*/", response_path) 55 | if m is not None: 56 | version = m.group(1) 57 | return version 58 | 59 | def __init__( 60 | self, 61 | host: str, 62 | user_agent=ADT_DEFAULT_HTTP_USER_AGENT["User-Agent"], 63 | detailed_debug_logging=False, 64 | debug_locks=False, 65 | ) -> None: 66 | """Initialize Pulse connection information.""" 67 | self._pci_attribute_lock = set_debug_lock( 68 | debug_locks, "pyadtpulse.pci_attribute_lock" 69 | ) 70 | self.debug_locks = debug_locks 71 | self.detailed_debug_logging = detailed_debug_logging 72 | self._loop: AbstractEventLoop | None = None 73 | self._session: ClientSession | None = None 74 | self.service_host = host 75 | self._api_version = "" 76 | self._user_agent = user_agent 77 | 78 | def __del__(self): 79 | """Destructor for ADTPulseConnection.""" 80 | if self._session is not None and not self._session.closed: 81 | self._session.detach() 82 | 83 | def _set_headers(self) -> None: 84 | if self._session is not None: 85 | self._session.headers.update(ADT_DEFAULT_HTTP_ACCEPT_HEADERS) 86 | self._session.headers.update(ADT_DEFAULT_SEC_FETCH_HEADERS) 87 | self._session.headers.update({"User-Agent": self._user_agent}) 88 | 89 | @property 90 | def service_host(self) -> str: 91 | """Get the service host.""" 92 | with self._pci_attribute_lock: 93 | return self._api_host 94 | 95 | @service_host.setter 96 | @typechecked 97 | def service_host(self, host: str): 98 | """Set the service host. 99 | 100 | Raises: 101 | ValueError if host is not valid. 102 | """ 103 | self.check_service_host(host) 104 | with self._pci_attribute_lock: 105 | self._api_host = host 106 | 107 | @property 108 | def detailed_debug_logging(self) -> bool: 109 | """Get the detailed debug logging flag.""" 110 | with self._pci_attribute_lock: 111 | return self._detailed_debug_logging 112 | 113 | @detailed_debug_logging.setter 114 | @typechecked 115 | def detailed_debug_logging(self, value: bool): 116 | """Set the detailed debug logging flag.""" 117 | with self._pci_attribute_lock: 118 | self._detailed_debug_logging = value 119 | 120 | @property 121 | def debug_locks(self) -> bool: 122 | """Get the debug locks flag.""" 123 | with self._pci_attribute_lock: 124 | return self._debug_locks 125 | 126 | @debug_locks.setter 127 | @typechecked 128 | def debug_locks(self, value: bool): 129 | """Set the debug locks flag.""" 130 | with self._pci_attribute_lock: 131 | self._debug_locks = value 132 | 133 | @typechecked 134 | def check_sync(self, message: str) -> AbstractEventLoop: 135 | """Checks if sync login was performed. 136 | 137 | Returns the loop to use for run_coroutine_threadsafe if so. 138 | Raises RuntimeError with given message if not. 139 | """ 140 | with self._pci_attribute_lock: 141 | if self._loop is None: 142 | raise RuntimeError(message) 143 | return self._loop 144 | 145 | @typechecked 146 | def check_async(self, message: str) -> None: 147 | """Checks if async login was performed. 148 | 149 | Raises RuntimeError with given message if not. 150 | """ 151 | with self._pci_attribute_lock: 152 | if self._loop is not None: 153 | raise RuntimeError(message) 154 | 155 | @property 156 | def loop(self) -> AbstractEventLoop | None: 157 | """Get the event loop.""" 158 | with self._pci_attribute_lock: 159 | return self._loop 160 | 161 | @loop.setter 162 | @typechecked 163 | def loop(self, loop: AbstractEventLoop | None): 164 | """Set the event loop.""" 165 | with self._pci_attribute_lock: 166 | self._loop = loop 167 | 168 | @property 169 | def session(self) -> ClientSession: 170 | """Get the session.""" 171 | with self._pci_attribute_lock: 172 | if self._session is None: 173 | self._session = ClientSession() 174 | self._set_headers() 175 | return self._session 176 | 177 | @property 178 | def api_version(self) -> str: 179 | """Get the API version.""" 180 | with self._pci_attribute_lock: 181 | return self._api_version 182 | 183 | @api_version.setter 184 | @typechecked 185 | def api_version(self, version: str): 186 | """Set the API version. 187 | 188 | Raises: 189 | ValueError: if version is not in the form major.minor.patch-subpatch 190 | """ 191 | 192 | def check_version_string(value: str): 193 | parts = value.split("-") 194 | if len(parts) == 2: 195 | version_parts = parts[0].split(".") 196 | if not ( 197 | version_parts[0].isdigit() 198 | and version_parts[1].isdigit() 199 | and version_parts[2].isdigit() 200 | and parts[1].isdigit() 201 | ): 202 | raise ValueError( 203 | "API version must be in the form major.minor.patch-subpatch" 204 | ) 205 | if len(version_parts) == 3 and version_parts[0].isdigit(): 206 | major_version = int(version_parts[0]) 207 | if major_version >= 26: 208 | return 209 | else: 210 | raise ValueError("API version is numeric but less than 26") 211 | raise ValueError( 212 | "API version must be in the form major.minor.patch-subpatch" 213 | ) 214 | 215 | with self._pci_attribute_lock: 216 | check_version_string(version) 217 | self._api_version = version 218 | 219 | @typechecked 220 | def make_url(self, uri: str) -> str: 221 | """Create a URL to service host from a URI. 222 | 223 | Args: 224 | uri (str): the URI to convert 225 | 226 | Returns: 227 | str: the converted string 228 | """ 229 | with self._pci_attribute_lock: 230 | return f"{self._api_host}{API_PREFIX}{self._api_version}{uri}" 231 | 232 | async def clear_session(self): 233 | """Clear the session.""" 234 | with self._pci_attribute_lock: 235 | old_session = self._session 236 | self._session = None 237 | if old_session: 238 | await old_session.close() 239 | -------------------------------------------------------------------------------- /pyadtpulse/pulse_connection_status.py: -------------------------------------------------------------------------------- 1 | """Pulse Connection Status.""" 2 | 3 | from asyncio import Event 4 | 5 | from typeguard import typechecked 6 | 7 | from .pulse_backoff import PulseBackoff 8 | from .util import set_debug_lock 9 | 10 | 11 | class PulseConnectionStatus: 12 | """Pulse Connection Status.""" 13 | 14 | __slots__ = ( 15 | "_backoff", 16 | "_authenticated_flag", 17 | "_pcs_attribute_lock", 18 | ) 19 | 20 | @typechecked 21 | def __init__(self, debug_locks: bool = False, detailed_debug_logging=False): 22 | self._pcs_attribute_lock = set_debug_lock( 23 | debug_locks, "pyadtpulse.pcs_attribute_lock" 24 | ) 25 | """Initialize the connection status object. 26 | 27 | Args: 28 | debug_locks (bool, optional): Enable debug locks. Defaults to False. 29 | detailed_debug_logging (bool, optional): Enable detailed debug logging for the backoff. 30 | Defaults to False. 31 | """ 32 | self._backoff = PulseBackoff( 33 | "Connection Status", 34 | initial_backoff_interval=1, 35 | detailed_debug_logging=detailed_debug_logging, 36 | ) 37 | self._authenticated_flag = Event() 38 | 39 | @property 40 | def authenticated_flag(self) -> Event: 41 | """Get the authenticated flag.""" 42 | with self._pcs_attribute_lock: 43 | return self._authenticated_flag 44 | 45 | @property 46 | def retry_after(self) -> float: 47 | """Get the number of seconds to wait before retrying HTTP requests.""" 48 | with self._pcs_attribute_lock: 49 | return self._backoff.expiration_time 50 | 51 | @retry_after.setter 52 | @typechecked 53 | def retry_after(self, seconds: float) -> None: 54 | """Set time after which HTTP requests can be retried.""" 55 | with self._pcs_attribute_lock: 56 | self._backoff.set_absolute_backoff_time(seconds) 57 | 58 | def get_backoff(self) -> PulseBackoff: 59 | """Get the backoff object.""" 60 | return self._backoff 61 | 62 | @property 63 | def detailed_debug_logging(self) -> bool: 64 | """Get the detailed debug logging flag.""" 65 | with self._pcs_attribute_lock: 66 | return self._backoff.detailed_debug_logging 67 | 68 | @detailed_debug_logging.setter 69 | @typechecked 70 | def detailed_debug_logging(self, value: bool): 71 | """Set the detailed debug logging flag.""" 72 | with self._pcs_attribute_lock: 73 | self._backoff.detailed_debug_logging = value 74 | -------------------------------------------------------------------------------- /pyadtpulse/pyadtpulse_properties.py: -------------------------------------------------------------------------------- 1 | """PyADTPulse Properties.""" 2 | 3 | import logging 4 | import asyncio 5 | from warnings import warn 6 | 7 | from typeguard import typechecked 8 | 9 | from .const import ( 10 | ADT_DEFAULT_KEEPALIVE_INTERVAL, 11 | ADT_DEFAULT_RELOGIN_INTERVAL, 12 | ADT_MAX_KEEPALIVE_INTERVAL, 13 | ADT_MIN_RELOGIN_INTERVAL, 14 | ) 15 | from .site import ADTPulseSite 16 | from .util import set_debug_lock 17 | 18 | LOG = logging.getLogger(__name__) 19 | 20 | 21 | class PyADTPulseProperties: 22 | """PyADTPulse Properties.""" 23 | 24 | __slots__ = ( 25 | "_updates_exist", 26 | "_pp_attribute_lock", 27 | "_relogin_interval", 28 | "_keepalive_interval", 29 | "_site", 30 | ) 31 | 32 | @staticmethod 33 | @typechecked 34 | def _check_keepalive_interval(keepalive_interval: int) -> None: 35 | if keepalive_interval > ADT_MAX_KEEPALIVE_INTERVAL or keepalive_interval <= 0: 36 | raise ValueError( 37 | f"keepalive interval ({keepalive_interval}) must be " 38 | f"greater than 0 and less than {ADT_MAX_KEEPALIVE_INTERVAL}" 39 | ) 40 | 41 | @staticmethod 42 | @typechecked 43 | def _check_relogin_interval(relogin_interval: int) -> None: 44 | if relogin_interval < ADT_MIN_RELOGIN_INTERVAL: 45 | raise ValueError( 46 | f"relogin interval ({relogin_interval}) must be " 47 | f"greater than {ADT_MIN_RELOGIN_INTERVAL}" 48 | ) 49 | 50 | @typechecked 51 | def __init__( 52 | self, 53 | keepalive_interval: int = ADT_DEFAULT_KEEPALIVE_INTERVAL, 54 | relogin_interval: int = ADT_DEFAULT_RELOGIN_INTERVAL, 55 | debug_locks: bool = False, 56 | ) -> None: 57 | """Create a PyADTPulse properties object. 58 | Args: 59 | pulse_authentication_properties (PulseAuthenticationProperties): 60 | an instance of PulseAuthenticationProperties 61 | pulse_connection_properties (PulseConnectionProperties): 62 | """ 63 | # FIXME use thread event/condition, regular condition? 64 | # defer initialization to make sure we have an event loop 65 | 66 | self._updates_exist = asyncio.locks.Event() 67 | 68 | self._pp_attribute_lock = set_debug_lock( 69 | debug_locks, "pyadtpulse.async_attribute_lock" 70 | ) 71 | 72 | self._site: ADTPulseSite | None = None 73 | self.keepalive_interval = keepalive_interval 74 | self.relogin_interval = relogin_interval 75 | 76 | @property 77 | def relogin_interval(self) -> int: 78 | """Get re-login interval. 79 | 80 | Returns: 81 | int: number of minutes to re-login to Pulse 82 | 0 means disabled 83 | """ 84 | with self._pp_attribute_lock: 85 | return self._relogin_interval 86 | 87 | @relogin_interval.setter 88 | @typechecked 89 | def relogin_interval(self, interval: int | None) -> None: 90 | """Set re-login interval. 91 | 92 | Args: 93 | interval (int|None): The number of minutes between logins. 94 | If set to None, resets to ADT_DEFAULT_RELOGIN_INTERVAL 95 | 96 | Raises: 97 | ValueError: if a relogin interval of less than ADT_MIN_RELOGIN_INTERVAL 98 | minutes is specified 99 | """ 100 | if interval is None: 101 | interval = ADT_DEFAULT_RELOGIN_INTERVAL 102 | else: 103 | self._check_relogin_interval(interval) 104 | with self._pp_attribute_lock: 105 | self._relogin_interval = interval 106 | LOG.debug("relogin interval set to %d", self._relogin_interval) 107 | 108 | @property 109 | def keepalive_interval(self) -> int: 110 | """Get the keepalive interval in minutes. 111 | 112 | Returns: 113 | int: the keepalive interval 114 | """ 115 | with self._pp_attribute_lock: 116 | return self._keepalive_interval 117 | 118 | @keepalive_interval.setter 119 | @typechecked 120 | def keepalive_interval(self, interval: int | None) -> None: 121 | """Set the keepalive interval in minutes. 122 | 123 | Args: 124 | interval (int|None): The number of minutes between keepalive calls 125 | If set to None, resets to ADT_DEFAULT_KEEPALIVE_INTERVAL 126 | 127 | Raises: 128 | ValueError: if a keepalive interval of greater than ADT_MAX_KEEPALIVE_INTERVAL 129 | minutes is specified 130 | """ 131 | if interval is None: 132 | interval = ADT_DEFAULT_KEEPALIVE_INTERVAL 133 | else: 134 | self._check_keepalive_interval(interval) 135 | with self._pp_attribute_lock: 136 | self._keepalive_interval = interval 137 | LOG.debug("keepalive interval set to %d", self._keepalive_interval) 138 | 139 | @property 140 | def sites(self) -> list[ADTPulseSite]: 141 | """Return all sites for this ADT Pulse account.""" 142 | warn( 143 | "multiple sites being removed, use pyADTPulse.site instead", 144 | PendingDeprecationWarning, 145 | stacklevel=2, 146 | ) 147 | with self._pp_attribute_lock: 148 | if self._site is None: 149 | raise RuntimeError( 150 | "No sites have been retrieved, have you logged in yet?" 151 | ) 152 | return [self._site] 153 | 154 | @property 155 | def site(self) -> ADTPulseSite: 156 | """Return the site associated with the Pulse login.""" 157 | with self._pp_attribute_lock: 158 | if self._site is None: 159 | raise RuntimeError( 160 | "No sites have been retrieved, have you logged in yet?" 161 | ) 162 | return self._site 163 | 164 | def set_update_status(self) -> None: 165 | """Sets updates_exist to notify wait_for_update.""" 166 | with self._pp_attribute_lock: 167 | self.updates_exist.set() 168 | 169 | @property 170 | def updates_exist(self) -> asyncio.locks.Event: 171 | """Check if updates exist.""" 172 | with self._pp_attribute_lock: 173 | return self._updates_exist 174 | -------------------------------------------------------------------------------- /pyadtpulse/site_properties.py: -------------------------------------------------------------------------------- 1 | """Pulse Site Properties.""" 2 | 3 | from threading import RLock 4 | from warnings import warn 5 | 6 | from typeguard import typechecked 7 | 8 | from .alarm_panel import ADTPulseAlarmPanel 9 | from .gateway import ADTPulseGateway 10 | from .util import DebugRLock, set_debug_lock 11 | from .zones import ADTPulseFlattendZone, ADTPulseZones 12 | 13 | 14 | class ADTPulseSiteProperties: 15 | """Pulse Site Properties.""" 16 | 17 | __slots__ = ( 18 | "_id", 19 | "_name", 20 | "_last_updated", 21 | "_alarm_panel", 22 | "_zones", 23 | "_site_lock", 24 | "_gateway", 25 | ) 26 | 27 | @typechecked 28 | def __init__(self, site_id: str, name: str, debug_locks: bool = False): 29 | self._id = site_id 30 | self._name = name 31 | self._last_updated: int = 0 32 | self._zones = ADTPulseZones() 33 | self._site_lock: RLock | DebugRLock 34 | self._site_lock = set_debug_lock(debug_locks, "pyadtpulse.site_property_lock") 35 | self._alarm_panel = ADTPulseAlarmPanel() 36 | self._gateway = ADTPulseGateway() 37 | 38 | @property 39 | def id(self) -> str: 40 | """Get site id. 41 | 42 | Returns: 43 | str: the site id 44 | """ 45 | return self._id 46 | 47 | @property 48 | def name(self) -> str: 49 | """Get site name. 50 | 51 | Returns: 52 | str: the site name 53 | """ 54 | return self._name 55 | 56 | # FIXME: should this actually return if the alarm is going off!? How do we 57 | # return state that shows the site is compromised?? 58 | 59 | @property 60 | def last_updated(self) -> int: 61 | """Return time site last updated. 62 | 63 | Returns: 64 | int: the time site last updated as datetime 65 | """ 66 | with self._site_lock: 67 | return self._last_updated 68 | 69 | @property 70 | def site_lock(self) -> "RLock| DebugRLock": 71 | """Get thread lock for site data. 72 | 73 | Not needed for async 74 | 75 | Returns: 76 | RLock: thread RLock 77 | """ 78 | return self._site_lock 79 | 80 | @property 81 | def zones(self) -> list[ADTPulseFlattendZone] | None: 82 | """Return all zones registered with the ADT Pulse account. 83 | 84 | (cached copy of last fetch) 85 | See Also fetch_zones() 86 | """ 87 | with self._site_lock: 88 | if not self._zones: 89 | raise RuntimeError("No zones exist") 90 | return self._zones.flatten() 91 | 92 | @property 93 | def zones_as_dict(self) -> ADTPulseZones | None: 94 | """Return zone information in dictionary form. 95 | 96 | Returns: 97 | ADTPulseZones: all zone information 98 | """ 99 | with self._site_lock: 100 | if not self._zones: 101 | raise RuntimeError("No zones exist") 102 | return self._zones 103 | 104 | @property 105 | def alarm_control_panel(self) -> ADTPulseAlarmPanel: 106 | """Return the alarm panel object for the site. 107 | 108 | Returns: 109 | Optional[ADTPulseAlarmPanel]: the alarm panel object 110 | """ 111 | return self._alarm_panel 112 | 113 | @property 114 | def gateway(self) -> ADTPulseGateway: 115 | """Get gateway device object. 116 | 117 | Returns: 118 | ADTPulseGateway: Gateway device 119 | """ 120 | return self._gateway 121 | 122 | @property 123 | def updates_may_exist(self) -> bool: 124 | """Query whether updated sensor data exists. 125 | 126 | Deprecated, use method on pyADTPulse object instead 127 | """ 128 | # FIXME: this should actually capture the latest version 129 | # and compare if different!!! 130 | # ...this doesn't actually work if other components are also checking 131 | # if updates exist 132 | warn( 133 | "updates_may_exist on site object is deprecated, " 134 | "use method on pyADTPulse object instead", 135 | DeprecationWarning, 136 | stacklevel=2, 137 | ) 138 | return False 139 | 140 | async def async_update(self) -> bool: 141 | """Force update site/zone data async with current data. 142 | 143 | Deprecated, use method on pyADTPulse object instead 144 | """ 145 | warn( 146 | "updating zones from site object is deprecated, " 147 | "use method on pyADTPulse object instead", 148 | DeprecationWarning, 149 | stacklevel=2, 150 | ) 151 | return False 152 | 153 | def update(self) -> bool: 154 | """Force update site/zones with current data. 155 | 156 | Deprecated, use method on pyADTPulse object instead 157 | """ 158 | warn( 159 | "updating zones from site object is deprecated, " 160 | "use method on pyADTPulse object instead", 161 | DeprecationWarning, 162 | stacklevel=2, 163 | ) 164 | return False 165 | -------------------------------------------------------------------------------- /pyadtpulse/util.py: -------------------------------------------------------------------------------- 1 | """Utility functions for pyadtpulse.""" 2 | 3 | import logging 4 | import string 5 | import sys 6 | from base64 import urlsafe_b64encode 7 | from datetime import datetime, timedelta 8 | from pathlib import Path 9 | from random import randint 10 | from threading import RLock, current_thread 11 | 12 | from lxml import html 13 | from yarl import URL 14 | 15 | LOG = logging.getLogger(__name__) 16 | 17 | 18 | def remove_prefix(text: str, prefix: str) -> str: 19 | """Remove prefix from a string. 20 | 21 | Args: 22 | text (str): original text 23 | prefix (str): prefix to remove 24 | 25 | Returns: 26 | str: modified string 27 | """ 28 | return text[text.startswith(prefix) and len(prefix) :] 29 | 30 | 31 | def handle_response(code: int, url: URL | None, level: int, error_message: str) -> bool: 32 | """Handle the response from query(). 33 | 34 | Args: 35 | code (int): the return code 36 | level (int): Level to log on error (i.e. INFO, DEBUG) 37 | error_message (str): the error message 38 | 39 | Returns: 40 | bool: True if no error occurred. 41 | """ 42 | if code >= 400: 43 | LOG.log(level, "%s: error code = %s from %s", error_message, code, url) 44 | return False 45 | return True 46 | 47 | 48 | def make_etree( 49 | code: int, 50 | response_text: str | None, 51 | url: URL | None, 52 | level: int, 53 | error_message: str, 54 | ) -> html.HtmlElement | None: 55 | """Make a parsed HTML tree from a Response using lxml. 56 | 57 | Args: 58 | code (int): the return code 59 | response_text (Optional[str]): the response text 60 | level (int): the logging level on error 61 | error_message (str): the error message 62 | 63 | Returns: 64 | Optional[html.HtmlElement]: a parsed HTML tree, or None on failure 65 | """ 66 | if not handle_response(code, url, level, error_message): 67 | return None 68 | if response_text is None: 69 | LOG.log(level, "%s: no response received from %s", error_message, url) 70 | return None 71 | return html.fromstring(response_text) 72 | 73 | 74 | FINGERPRINT_LENGTH = 2292 75 | ALLOWABLE_CHARACTERS = list(string.ascii_letters + string.digits) 76 | FINGERPRINT_RANGE_LEN = len(ALLOWABLE_CHARACTERS) 77 | 78 | 79 | def generate_random_fingerprint() -> str: 80 | """Generate a random browser fingerprint string. 81 | 82 | Returns: 83 | str: a fingerprint string 84 | """ 85 | fingerprint = [ 86 | ALLOWABLE_CHARACTERS[(randint(0, FINGERPRINT_RANGE_LEN - 1))] 87 | for i in range(FINGERPRINT_LENGTH) 88 | ] 89 | return "".join(fingerprint) 90 | 91 | 92 | def generate_fingerprint_from_browser_json(filename: str) -> str: 93 | """Generate a browser fingerprint from a JSON file. 94 | 95 | Args: 96 | filename (str): JSON file containing fingerprint information 97 | 98 | Returns: 99 | str: the fingerprint 100 | """ 101 | data = Path(filename).read_text(encoding="utf-8") 102 | # Pulse just calls JSON.Stringify() and btoa() in javascript, so we need to 103 | # do this to emulate that 104 | data2 = "".join(data.split()) 105 | return str(urlsafe_b64encode(data2.encode("utf-8")), "utf-8") 106 | 107 | 108 | class DebugRLock: 109 | """Provides a debug lock logging caller who acquired/released.""" 110 | 111 | def __init__(self, name: str): 112 | """Create the lock.""" 113 | self._Rlock = RLock() 114 | self._lock_name = name 115 | 116 | def acquire(self, blocking: bool = True, timeout: float = -1) -> bool: 117 | """Acquire the lock. 118 | 119 | Args: 120 | blocking (bool, optional): blocks if can't obtain the lock if True. 121 | Defaults to True. 122 | timeout (float, optional): timeout to wait to acquire the lock. 123 | Defaults to -1. 124 | 125 | Returns: 126 | bool: True if lock obtained, False if blocking is False and lock couldn't be 127 | obtained 128 | """ 129 | caller = sys._getframe().f_back 130 | thread_name = current_thread().name 131 | if caller is not None: 132 | caller2 = caller.f_code.co_name 133 | else: 134 | caller2 = "*Unknown*" 135 | LOG.debug( 136 | "acquiring lock %s blocking: %s from %s from thread %s", 137 | self._lock_name, 138 | blocking, 139 | caller2, 140 | thread_name, 141 | ) 142 | retval = self._Rlock.acquire(blocking, timeout) 143 | LOG.debug( 144 | "acquisition of %s from %s from thread %s returned %d info: %s", 145 | self._lock_name, 146 | caller2, 147 | thread_name, 148 | retval, 149 | repr(self._Rlock), 150 | ) 151 | return retval 152 | 153 | __enter__ = acquire 154 | 155 | def release(self) -> None: 156 | """Releases the lock.""" 157 | caller = sys._getframe().f_back 158 | if caller is not None: 159 | caller2 = caller.f_code.co_name 160 | else: 161 | caller2 = "*Unknown*" 162 | thread_name = current_thread().name 163 | LOG.debug( 164 | "attempting to release lock %s from %s in thread %s", 165 | self._lock_name, 166 | caller2, 167 | thread_name, 168 | ) 169 | self._Rlock.release() 170 | LOG.debug( 171 | "released lock %s from %s in thread %s info: %s", 172 | self._lock_name, 173 | caller2, 174 | thread_name, 175 | repr(self._Rlock), 176 | ) 177 | 178 | def __exit__(self, t, v, b): 179 | """Automatically release lock on exit. 180 | 181 | Args: 182 | t (_type_): _description_ 183 | v (_type_): _description_ 184 | b (_type_): _description_ 185 | """ 186 | caller = sys._getframe().f_back 187 | if caller is not None: 188 | caller2 = caller.f_code.co_name 189 | else: 190 | caller2 = "*Unknown*" 191 | thread_name = current_thread().name 192 | LOG.debug( 193 | "released lock %s from %s in thread %s at exit", 194 | self._lock_name, 195 | caller2, 196 | thread_name, 197 | ) 198 | 199 | self._Rlock.release() 200 | 201 | 202 | def parse_pulse_datetime(datestring: str) -> datetime: 203 | """Parse pulse date strings. 204 | 205 | Args: 206 | datestring (str): the string to parse 207 | 208 | Raises: 209 | ValueError: pass through of value error if string 210 | cannot be converted 211 | 212 | Returns: 213 | datetime: time value of given string 214 | """ 215 | datestring = datestring.replace("\xa0", " ").rstrip() 216 | split_string = [s for s in datestring.split(" ") if s.strip()] 217 | if len(split_string) < 3: 218 | raise ValueError("Invalid datestring") 219 | t = datetime.today() 220 | if split_string[0].lstrip() == "Today": 221 | last_update = t 222 | elif split_string[0].lstrip() == "Yesterday": 223 | last_update = t - timedelta(days=1) 224 | else: 225 | tempdate = f"{split_string[0]}/{t.year}" 226 | last_update = datetime.strptime(tempdate, "%m/%d/%Y") 227 | if last_update > t: 228 | last_update = last_update.replace(year=t.year - 1) 229 | update_time = datetime.time( 230 | datetime.strptime(split_string[1] + split_string[2], "%I:%M%p") 231 | ) 232 | last_update = datetime.combine(last_update, update_time) 233 | return last_update 234 | 235 | 236 | def set_debug_lock(debug_lock: bool, name: str) -> "RLock | DebugRLock": 237 | """Set lock or debug lock 238 | 239 | Args: 240 | debug_lock (bool): set a debug lock 241 | name (str): debug lock name 242 | 243 | Returns: 244 | RLock | DebugRLock: lock object to return 245 | """ 246 | if debug_lock: 247 | return DebugRLock(name) 248 | return RLock() 249 | -------------------------------------------------------------------------------- /pyadtpulse/zones.py: -------------------------------------------------------------------------------- 1 | """ADT Pulse zone info.""" 2 | 3 | import logging 4 | from collections import UserDict 5 | from dataclasses import dataclass 6 | from datetime import datetime 7 | from typing import TypedDict 8 | 9 | from typeguard import typechecked 10 | 11 | ADT_NAME_TO_DEFAULT_TAGS: dict[str, tuple[str, str]] = { 12 | "Door": ("sensor", "doorWindow"), 13 | "Window": ("sensor", "doorWindow"), 14 | "Motion": ("sensor", "motion"), 15 | "Glass": ("sensor", "glass"), 16 | "Gas": ("sensor", "co"), 17 | "Carbon": ("sensor", "co"), 18 | "Smoke": ("sensor", "smoke"), 19 | "Flood": ("sensor", "flood"), 20 | "Floor": ("sensor", "flood"), 21 | "Moisture": ("sensor", "flood"), 22 | } 23 | 24 | LOG = logging.getLogger(__name__) 25 | 26 | 27 | @dataclass(slots=True) 28 | class ADTPulseZoneData: 29 | """Data for an ADT Pulse zone. 30 | 31 | Fields: 32 | name (str): the zone name 33 | id_ (str): zone name in ADT Pulse (Note, not id as this is a reserved word) 34 | tags (Tuple): sensor and type(s) 35 | status (str): sensor status (i.e. Online, Low Battery, etc.) 36 | state (str): sensor state (i.e. Opened, Closed, etc) 37 | timestamp (datetime): timestamp of last activity 38 | 39 | Will set unknown type defaults to all but name and id_ 40 | """ 41 | 42 | name: str 43 | id_: str 44 | _tags: tuple[str, str] = ADT_NAME_TO_DEFAULT_TAGS["Window"] 45 | status: str = "Unknown" 46 | state: str = "Unknown" 47 | _last_activity_timestamp: int = 0 48 | 49 | @property 50 | def last_activity_timestamp(self) -> int: 51 | """Return the last activity timestamp.""" 52 | return self._last_activity_timestamp 53 | 54 | @last_activity_timestamp.setter 55 | @typechecked 56 | def last_activity_timestamp(self, value: int) -> None: 57 | """Set the last activity timestamp.""" 58 | self._last_activity_timestamp = value 59 | 60 | @property 61 | def tags(self) -> tuple[str, str]: 62 | """Return the tags.""" 63 | return self._tags 64 | 65 | @tags.setter 66 | @typechecked 67 | def tags(self, value: tuple[str, str]) -> None: 68 | """Set the tags.""" 69 | if value not in ADT_NAME_TO_DEFAULT_TAGS.values(): 70 | raise ValueError("tags must be one of: " + str(ADT_NAME_TO_DEFAULT_TAGS)) 71 | self._tags = value 72 | 73 | 74 | class ADTPulseFlattendZone(TypedDict): 75 | """Represent ADTPulseZones as a "flattened" dictionary. 76 | 77 | Fields: 78 | zone (int): the zone id 79 | name (str): the zone name 80 | id_ (str): zone name in ADT Pulse (Note, not id as this is a reserved word) 81 | tags (Tuple): sensor and type(s) 82 | status (str): sensor status (i.e. Online, Low Battery, etc.) 83 | state (str): sensor state (i.e. Opened, Closed, etc) 84 | timestamp (datetime): timestamp of last activity 85 | """ 86 | 87 | zone: int 88 | name: str 89 | id_: str 90 | tags: tuple 91 | status: str 92 | state: str 93 | last_activity_timestamp: int 94 | 95 | 96 | class ADTPulseZones(UserDict): 97 | """Dictionary containing ADTPulseZoneData with zone as the key.""" 98 | 99 | @staticmethod 100 | def _check_value(value: ADTPulseZoneData) -> None: 101 | if not isinstance(value, ADTPulseZoneData): 102 | raise ValueError("ADT Pulse zone data must be of type ADTPulseZoneData") 103 | 104 | @staticmethod 105 | def _check_key(key: int) -> None: 106 | if not isinstance(key, int): 107 | raise ValueError("ADT Pulse Zone must be an integer") 108 | 109 | def __getitem__(self, key: int) -> ADTPulseZoneData: 110 | """Get a Zone. 111 | 112 | Args: 113 | key (int): zone id 114 | 115 | Returns: 116 | ADTPulseZoneData: zone data 117 | """ 118 | return super().__getitem__(key) 119 | 120 | def _get_zonedata(self, key: int) -> ADTPulseZoneData: 121 | self._check_key(key) 122 | result: ADTPulseZoneData = self.data[key] 123 | self._check_value(result) 124 | return result 125 | 126 | def __setitem__(self, key: int, value: ADTPulseZoneData) -> None: 127 | """Validate types and sets defaults for ADTPulseZones. 128 | 129 | ADTPulseZoneData.id_ and name will be set to generic defaults if not set 130 | 131 | Raises: 132 | ValueError: if key is not an int or value is not ADTPulseZoneData 133 | """ 134 | self._check_key(key) 135 | self._check_value(value) 136 | if not value.id_: 137 | value.id_ = "sensor-" + str(key) 138 | if not value.name: 139 | value.name = "Sensor for Zone " + str(key) 140 | super().__setitem__(key, value) 141 | 142 | @typechecked 143 | def update_status(self, key: int, status: str) -> None: 144 | """Update zone status. 145 | 146 | Args: 147 | key (int): zone id to change 148 | status (str): status to set 149 | """ """""" 150 | temp = self._get_zonedata(key) 151 | temp.status = status 152 | self.__setitem__(key, temp) 153 | 154 | @typechecked 155 | def update_state(self, key: int, state: str) -> None: 156 | """Update zone state. 157 | 158 | Args: 159 | key (int): zone id to change 160 | state (str): state to set 161 | """ 162 | temp = self._get_zonedata(key) 163 | temp.state = state 164 | self.__setitem__(key, temp) 165 | 166 | @typechecked 167 | def update_last_activity_timestamp(self, key: int, dt: datetime) -> None: 168 | """Update timestamp. 169 | 170 | Args: 171 | key (int): zone id to change 172 | dt (datetime): timestamp to set 173 | """ 174 | temp = self._get_zonedata(key) 175 | temp.last_activity_timestamp = int(dt.timestamp()) 176 | self.__setitem__(key, temp) 177 | 178 | @typechecked 179 | def update_device_info( 180 | self, 181 | key: int, 182 | state: str, 183 | status: str = "Online", 184 | last_activity: datetime = datetime.now(), 185 | ) -> None: 186 | """Update the device info. 187 | 188 | Convenience method to update all common device info 189 | at once. 190 | 191 | Args: 192 | key (int): zone id 193 | state (str): state 194 | status (str, optional): status. Defaults to "Online". 195 | last_activity (datetime, optional): last_activity_datetime. 196 | Defaults to datetime.now(). 197 | """ 198 | temp = self._get_zonedata(key) 199 | temp.state = state 200 | temp.status = status 201 | temp.last_activity_timestamp = int(last_activity.timestamp()) 202 | self.__setitem__(key, temp) 203 | 204 | def flatten(self) -> list[ADTPulseFlattendZone]: 205 | """Flattens ADTPulseZones into a list of ADTPulseFlattenedZones. 206 | 207 | Returns: 208 | List[ADTPulseFlattendZone] 209 | """ 210 | result: list[ADTPulseFlattendZone] = [] 211 | for k, i in self.items(): 212 | if not isinstance(i, ADTPulseZoneData): 213 | raise ValueError("Invalid Zone data in ADTPulseZones") 214 | result.append( 215 | { 216 | "zone": k, 217 | "name": i.name, 218 | "id_": i.id_, 219 | "tags": i.tags, 220 | "status": i.status, 221 | "state": i.state, 222 | "last_activity_timestamp": i.last_activity_timestamp, 223 | } 224 | ) 225 | return result 226 | 227 | @typechecked 228 | def update_zone_attributes(self, dev_attr: dict[str, str]) -> None: 229 | """Update zone attributes.""" 230 | d_name = dev_attr.get("name", "Unknown") 231 | d_type = dev_attr.get("type_model", "Unknown") 232 | d_zone = dev_attr.get("zone", "Unknown") 233 | d_status = dev_attr.get("status", "Unknown") 234 | 235 | if d_zone != "Unknown": 236 | tags = None 237 | for search_term, default_tags in ADT_NAME_TO_DEFAULT_TAGS.items(): 238 | # convert to uppercase first 239 | if search_term.upper() in d_type.upper(): 240 | tags = default_tags 241 | break 242 | if not tags: 243 | LOG.warning( 244 | "Unknown sensor type for '%s', defaulting to doorWindow", d_type 245 | ) 246 | tags = ("sensor", "doorWindow") 247 | LOG.debug( 248 | "Retrieved sensor %s id: sensor-%s Status: %s, tags %s", 249 | d_name, 250 | d_zone, 251 | d_status, 252 | tags, 253 | ) 254 | if "Unknown" in (d_name, d_status, d_zone) or not d_zone.isdecimal(): 255 | LOG.debug("Zone data incomplete, skipping...") 256 | else: 257 | tmpzone = ADTPulseZoneData(d_name, f"sensor-{d_zone}", tags, d_status) 258 | self.update({int(d_zone): tmpzone}) 259 | else: 260 | LOG.debug( 261 | "Skipping incomplete zone name: %s, zone: %s status: %s", 262 | d_name, 263 | d_zone, 264 | d_status, 265 | ) 266 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | reports=no 3 | 4 | disable= 5 | locally-disabled 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pyadtpulse" 3 | version = "1.2.11" 4 | description = "Python interface for ADT Pulse security systems" 5 | authors = ["Ryan Snodgrass"] 6 | maintainers = ["Robert Lippmann"] 7 | license = "Apache-2.0" 8 | readme = "README.md" 9 | repository = "https://github.com/rlippmann/pyadtpulse" 10 | classifiers = [ 11 | "Programming Language :: Python :: 3", 12 | "License :: OSI Approved :: Apache Software License", 13 | "Operating System :: OS Independent" 14 | ] 15 | 16 | 17 | [tool.poetry.dependencies] 18 | python = "^3.13" 19 | aiohttp = ">=3.8.5, < 4.0" 20 | uvloop = ">=0.19.0" 21 | typeguard = "^4.1.5" 22 | yarl = ">=1.9, < 2.0" 23 | lxml = "^5.1.0" 24 | aiohttp-zlib-ng = ">=0.1.1" 25 | aioresponses = "^0.7.3" 26 | 27 | 28 | [tool.poetry.urls] 29 | "Changelog" = "https://github.com/rlippmann/pyadtpulse/blob/master/CHANGELOG.md" 30 | "Issues" = "https://github.com/rlippmann/pyadtpulse/issues" 31 | 32 | [tool.poetry.group.test.dependencies] 33 | pytest = "^7.4.3" 34 | pytest-asyncio = "^0.21.1" 35 | pytest-mock = "^3.13.0" 36 | pytest-aiohttp = "^1.0.5" 37 | pytest-timeout = "^2.2.0" 38 | aioresponses = "^0.7.6" 39 | freezegun = "^1.2.2" 40 | pytest-coverage = "^0.0" 41 | pytest-xdist = "^3.5.0" 42 | 43 | 44 | [tool.poetry.group.dev.dependencies] 45 | pre-commit = "^3.5.0" 46 | ruff = "^0.1.4" 47 | pycln = "^2.3.0" 48 | pyupgrade = "^3.15.0" 49 | isort = "^5.12.0" 50 | black = "^23.10.1" 51 | mypy = "^1.6.1" 52 | pylint = "^3.0.2" 53 | refurb = "^1.22.1" 54 | types-lxml = "^2024.2.9" 55 | 56 | [build-system] 57 | requires = ["poetry-core"] 58 | build-backend = ["poetry.core.masonry.api"] 59 | 60 | [tool.isort] 61 | profile = "black" 62 | force_to_top = [ "logging" ] 63 | balanced_wrapping = true 64 | 65 | [black] 66 | line-length = 90 67 | 68 | [tool.pycln] 69 | all = true 70 | 71 | [tool.pytest.ini_options] 72 | timeout = 30 73 | # addopts = "--cov=pyadtpulse --cov-report=html" 74 | 75 | [tool.pyright] 76 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | lxml>=5.1.0 2 | aiohttp>=3.9.1 3 | uvloop>=0.21.0 4 | typeguard>=4.1.5 5 | aiohttp-zlib-ng>=0.1.1 6 | freezegun 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description_file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | from os import path, system 5 | from pathlib import Path 6 | 7 | import setuptools 8 | 9 | from pyadtpulse.const import __version__ 10 | 11 | if sys.argv[-1] == "publish": 12 | system("python setup.py sdist upload") 13 | sys.exit() 14 | 15 | 16 | this_directory = path.abspath(path.dirname(__file__)) 17 | long_description = Path( 18 | path.join(this_directory, "README.md"), encoding="utf-8" 19 | ).read_text() 20 | 21 | setuptools.setup( 22 | name="pyadtpulse", 23 | version=__version__, 24 | packages=["pyadtpulse"], 25 | description="Python interface for ADT Pulse security systems", 26 | long_description=long_description, 27 | long_description_content_type="text/markdown", 28 | url="https://github.com/rlippmann/pyadtpulse", 29 | author="", 30 | author_email="", 31 | license="Apache Software License", 32 | install_requires=[ 33 | "aiohttp>=3.8.5", 34 | "uvloop>=0.21.0", 35 | "lxml>=5.1.0", 36 | "typeguard>=2.13.3", 37 | "yarl>=1.8.2", 38 | "aiohttp-zlib-ng>=0.1.1", 39 | ], 40 | keywords=["security system", "adt", "home automation", "security alarm"], 41 | zip_safe=True, 42 | classifiers=[ 43 | "Programming Language :: Python :: 3", 44 | "License :: OSI Approved :: Apache Software License", 45 | "Operating System :: OS Independent", 46 | ], 47 | ) 48 | -------------------------------------------------------------------------------- /tests/data_files/mfa.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | ADT Pulse(TM) Interactive Solutions - Multi-factor Authentication 74 | 75 | 76 | 77 | 79 | 80 |
81 |
82 |
83 |
84 |
85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 |
98 |
99 | 100 | 101 | 102 | 134 | 135 |
136 | 137 | 138 | 139 |
140 |
141 |
142 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /tests/data_files/not_signed_in.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | ADT Pulse(TM) Interactive Solutions - Sign In 47 | 48 | 49 | 50 | 52 | 53 |
54 |
55 |
56 |
57 |
58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 |
71 |
72 | 73 |
74 |
75 |
76 | 77 |
78 |

Please Sign In

79 | 80 |

81 | 82 | 83 | 84 | 89 | 90 | 91 | 111 | 112 | 113 | 116 | 117 | 118 | 119 | 120 | 121 | 122 |
85 | 87 |

88 |
92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 |

Password:
 
110 |
114 |
Sign In
115 |

Forgot your username or password?

123 | 124 | 125 |
126 | 127 |
128 |


129 | Your use of this site signifies that you accept the ADT Pulse Website Terms of Use Agreement. © 2023 ADT Security Services. All rights reserved. ADT, the ADT logo, 800.ADT.ASAP and the product/service names listed in this document are marks and/or registered marks. Unauthorized use is strictly prohibited. Unauthorized use of this site is prohibited and may be subject to civil and criminal prosecution. 130 |
131 | 132 | 133 |
134 |
135 | 136 |
137 |
138 |
139 | 146 | 147 | 148 | 149 | 178 | -------------------------------------------------------------------------------- /tests/data_files/signin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | ADT Pulse(TM) Interactive Solutions - Sign In 47 | 48 | 49 | 50 | 52 | 53 |
54 |
55 |
56 |
57 |
58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 |
71 |
72 | 73 |
74 |
75 |
76 | 77 |
78 |

Please Sign In

79 | 80 |

81 | 82 | 83 | 84 | 89 | 90 | 91 | 111 | 112 | 113 | 116 | 117 | 118 | 119 | 120 | 121 | 122 |
92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 |

Password:
 
110 |
114 |
Sign In
115 |

Forgot your username or password?

123 | 124 | 125 |
126 | 127 |
128 |


129 | Your use of this site signifies that you accept the ADT Pulse Website Terms of Use Agreement. © 2023 ADT Security Services. All rights reserved. ADT, the ADT logo, 800.ADT.ASAP and the product/service names listed in this document are marks and/or registered marks. Unauthorized use is strictly prohibited. Unauthorized use of this site is prohibited and may be subject to civil and criminal prosecution. 130 |
131 | 132 | 133 |
134 |
135 | 136 |
137 |
138 |
139 | 146 | 147 | 148 | 149 | 178 | -------------------------------------------------------------------------------- /tests/data_files/signin_fail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ADT Pulse(TM) Interactive Solutions - Sign In 46 | 47 | 48 | 49 | 51 | 52 |
53 |
54 |
55 |
56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 |
70 |
71 | 72 |
73 |
74 |
75 | 76 |
77 |

Please Sign In

78 | 79 |

80 | 81 | 82 | 83 | 88 | 89 | 90 | 110 | 111 | 112 | 115 | 116 | 117 | 118 | 119 | 120 | 121 |
84 | 86 |

87 |
91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |

Password:
 
109 |
113 |
Sign In
114 |

Forgot your username or password?

122 | 123 | 124 |
125 | 126 |
127 |


128 | Your use of this site signifies that you accept the ADT Pulse Website Terms of Use Agreement. © 2023 ADT Security Services. All rights reserved. ADT, the ADT logo, 800.ADT.ASAP and the product/service names listed in this document are marks and/or registered marks. Unauthorized use is strictly prohibited. Unauthorized use of this site is prohibited and may be subject to civil and criminal prosecution. 129 |
130 | 131 | 132 |
133 |
134 | 135 |
136 |
137 |
138 | 145 | 146 | 147 | 148 | 177 | -------------------------------------------------------------------------------- /tests/data_files/signin_locked.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ADT Pulse(TM) Interactive Solutions - Sign In 46 | 47 | 48 | 49 | 51 | 52 |
53 |
54 |
55 |
56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 |
70 |
71 | 72 |
73 |
74 |
75 | 76 |
77 |

Please Sign In

78 | 79 |

80 | 81 | 82 | 83 | 88 | 89 | 90 | 110 | 111 | 112 | 115 | 116 | 117 | 118 | 119 | 120 | 121 |
84 | 86 |

87 |
91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |

Password:
 
109 |
113 |
Sign In
114 |

Forgot your username or password?

122 | 123 | 124 |
125 | 126 |
127 |


128 | Your use of this site signifies that you accept the ADT Pulse Website Terms of Use Agreement. © 2023 ADT Security Services. All rights reserved. ADT, the ADT logo, 800.ADT.ASAP and the product/service names listed in this document are marks and/or registered marks. Unauthorized use is strictly prohibited. Unauthorized use of this site is prohibited and may be subject to civil and criminal prosecution. 129 |
130 | 131 | 132 |
133 |
134 | 135 |
136 |
137 |
138 | 145 | 146 | 147 | 148 | 177 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | # Generated by CodiumAI 2 | from time import time 3 | 4 | import pytest 5 | 6 | from pyadtpulse.exceptions import ( 7 | PulseAccountLockedError, 8 | PulseAuthenticationError, 9 | PulseClientConnectionError, 10 | PulseConnectionError, 11 | PulseExceptionWithBackoff, 12 | PulseExceptionWithRetry, 13 | PulseLoginException, 14 | PulseNotLoggedInError, 15 | PulseServerConnectionError, 16 | PulseServiceTemporarilyUnavailableError, 17 | ) 18 | from pyadtpulse.pulse_backoff import PulseBackoff 19 | 20 | 21 | class TestCodeUnderTest: 22 | # PulseExceptionWithBackoff can be initialized with a message and a PulseBackoff object 23 | def test_pulse_exception_with_backoff_initialization(self): 24 | backoff = PulseBackoff("test", 1.0) 25 | exception = PulseExceptionWithBackoff("error", backoff) 26 | assert str(exception) == "PulseExceptionWithBackoff: error" 27 | assert exception.backoff == backoff 28 | assert backoff.backoff_count == 1 29 | 30 | # PulseExceptionWithBackoff increments the backoff count when initialized 31 | def test_pulse_exception_with_backoff_increment(self): 32 | backoff = PulseBackoff("test", 1.0) 33 | exception = PulseExceptionWithBackoff("error", backoff) 34 | assert backoff.backoff_count == 1 35 | 36 | # PulseExceptionWithRetry can be initialized with a message, a PulseBackoff object, and a retry time 37 | def test_pulse_exception_with_retry_initialization(self): 38 | backoff = PulseBackoff("test", 1.0) 39 | retry_time = time() + 10 40 | exception = PulseExceptionWithRetry("error", backoff, retry_time) 41 | assert str(exception) == "PulseExceptionWithRetry: error" 42 | assert exception.backoff == backoff 43 | assert exception.retry_time == retry_time 44 | 45 | # PulseExceptionWithRetry resets the backoff count and sets an absolute backoff time if retry time is in the future 46 | def test_pulse_exception_with_retry_reset_and_set_absolute_backoff_time(self): 47 | backoff = PulseBackoff("test", 1.0) 48 | backoff.increment_backoff() 49 | retry_time = time() + 10 50 | exception = PulseExceptionWithRetry("error", backoff, retry_time) 51 | assert backoff.backoff_count == 0 52 | assert backoff.expiration_time == retry_time 53 | 54 | # PulseServerConnectionError is a subclass of PulseExceptionWithBackoff and PulseConnectionError 55 | def test_pulse_server_connection_error_inheritance_fixed(self): 56 | assert issubclass(PulseServerConnectionError, PulseExceptionWithBackoff) 57 | assert issubclass(PulseServerConnectionError, PulseConnectionError) 58 | 59 | # PulseClientConnectionError is a subclass of PulseExceptionWithBackoff and PulseConnectionError 60 | def test_pulse_client_connection_error_inheritance_fixed(self): 61 | assert issubclass(PulseClientConnectionError, PulseExceptionWithBackoff) 62 | assert issubclass(PulseClientConnectionError, PulseConnectionError) 63 | 64 | # PulseExceptionWithBackoff raises an exception if initialized with an invalid message or non-PulseBackoff object 65 | def test_pulse_exception_with_backoff_invalid_initialization(self): 66 | with pytest.raises(Exception): 67 | PulseExceptionWithBackoff(123, "backoff") 68 | 69 | # PulseExceptionWithRetry raises an exception if initialized with an invalid message, non-PulseBackoff object, or invalid retry time 70 | def test_pulse_exception_with_retry_invalid_initialization(self): 71 | backoff = PulseBackoff("test", 1.0) 72 | with pytest.raises(Exception): 73 | PulseExceptionWithRetry(123, backoff, "retry") 74 | with pytest.raises(Exception): 75 | PulseExceptionWithRetry("error", "backoff", time() + 10) 76 | with pytest.raises(Exception): 77 | PulseExceptionWithRetry("error", backoff, "retry") 78 | 79 | # PulseExceptionWithRetry does not reset the backoff count or set an absolute backoff time if retry time is in the past 80 | def test_pulse_exception_with_retry_past_retry_time(self): 81 | backoff = PulseBackoff("test", 1.0) 82 | backoff.increment_backoff() 83 | retry_time = time() - 10 84 | with pytest.raises(PulseExceptionWithRetry): 85 | raise PulseExceptionWithRetry( 86 | "retry must be in the future", backoff, retry_time 87 | ) 88 | # 1 backoff for increment 89 | assert backoff.backoff_count == 2 90 | assert backoff.expiration_time == 0.0 91 | 92 | # PulseServiceTemporarilyUnavailableError does not reset the backoff count or set an absolute backoff time if retry time is in the past 93 | def test_pulse_service_temporarily_unavailable_error_past_retry_time_fixed(self): 94 | backoff = PulseBackoff("test", 1.0) 95 | backoff.increment_backoff() 96 | retry_time = time() - 10 97 | with pytest.raises(PulseServiceTemporarilyUnavailableError): 98 | raise PulseServiceTemporarilyUnavailableError(backoff, retry_time) 99 | assert backoff.backoff_count == 2 100 | assert backoff.expiration_time == 0.0 101 | 102 | # PulseAuthenticationError is a subclass of PulseLoginException 103 | def test_pulse_authentication_error_inheritance(self): 104 | backoff = PulseBackoff("test", 1.0) 105 | exception = PulseAuthenticationError() 106 | assert isinstance(exception, PulseLoginException) 107 | 108 | # PulseServiceTemporarilyUnavailableError is a subclass of PulseExceptionWithRetry and PulseConnectionError 109 | def test_pulse_service_temporarily_unavailable_error(self): 110 | backoff = PulseBackoff("test", 1.0) 111 | exception = PulseServiceTemporarilyUnavailableError( 112 | backoff, retry_time=time() + 10.0 113 | ) 114 | assert backoff.backoff_count == 0 115 | assert isinstance(exception, PulseExceptionWithRetry) 116 | assert isinstance(exception, PulseConnectionError) 117 | 118 | # PulseAccountLockedError is a subclass of PulseExceptionWithRetry and PulseLoginException 119 | def test_pulse_account_locked_error_inheritance(self): 120 | backoff = PulseBackoff("test", 1.0) 121 | exception = PulseAccountLockedError(backoff, time() + 10.0) 122 | assert backoff.backoff_count == 0 123 | assert isinstance(exception, PulseExceptionWithRetry) 124 | assert isinstance(exception, PulseLoginException) 125 | 126 | # PulseExceptionWithBackoff string representation includes the class name and message 127 | def test_pulse_exception_with_backoff_string_representation(self): 128 | backoff = PulseBackoff("test", 1.0) 129 | exception = PulseExceptionWithBackoff("error", backoff) 130 | assert str(exception) == "PulseExceptionWithBackoff: error" 131 | 132 | # PulseExceptionWithBackoff string representation includes the backoff object 133 | def test_pulse_exception_with_backoff_string_representation(self): 134 | backoff = PulseBackoff("test", 1.0) 135 | exception = PulseExceptionWithBackoff("error", backoff) 136 | assert str(exception) == "PulseExceptionWithBackoff: error" 137 | assert exception.backoff == backoff 138 | assert backoff.backoff_count == 1 139 | 140 | # PulseExceptionWithRetry string representation includes the class name, message, backoff object, and retry time 141 | def test_pulse_exception_with_retry_string_representation_fixed(self): 142 | backoff = PulseBackoff("test", 1.0) 143 | exception = PulseExceptionWithRetry("error", backoff, time() + 10) 144 | expected_string = "PulseExceptionWithRetry: error" 145 | assert str(exception) == expected_string 146 | 147 | # PulseNotLoggedInError is a subclass of PulseLoginException 148 | def test_pulse_not_logged_in_error_inheritance(self): 149 | backoff = PulseBackoff("test", 1.0) 150 | exception = PulseNotLoggedInError() 151 | assert isinstance(exception, PulseLoginException) 152 | 153 | # PulseExceptionWithRetry string representation does not include the backoff count if retry time is set 154 | def test_pulse_exception_with_retry_string_representation(self): 155 | backoff = PulseBackoff("test", 1.0) 156 | exception = PulseExceptionWithRetry("error", backoff, time() + 10) 157 | assert str(exception) == "PulseExceptionWithRetry: error" 158 | assert exception.backoff == backoff 159 | assert backoff.backoff_count == 0 160 | -------------------------------------------------------------------------------- /tests/test_paa_codium.py: -------------------------------------------------------------------------------- 1 | # Generated by CodiumAI 2 | 3 | import pytest 4 | from lxml import html 5 | 6 | from conftest import LoginType, add_signin 7 | from pyadtpulse.exceptions import PulseAuthenticationError, PulseNotLoggedInError 8 | from pyadtpulse.pyadtpulse_async import PyADTPulseAsync 9 | from pyadtpulse.site import ADTPulseSite 10 | 11 | 12 | class TestPyADTPulseAsync: 13 | # The class can be instantiated with the required parameters (username, password, fingerprint) and optional parameters (service_host, user_agent, debug_locks, keepalive_interval, relogin_interval, detailed_debug_logging). 14 | @pytest.mark.asyncio 15 | async def test_instantiation_with_parameters(self): 16 | pulse = PyADTPulseAsync( 17 | username="valid_email@example.com", 18 | password="your_password", 19 | fingerprint="your_fingerprint", 20 | service_host="https://portal.adtpulse.com", 21 | user_agent="Your User Agent", 22 | debug_locks=False, 23 | keepalive_interval=5, 24 | relogin_interval=60, 25 | detailed_debug_logging=True, 26 | ) 27 | assert isinstance(pulse, PyADTPulseAsync) 28 | 29 | # The __repr__ method returns a string representation of the class. 30 | @pytest.mark.asyncio 31 | async def test_repr_method_with_valid_email(self): 32 | pulse = PyADTPulseAsync( 33 | username="your_username@example.com", 34 | password="your_password", 35 | fingerprint="your_fingerprint", 36 | ) 37 | assert repr(pulse) == "" 38 | 39 | # The async_login method successfully authenticates the user to the ADT Pulse cloud service using a valid email address as the username. 40 | @pytest.mark.asyncio 41 | async def test_async_login_success_with_valid_email( 42 | self, mocked_server_responses, get_mocked_url, read_file 43 | ): 44 | pulse = PyADTPulseAsync( 45 | username="valid_email@example.com", 46 | password="your_password", 47 | fingerprint="your_fingerprint", 48 | ) 49 | add_signin( 50 | LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file 51 | ) 52 | await pulse.async_login() 53 | 54 | # The class is instantiated without the required parameters (username, password, fingerprint) and raises an exception. 55 | @pytest.mark.asyncio 56 | async def test_instantiation_without_parameters(self): 57 | with pytest.raises(TypeError): 58 | pulse = PyADTPulseAsync() 59 | 60 | # The async_login method fails to authenticate the user to the ADT Pulse cloud service and raises a PulseAuthenticationError. 61 | @pytest.mark.asyncio 62 | async def test_async_login_failure_with_valid_username(self): 63 | pulse = PyADTPulseAsync( 64 | username="valid_email@example.com", 65 | password="invalid_password", 66 | fingerprint="invalid_fingerprint", 67 | ) 68 | with pytest.raises(PulseAuthenticationError): 69 | await pulse.async_login() 70 | 71 | # The async_logout method is called without being logged in and returns without any action. 72 | @pytest.mark.asyncio 73 | async def test_async_logout_without_login_with_valid_email_fixed(self): 74 | pulse = PyADTPulseAsync( 75 | username="valid_username@example.com", 76 | password="valid_password", 77 | fingerprint="valid_fingerprint", 78 | ) 79 | with pytest.raises(RuntimeError): 80 | await pulse.async_logout() 81 | 82 | # The async_logout method successfully logs the user out of the ADT Pulse cloud service. 83 | @pytest.mark.asyncio 84 | async def test_async_logout_successfully_logs_out( 85 | self, mocked_server_responses, get_mocked_url, read_file 86 | ): 87 | # Arrange 88 | pulse = PyADTPulseAsync( 89 | username="test_user@example.com", 90 | password="test_password", 91 | fingerprint="test_fingerprint", 92 | ) 93 | add_signin( 94 | LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file 95 | ) 96 | # Act 97 | await pulse.async_login() 98 | await pulse.async_logout() 99 | 100 | # Assert 101 | assert not pulse.is_connected 102 | 103 | # The site property returns an ADTPulseSite object after logging in. 104 | @pytest.mark.asyncio 105 | async def test_site_property_returns_ADTPulseSite_object_with_login( 106 | self, mocked_server_responses, get_mocked_url, read_file 107 | ): 108 | # Arrange 109 | pulse = PyADTPulseAsync("test@example.com", "valid_password", "fingerprint") 110 | add_signin( 111 | LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file 112 | ) 113 | # Act 114 | await pulse.async_login() 115 | site = pulse.site 116 | 117 | # Assert 118 | assert isinstance(site, ADTPulseSite) 119 | 120 | # The is_connected property returns True if the class is connected to the ADT Pulse cloud service. 121 | @pytest.mark.asyncio 122 | async def test_is_connected_property_returns_true( 123 | self, mocked_server_responses, get_mocked_url, read_file 124 | ): 125 | pulse = PyADTPulseAsync( 126 | username="valid_username@example.com", 127 | password="valid_password", 128 | fingerprint="valid_fingerprint", 129 | ) 130 | add_signin( 131 | LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file 132 | ) 133 | await pulse.async_login() 134 | assert pulse.is_connected == True 135 | 136 | # The site property is accessed without being logged in and raises an exception. 137 | @pytest.mark.asyncio 138 | async def test_site_property_without_login_raises_exception(self): 139 | pulse = PyADTPulseAsync( 140 | username="test@example.com", 141 | password="your_password", 142 | fingerprint="your_fingerprint", 143 | service_host="https://portal.adtpulse.com", 144 | user_agent="Your User Agent", 145 | debug_locks=False, 146 | keepalive_interval=5, 147 | relogin_interval=60, 148 | detailed_debug_logging=True, 149 | ) 150 | with pytest.raises(RuntimeError): 151 | pulse.site 152 | 153 | # The sites property returns a list of ADTPulseSite objects. 154 | @pytest.mark.asyncio 155 | async def test_sites_property_returns_list_of_objects( 156 | self, mocked_server_responses, get_mocked_url, read_file 157 | ): 158 | # Arrange 159 | pulse = PyADTPulseAsync( 160 | "test@example.com", "valid_password", "valid_fingerprint" 161 | ) 162 | add_signin( 163 | LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file 164 | ) 165 | # Act 166 | await pulse.async_login() 167 | sites = pulse.sites 168 | 169 | # Assert 170 | assert isinstance(sites, list) 171 | for site in sites: 172 | assert isinstance(site, ADTPulseSite) 173 | 174 | # The is_connected property returns False if the class is not connected to the ADT Pulse cloud service. 175 | @pytest.mark.asyncio 176 | async def test_is_connected_property_returns_false_when_not_connected(self): 177 | pulse = PyADTPulseAsync( 178 | username="your_username@example.com", 179 | password="your_password", 180 | fingerprint="your_fingerprint", 181 | ) 182 | assert pulse.is_connected == False 183 | 184 | # The sites property is accessed without being logged in and raises an exception. 185 | @pytest.mark.asyncio 186 | async def test_sites_property_without_login_raises_exception(self): 187 | pulse = PyADTPulseAsync( 188 | username="your_username@example.com", 189 | password="your_password", 190 | fingerprint="your_fingerprint", 191 | service_host="https://portal.adtpulse.com", 192 | user_agent="Your User Agent", 193 | debug_locks=False, 194 | keepalive_interval=5, 195 | relogin_interval=60, 196 | detailed_debug_logging=True, 197 | ) 198 | with pytest.raises(RuntimeError): 199 | pulse.sites 200 | 201 | # The wait_for_update method is called without being logged in and raises an exception. 202 | @pytest.mark.asyncio 203 | async def test_wait_for_update_without_login_raises_exception(self): 204 | pulse = PyADTPulseAsync( 205 | username="your_username@example.com", 206 | password="your_password", 207 | fingerprint="your_fingerprint", 208 | service_host="https://portal.adtpulse.com", 209 | user_agent="Your User Agent", 210 | debug_locks=False, 211 | keepalive_interval=5, 212 | relogin_interval=60, 213 | detailed_debug_logging=True, 214 | ) 215 | 216 | with pytest.raises(PulseNotLoggedInError): 217 | await pulse.wait_for_update() 218 | 219 | # The _initialize_sites method retrieves the site id and name from the lxml 220 | # etree and creates a new ADTPulseSite object. 221 | @pytest.mark.asyncio 222 | async def test_initialize_sites_method_with_valid_service_host( 223 | self, mocker, read_file 224 | ): 225 | # Arrange 226 | username = "test@example.com" 227 | password = "test_password" 228 | fingerprint = "test_fingerprint" 229 | service_host = "https://portal.adtpulse.com" 230 | user_agent = "Test User Agent" 231 | debug_locks = False 232 | keepalive_interval = 10 233 | relogin_interval = 30 234 | detailed_debug_logging = True 235 | 236 | pulse = PyADTPulseAsync( 237 | username=username, 238 | password=password, 239 | fingerprint=fingerprint, 240 | service_host=service_host, 241 | user_agent=user_agent, 242 | debug_locks=debug_locks, 243 | keepalive_interval=keepalive_interval, 244 | relogin_interval=relogin_interval, 245 | detailed_debug_logging=detailed_debug_logging, 246 | ) 247 | 248 | tree = html.fromstring(read_file("summary.html")) 249 | 250 | # Mock the fetch_devices method to always return True 251 | # mocker.patch.object(ADTPulseSite, "fetch_devices", return_value=True) 252 | 253 | # Act 254 | await pulse._initialize_sites(tree) 255 | 256 | # Assert 257 | assert pulse._site is not None 258 | assert pulse._site.id == "160301za524548" 259 | assert pulse._site.name == "Robert Lippmann" 260 | -------------------------------------------------------------------------------- /tests/test_pap.py: -------------------------------------------------------------------------------- 1 | # Generated by CodiumAI 2 | 3 | import pytest 4 | from typeguard import TypeCheckError 5 | 6 | from pyadtpulse.pulse_authentication_properties import PulseAuthenticationProperties 7 | 8 | 9 | class TestPulseAuthenticationProperties: 10 | # Initialize object with valid username, password, and fingerprint 11 | def test_initialize_with_valid_credentials(self): 12 | """ 13 | Test initializing PulseAuthenticationProperties with valid username, password, and fingerprint 14 | """ 15 | # Arrange 16 | username = "test@example.com" 17 | password = "password123" 18 | fingerprint = "fingerprint123" 19 | 20 | # Act 21 | properties = PulseAuthenticationProperties(username, password, fingerprint) 22 | 23 | # Assert 24 | assert properties.username == username 25 | assert properties.password == password 26 | assert properties.fingerprint == fingerprint 27 | 28 | # Get and set username, password, fingerprint, site_id, and last_login_time properties 29 | def test_get_and_set_properties(self): 30 | """ 31 | Test getting and setting username, password, fingerprint, site_id, and last_login_time properties 32 | """ 33 | # Arrange 34 | username = "test@example.com" 35 | password = "password123" 36 | fingerprint = "fingerprint123" 37 | site_id = "site123" 38 | last_login_time = 123456789 39 | 40 | properties = PulseAuthenticationProperties(username, password, fingerprint) 41 | 42 | # Act 43 | properties.username = "new_username@example.com" 44 | properties.password = "new_password" 45 | properties.fingerprint = "new_fingerprint" 46 | properties.site_id = site_id 47 | properties.last_login_time = last_login_time 48 | 49 | # Assert 50 | assert properties.username == "new_username@example.com" 51 | assert properties.password == "new_password" 52 | assert properties.fingerprint == "new_fingerprint" 53 | assert properties.site_id == site_id 54 | assert properties.last_login_time == last_login_time 55 | 56 | # Get last_login_time property after setting it 57 | def test_get_last_login_time_after_setting(self): 58 | """ 59 | Test getting last_login_time property after setting it 60 | """ 61 | # Arrange 62 | username = "test@example.com" 63 | password = "password123" 64 | fingerprint = "fingerprint123" 65 | last_login_time = 123456789 66 | 67 | properties = PulseAuthenticationProperties(username, password, fingerprint) 68 | 69 | # Act 70 | properties.last_login_time = last_login_time 71 | 72 | # Assert 73 | assert properties.last_login_time == last_login_time 74 | 75 | # Set username, password, fingerprint, site_id properties with valid values 76 | def test_set_properties_with_valid_values(self): 77 | """ 78 | Test setting username, password, fingerprint, site_id properties with valid values 79 | """ 80 | # Arrange 81 | username = "test@example.com" 82 | password = "password123" 83 | fingerprint = "fingerprint123" 84 | site_id = "site123" 85 | 86 | properties = PulseAuthenticationProperties(username, password, fingerprint) 87 | 88 | # Act 89 | properties.site_id = site_id 90 | 91 | # Assert 92 | assert properties.username == username 93 | assert properties.password == password 94 | assert properties.fingerprint == fingerprint 95 | assert properties.site_id == site_id 96 | 97 | # Set username, password, fingerprint properties with non-empty fingerprint 98 | def test_set_properties_with_non_empty_fingerprint(self): 99 | """ 100 | Test setting username, password, fingerprint properties with non-empty fingerprint 101 | """ 102 | # Arrange 103 | username = "test@example.com" 104 | password = "password123" 105 | fingerprint = "fingerprint123" 106 | 107 | properties = PulseAuthenticationProperties(username, password, fingerprint) 108 | 109 | # Act 110 | properties.username = username 111 | properties.password = password 112 | properties.fingerprint = fingerprint 113 | 114 | # Assert 115 | assert properties.username == username 116 | assert properties.password == password 117 | assert properties.fingerprint == fingerprint 118 | 119 | # Set site_id property with empty string 120 | def test_set_site_id_with_empty_string(self): 121 | """ 122 | Test setting site_id property with empty string 123 | """ 124 | # Arrange 125 | site_id = "" 126 | 127 | properties = PulseAuthenticationProperties( 128 | "test@example.com", "password123", "fingerprint123" 129 | ) 130 | 131 | # Act 132 | properties.site_id = site_id 133 | 134 | # Assert 135 | assert properties.site_id == site_id 136 | 137 | # Initialize object with empty username, password, or fingerprint 138 | def test_initialize_with_empty_credentials(self): 139 | """ 140 | Test initializing PulseAuthenticationProperties with empty username, password, or fingerprint 141 | """ 142 | # Arrange 143 | username = "" 144 | password = "" 145 | fingerprint = "" 146 | 147 | # Act and Assert 148 | with pytest.raises(ValueError): 149 | PulseAuthenticationProperties(username, password, fingerprint) 150 | 151 | # Initialize object with invalid username or password 152 | def test_initialize_with_invalid_credentials1(self): 153 | """ 154 | Test initializing PulseAuthenticationProperties with invalid username or password 155 | """ 156 | # Arrange 157 | username = "invalid_username" 158 | password = "invalid_password" 159 | fingerprint = "fingerprint123" 160 | 161 | # Act and Assert 162 | with pytest.raises(ValueError): 163 | PulseAuthenticationProperties(username, password, fingerprint) 164 | 165 | # Set username, password, fingerprint properties with invalid values 166 | def test_set_properties_with_invalid_values(self): 167 | """ 168 | Test setting username, password, fingerprint properties with invalid values 169 | """ 170 | # Arrange 171 | username = "invalid_username" 172 | password = "" 173 | fingerprint = "" 174 | 175 | properties = PulseAuthenticationProperties( 176 | "test@example.com", "password123", "fingerprint123" 177 | ) 178 | 179 | # Act and Assert 180 | with pytest.raises(ValueError): 181 | properties.username = username 182 | 183 | with pytest.raises(ValueError): 184 | properties.password = password 185 | 186 | with pytest.raises(ValueError): 187 | properties.fingerprint = fingerprint 188 | 189 | # Set last_login_time property with non-integer value 190 | def test_set_last_login_time_with_non_integer_value(self): 191 | """ 192 | Test setting last_login_time property with non-integer value 193 | """ 194 | # Arrange 195 | username = "test@example.com" 196 | password = "password123" 197 | fingerprint = "fingerprint123" 198 | last_login_time = "invalid_time" 199 | 200 | properties = PulseAuthenticationProperties(username, password, fingerprint) 201 | 202 | # Act and Assert 203 | with pytest.raises(TypeCheckError) as exc_info: 204 | properties.last_login_time = last_login_time 205 | 206 | # Assert 207 | assert ( 208 | str(exc_info.value) 209 | == 'argument "login_time" (str) is not an instance of int' 210 | ) 211 | 212 | # Set site_id property with non-string value 213 | def test_set_site_id_with_non_string_value(self): 214 | """ 215 | Test setting site_id property with non-string value 216 | """ 217 | # Arrange 218 | username = "test@example.com" 219 | password = "password123" 220 | fingerprint = "fingerprint123" 221 | site_id = 12345 # Fix: Set a non-string value 222 | 223 | properties = PulseAuthenticationProperties(username, password, fingerprint) 224 | 225 | # Act 226 | with pytest.raises(TypeCheckError): 227 | properties.site_id = site_id 228 | 229 | # Assert 230 | assert not properties.site_id 231 | 232 | # Set last_login_time property with integer value 233 | def test_set_last_login_time_with_integer_value(self): 234 | """ 235 | Test setting last_login_time property with integer value 236 | """ 237 | # Arrange 238 | username = "test@example.com" 239 | password = "password123" 240 | fingerprint = "fingerprint123" 241 | last_login_time = 123456789 242 | 243 | properties = PulseAuthenticationProperties(username, password, fingerprint) 244 | 245 | # Act 246 | properties.last_login_time = last_login_time 247 | 248 | # Assert 249 | assert properties.last_login_time == last_login_time 250 | 251 | # Raise ValueError when initializing object with invalid username or password 252 | def test_initialize_with_invalid_credentials(self): 253 | """ 254 | Test initializing PulseAuthenticationProperties with invalid username or password 255 | """ 256 | # Arrange 257 | username = "invalid_username" 258 | password = "" 259 | fingerprint = "valid_fingerprint" 260 | 261 | # Act and Assert 262 | with pytest.raises(ValueError): 263 | properties = PulseAuthenticationProperties(username, password, fingerprint) 264 | 265 | # Raise TypeError when setting site_id property with non-string value 266 | def test_raise_type_error_when_setting_site_id_with_non_string_value(self): 267 | """ 268 | Test that a TypeError is raised when setting the site_id property with a non-string value 269 | """ 270 | # Arrange 271 | properties = PulseAuthenticationProperties( 272 | "test@example.com", "password123", "fingerprint123" 273 | ) 274 | 275 | # Act and Assert 276 | with pytest.raises(TypeCheckError): 277 | properties.site_id = 123 278 | 279 | # Raise ValueError when setting username, password, fingerprint properties with invalid values 280 | def test_invalid_properties(self): 281 | """ 282 | Test that ValueError is raised when setting invalid username, password, and fingerprint properties 283 | """ 284 | # Arrange 285 | username = "test@example.com" 286 | password = "password123" 287 | fingerprint = "fingerprint123" 288 | properties = PulseAuthenticationProperties(username, password, fingerprint) 289 | 290 | # Act and Assert 291 | with pytest.raises(ValueError): 292 | properties.username = "" 293 | with pytest.raises(ValueError): 294 | properties.password = "" 295 | with pytest.raises(ValueError): 296 | properties.fingerprint = "" 297 | 298 | # Raise TypeCheckError when setting last_login_time property with non-integer value 299 | def test_raise_type_check_error_when_setting_last_login_time_with_non_integer_value( 300 | self, 301 | ): 302 | """ 303 | Test that a TypeCheckError is raised when setting the last_login_time property with a non-integer value 304 | """ 305 | import typeguard 306 | 307 | # Arrange 308 | properties = PulseAuthenticationProperties( 309 | "test@example.com", "password123", "fingerprint123" 310 | ) 311 | 312 | # Act and Assert 313 | with pytest.raises(typeguard.TypeCheckError): 314 | properties.last_login_time = "invalid_time" 315 | -------------------------------------------------------------------------------- /tests/test_pulse_connection.py: -------------------------------------------------------------------------------- 1 | """Test Pulse Connection.""" 2 | 3 | import asyncio 4 | import datetime 5 | 6 | import pytest 7 | from lxml import html 8 | 9 | from conftest import LoginType, add_custom_response, add_signin 10 | from pyadtpulse.const import ADT_LOGIN_URI, DEFAULT_API_HOST 11 | from pyadtpulse.exceptions import ( 12 | PulseAccountLockedError, 13 | PulseAuthenticationError, 14 | PulseMFARequiredError, 15 | PulseServerConnectionError, 16 | ) 17 | from pyadtpulse.pulse_authentication_properties import PulseAuthenticationProperties 18 | from pyadtpulse.pulse_connection import PulseConnection 19 | from pyadtpulse.pulse_connection_properties import PulseConnectionProperties 20 | from pyadtpulse.pulse_connection_status import PulseConnectionStatus 21 | from pyadtpulse.pulse_query_manager import MAX_REQUERY_RETRIES 22 | 23 | 24 | def setup_pulse_connection() -> PulseConnection: 25 | s = PulseConnectionStatus() 26 | pcp = PulseConnectionProperties(DEFAULT_API_HOST) 27 | pa = PulseAuthenticationProperties( 28 | "test@example.com", "testpassword", "testfingerprint" 29 | ) 30 | pc = PulseConnection(s, pcp, pa) 31 | return pc 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_login(mocked_server_responses, read_file, mock_sleep, get_mocked_url): 36 | """Test Pulse Connection.""" 37 | pc = setup_pulse_connection() 38 | add_signin(LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file) 39 | # first call to signin post is successful in conftest.py 40 | result = await pc.async_do_login_query() 41 | assert result is not None 42 | assert html.tostring(result) == read_file("summary.html") 43 | assert mock_sleep.call_count == 0 44 | assert pc.login_in_progress is False 45 | assert pc._login_backoff.backoff_count == 0 46 | assert pc._connection_status.authenticated_flag.is_set() 47 | # so logout won't fail 48 | add_custom_response( 49 | mocked_server_responses, read_file, get_mocked_url(ADT_LOGIN_URI) 50 | ) 51 | await pc.async_do_logout_query() 52 | assert not pc._connection_status.authenticated_flag.is_set() 53 | assert mock_sleep.call_count == 0 54 | assert pc._login_backoff.backoff_count == 0 55 | 56 | 57 | @pytest.mark.asyncio 58 | async def test_login_failure_server_down(mock_server_down): 59 | pc = setup_pulse_connection() 60 | with pytest.raises(PulseServerConnectionError): 61 | await pc.async_do_login_query() 62 | assert pc.login_in_progress is False 63 | assert pc._login_backoff.backoff_count == 0 64 | 65 | 66 | @pytest.mark.asyncio 67 | async def test_multiple_login( 68 | mocked_server_responses, get_mocked_url, read_file, mock_sleep 69 | ): 70 | """Test Pulse Connection.""" 71 | pc = setup_pulse_connection() 72 | add_signin(LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file) 73 | result = await pc.async_do_login_query() 74 | assert result is not None 75 | assert html.tostring(result) == read_file("summary.html") 76 | assert mock_sleep.call_count == 0 77 | assert pc.login_in_progress is False 78 | assert pc._login_backoff.backoff_count == 0 79 | assert pc._connection_status.authenticated_flag.is_set() 80 | add_signin(LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file) 81 | await pc.async_do_login_query() 82 | assert mock_sleep.call_count == 0 83 | assert pc.login_in_progress is False 84 | assert pc._login_backoff.backoff_count == 0 85 | assert pc._connection_status.get_backoff().backoff_count == 0 86 | assert pc._connection_status.authenticated_flag.is_set() 87 | # this should fail 88 | with pytest.raises(PulseServerConnectionError): 89 | await pc.async_do_login_query() 90 | assert mock_sleep.call_count == MAX_REQUERY_RETRIES - 1 91 | assert pc.login_in_progress is False 92 | assert pc._login_backoff.backoff_count == 0 93 | assert pc._connection_status.get_backoff().backoff_count == 1 94 | assert not pc._connection_status.authenticated_flag.is_set() 95 | assert not pc.is_connected 96 | with pytest.raises(PulseServerConnectionError): 97 | await pc.async_do_login_query() 98 | assert pc._login_backoff.backoff_count == 0 99 | # 2 retries first time, 1 for the connection backoff 100 | assert mock_sleep.call_count == MAX_REQUERY_RETRIES 101 | assert pc.login_in_progress is False 102 | 103 | assert pc._connection_status.get_backoff().backoff_count == 2 104 | assert not pc._connection_status.authenticated_flag.is_set() 105 | assert not pc.is_connected 106 | add_signin(LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file) 107 | await pc.async_do_login_query() 108 | # will just to a connection backoff 109 | assert mock_sleep.call_count == MAX_REQUERY_RETRIES + 1 110 | assert pc.login_in_progress is False 111 | assert pc._login_backoff.backoff_count == 0 112 | assert pc._connection_status.authenticated_flag.is_set() 113 | 114 | add_signin(LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file) 115 | await pc.async_do_login_query() 116 | # shouldn't sleep at all 117 | assert mock_sleep.call_count == MAX_REQUERY_RETRIES + 1 118 | assert pc.login_in_progress is False 119 | assert pc._login_backoff.backoff_count == 0 120 | assert pc._connection_status.authenticated_flag.is_set() 121 | 122 | 123 | @pytest.mark.asyncio 124 | async def test_account_lockout( 125 | mocked_server_responses, mock_sleep, get_mocked_url, read_file, freeze_time_to_now 126 | ): 127 | pc = setup_pulse_connection() 128 | add_signin(LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file) 129 | await pc.async_do_login_query() 130 | assert mock_sleep.call_count == 0 131 | assert pc.login_in_progress is False 132 | assert pc._login_backoff.backoff_count == 0 133 | assert pc.is_connected 134 | assert pc._connection_status.authenticated_flag.is_set() 135 | add_signin(LoginType.LOCKED, mocked_server_responses, get_mocked_url, read_file) 136 | with pytest.raises(PulseAccountLockedError): 137 | await pc.async_do_login_query() 138 | # won't sleep yet 139 | assert not pc.is_connected 140 | assert not pc._connection_status.authenticated_flag.is_set() 141 | # don't set backoff on locked account, just set expiration time on backoff 142 | assert pc._login_backoff.backoff_count == 0 143 | assert mock_sleep.call_count == 0 144 | freeze_time_to_now.tick(delta=datetime.timedelta(seconds=(60 * 30) + 1)) 145 | add_signin(LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file) 146 | await pc.async_do_login_query() 147 | assert mock_sleep.call_count == 0 148 | assert pc.is_connected 149 | assert pc._connection_status.authenticated_flag.is_set() 150 | freeze_time_to_now.tick(delta=datetime.timedelta(seconds=60 * 30 + 1)) 151 | add_signin(LoginType.LOCKED, mocked_server_responses, get_mocked_url, read_file) 152 | with pytest.raises(PulseAccountLockedError): 153 | await pc.async_do_login_query() 154 | assert pc._login_backoff.backoff_count == 0 155 | assert mock_sleep.call_count == 0 156 | 157 | 158 | @pytest.mark.asyncio 159 | async def test_invalid_credentials( 160 | mocked_server_responses, mock_sleep, get_mocked_url, read_file 161 | ): 162 | pc = setup_pulse_connection() 163 | add_signin(LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file) 164 | await pc.async_do_login_query() 165 | assert mock_sleep.call_count == 0 166 | assert pc.login_in_progress is False 167 | assert pc._login_backoff.backoff_count == 0 168 | add_signin(LoginType.FAIL, mocked_server_responses, get_mocked_url, read_file) 169 | with pytest.raises(PulseAuthenticationError): 170 | await pc.async_do_login_query() 171 | assert pc._login_backoff.backoff_count == 0 172 | assert mock_sleep.call_count == 0 173 | add_signin(LoginType.FAIL, mocked_server_responses, get_mocked_url, read_file) 174 | 175 | with pytest.raises(PulseAuthenticationError): 176 | await pc.async_do_login_query() 177 | assert pc._login_backoff.backoff_count == 0 178 | assert mock_sleep.call_count == 0 179 | add_signin(LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file) 180 | assert pc._login_backoff.backoff_count == 0 181 | assert mock_sleep.call_count == 0 182 | 183 | 184 | @pytest.mark.asyncio 185 | async def test_mfa_failure(mocked_server_responses, get_mocked_url, read_file): 186 | pc = setup_pulse_connection() 187 | add_signin(LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file) 188 | await pc.async_do_login_query() 189 | assert pc.login_in_progress is False 190 | assert pc._login_backoff.backoff_count == 0 191 | add_signin(LoginType.MFA, mocked_server_responses, get_mocked_url, read_file) 192 | with pytest.raises(PulseMFARequiredError): 193 | await pc.async_do_login_query() 194 | assert pc._login_backoff.backoff_count == 0 195 | add_signin(LoginType.MFA, mocked_server_responses, get_mocked_url, read_file) 196 | with pytest.raises(PulseMFARequiredError): 197 | await pc.async_do_login_query() 198 | assert pc._login_backoff.backoff_count == 0 199 | add_signin(LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file) 200 | await pc.async_do_login_query() 201 | assert pc._login_backoff.backoff_count == 0 202 | 203 | 204 | @pytest.mark.asyncio 205 | async def test_only_single_login(mocked_server_responses, get_mocked_url, read_file): 206 | async def login_task(): 207 | await pc.async_do_login_query() 208 | 209 | pc = setup_pulse_connection() 210 | add_signin(LoginType.SUCCESS, mocked_server_responses, get_mocked_url, read_file) 211 | # delay one task for a little bit 212 | for i in range(4): 213 | pc._login_backoff.increment_backoff() 214 | task1 = asyncio.create_task(login_task()) 215 | task2 = asyncio.create_task(login_task()) 216 | await task2 217 | assert pc.login_in_progress 218 | assert not pc.is_connected 219 | assert not task1.done() 220 | await task1 221 | assert not pc.login_in_progress 222 | assert pc.is_connected 223 | -------------------------------------------------------------------------------- /tests/test_pulse_connection_status.py: -------------------------------------------------------------------------------- 1 | # Generated by CodiumAI 2 | import pytest 3 | 4 | from pyadtpulse.pulse_backoff import PulseBackoff 5 | from pyadtpulse.pulse_connection_status import PulseConnectionStatus 6 | 7 | 8 | class TestPulseConnectionStatus: 9 | # PulseConnectionStatus can be initialized without errors 10 | def test_initialized_without_errors(self): 11 | """ 12 | Test that PulseConnectionStatus can be initialized without errors. 13 | """ 14 | pcs = PulseConnectionStatus() 15 | assert pcs is not None 16 | 17 | # authenticated_flag can be accessed without errors 18 | def test_access_authenticated_flag(self): 19 | """ 20 | Test that authenticated_flag can be accessed without errors. 21 | """ 22 | pcs = PulseConnectionStatus() 23 | authenticated_flag = pcs.authenticated_flag 24 | assert authenticated_flag is not None 25 | 26 | # retry_after can be accessed without errors 27 | def test_access_retry_after(self): 28 | """ 29 | Test that retry_after can be accessed without errors. 30 | """ 31 | pcs = PulseConnectionStatus() 32 | retry_after = pcs.retry_after 33 | assert retry_after is not None 34 | 35 | # retry_after can be set without errors 36 | def test_set_retry_after(self): 37 | """ 38 | Test that retry_after can be set without errors. 39 | """ 40 | import time 41 | 42 | pcs = PulseConnectionStatus() 43 | current_time = time.time() 44 | retry_time = current_time + 1000 45 | pcs.retry_after = retry_time 46 | assert pcs.retry_after == retry_time 47 | 48 | # get_backoff returns a PulseBackoff object 49 | def test_get_backoff(self): 50 | """ 51 | Test that get_backoff returns a PulseBackoff object. 52 | """ 53 | pcs = PulseConnectionStatus() 54 | backoff = pcs.get_backoff() 55 | assert isinstance(backoff, PulseBackoff) 56 | 57 | # increment_backoff can be called without errors 58 | def test_increment_backoff(self): 59 | """ 60 | Test that increment_backoff can be called without errors. 61 | """ 62 | pcs = PulseConnectionStatus() 63 | pcs.get_backoff().increment_backoff() 64 | 65 | # retry_after can be set to a time in the future 66 | def test_set_retry_after_past_time_fixed(self): 67 | """ 68 | Test that retry_after can be set to a time in the future. 69 | """ 70 | import time 71 | 72 | pcs = PulseConnectionStatus() 73 | current_time = time.time() 74 | past_time = current_time - 10.0 75 | with pytest.raises(ValueError): 76 | pcs.retry_after = past_time 77 | 78 | # retry_after can be set to a time in the future 79 | def test_set_retry_after_future_time_fixed(self): 80 | """ 81 | Test that retry_after can be set to a time in the future. 82 | """ 83 | import time 84 | 85 | pcs = PulseConnectionStatus() 86 | pcs.retry_after = time.time() + 10.0 87 | assert pcs.retry_after > time.time() 88 | 89 | # retry_after can be set to a positive value greater than the current time 90 | def test_set_retry_after_negative_value_fixed(self): 91 | """ 92 | Test that retry_after can be set to a positive value greater than the current time. 93 | """ 94 | from time import time 95 | 96 | pcs = PulseConnectionStatus() 97 | retry_after_time = time() + 10.0 98 | pcs.retry_after = retry_after_time 99 | assert pcs.retry_after == retry_after_time 100 | 101 | # retry_after can be set to a very large value 102 | def test_set_retry_after_large_value(self): 103 | """ 104 | Test that retry_after can be set to a very large value. 105 | """ 106 | pcs = PulseConnectionStatus() 107 | pcs.retry_after = float("inf") 108 | assert pcs.retry_after == float("inf") 109 | 110 | # retry_after can be set to a non-numeric value 111 | def test_set_retry_after_non_numeric_value_fixed(self): 112 | """ 113 | Test that retry_after can be set to a non-numeric value. 114 | """ 115 | import time 116 | 117 | pcs = PulseConnectionStatus() 118 | retry_after_time = time.time() + 5.0 119 | pcs.retry_after = retry_after_time 120 | assert pcs.retry_after == retry_after_time 121 | 122 | # reset_backoff can be called without errors 123 | def test_reset_backoff(self): 124 | """ 125 | Test that reset_backoff can be called without errors. 126 | """ 127 | pcs = PulseConnectionStatus() 128 | pcs.get_backoff().reset_backoff() 129 | 130 | # authenticated_flag can be set to True 131 | def test_authenticated_flag_set_to_true(self): 132 | """ 133 | Test that authenticated_flag can be set to True. 134 | """ 135 | pcs = PulseConnectionStatus() 136 | pcs.authenticated_flag.set() 137 | assert pcs.authenticated_flag.is_set() 138 | 139 | # authenticated_flag can be set to False 140 | def test_authenticated_flag_false(self): 141 | """ 142 | Test that authenticated_flag can be set to False. 143 | """ 144 | pcs = PulseConnectionStatus() 145 | pcs.authenticated_flag.clear() 146 | assert not pcs.authenticated_flag.is_set() 147 | 148 | # Test that get_backoff returns the same PulseBackoff object every time it is called. 149 | def test_get_backoff_returns_same_object(self): 150 | """ 151 | Test that get_backoff returns the same PulseBackoff object every time it is called. 152 | Arrange: 153 | - Create an instance of PulseConnectionStatus 154 | Act: 155 | - Call get_backoff method twice 156 | Assert: 157 | - The returned PulseBackoff objects are the same 158 | """ 159 | pcs = PulseConnectionStatus() 160 | backoff1 = pcs.get_backoff() 161 | backoff2 = pcs.get_backoff() 162 | assert backoff1 is backoff2 163 | 164 | # increment_backoff increases the backoff count by 1 165 | def test_increment_backoff2(self): 166 | """ 167 | Test that increment_backoff increases the backoff count by 1. 168 | """ 169 | pcs = PulseConnectionStatus() 170 | backoff = pcs.get_backoff() 171 | initial_backoff_count = backoff.backoff_count 172 | backoff.increment_backoff() 173 | new_backoff_count = backoff.backoff_count 174 | assert new_backoff_count == initial_backoff_count + 1 175 | 176 | # reset_backoff sets the backoff count to 0 and the expiration time to 0.0 177 | def test_reset_backoff_sets_backoff_count_and_expiration_time(self): 178 | """ 179 | Test that reset_backoff sets the backoff count to 0 and the expiration time to 0.0. 180 | """ 181 | pcs = PulseConnectionStatus() 182 | backoff = pcs.get_backoff() 183 | backoff.increment_backoff() 184 | backoff.reset_backoff() 185 | assert backoff.backoff_count == 0 186 | assert backoff.expiration_time == 0.0 187 | -------------------------------------------------------------------------------- /tests/test_site_properties.py: -------------------------------------------------------------------------------- 1 | # Generated by CodiumAI 2 | from multiprocessing import RLock 3 | from time import time 4 | 5 | # Dependencies: 6 | # pip install pytest-mock 7 | import pytest 8 | 9 | from pyadtpulse.alarm_panel import ADTPulseAlarmPanel 10 | from pyadtpulse.const import DEFAULT_API_HOST 11 | from pyadtpulse.gateway import ADTPulseGateway 12 | from pyadtpulse.pulse_authentication_properties import PulseAuthenticationProperties 13 | from pyadtpulse.pulse_connection import PulseConnection 14 | from pyadtpulse.pulse_connection_properties import PulseConnectionProperties 15 | from pyadtpulse.pulse_connection_status import PulseConnectionStatus 16 | from pyadtpulse.site_properties import ADTPulseSiteProperties 17 | from pyadtpulse.zones import ADTPulseFlattendZone, ADTPulseZoneData, ADTPulseZones 18 | 19 | 20 | class TestADTPulseSiteProperties: 21 | # Retrieve site id and name 22 | def test_retrieve_site_id_and_name(self): 23 | # Arrange 24 | site_id = "12345" 25 | site_name = "My ADT Pulse Site" 26 | site_properties = ADTPulseSiteProperties(site_id, site_name) 27 | 28 | # Act 29 | retrieved_id = site_properties.id 30 | retrieved_name = site_properties.name 31 | 32 | # Assert 33 | assert retrieved_id == site_id 34 | assert retrieved_name == site_name 35 | 36 | # Retrieve last update time 37 | def test_retrieve_last_update_time(self): 38 | # Arrange 39 | site_id = "12345" 40 | site_name = "My ADT Pulse Site" 41 | site_properties = ADTPulseSiteProperties(site_id, site_name) 42 | 43 | # Act 44 | last_updated = site_properties.last_updated 45 | 46 | # Assert 47 | assert isinstance(last_updated, int) 48 | 49 | # Retrieve all zones registered with ADT Pulse account when zones exist 50 | def test_retrieve_all_zones_with_zones_fixed(self): 51 | # Arrange 52 | 53 | site_id = "12345" 54 | site_name = "My ADT Pulse Site" 55 | site_properties = ADTPulseSiteProperties(site_id, site_name) 56 | 57 | # Add some zones to the site_properties instance 58 | zone1 = ADTPulseZoneData(id_=1, name="Front Door") 59 | zone2 = ADTPulseZoneData(id_=2, name="Back Door") 60 | 61 | site_properties._zones[1] = zone1 62 | site_properties._zones[2] = zone2 63 | 64 | # Act 65 | zones = site_properties.zones 66 | 67 | # Assert 68 | assert isinstance(zones, list) 69 | assert len(zones) == 2 70 | 71 | # Retrieve zone information in dictionary form 72 | def test_retrieve_zone_information_as_dict(self): 73 | # Arrange 74 | 75 | site_id = "12345" 76 | site_name = "My ADT Pulse Site" 77 | site_properties = ADTPulseSiteProperties(site_id, site_name) 78 | site_properties._zones = ADTPulseZones() 79 | zone = ADTPulseZoneData(id_=1, name="Zone1") # Provide the 'id_' argument 80 | site_properties._zones[1] = zone 81 | 82 | # Act 83 | zones_dict = site_properties.zones_as_dict 84 | 85 | # Assert 86 | assert isinstance(zones_dict, ADTPulseZones) 87 | 88 | # Retrieve alarm panel object for the site 89 | def test_retrieve_alarm_panel_object(self): 90 | # Arrange 91 | site_id = "12345" 92 | site_name = "My ADT Pulse Site" 93 | site_properties = ADTPulseSiteProperties(site_id, site_name) 94 | 95 | # Act 96 | alarm_panel = site_properties.alarm_control_panel 97 | 98 | # Assert 99 | assert isinstance(alarm_panel, ADTPulseAlarmPanel) 100 | 101 | # Retrieve gateway device object 102 | def test_retrieve_gateway_device_object(self): 103 | # Arrange 104 | site_id = "12345" 105 | site_name = "My ADT Pulse Site" 106 | site_properties = ADTPulseSiteProperties(site_id, site_name) 107 | 108 | # Act 109 | gateway = site_properties.gateway 110 | 111 | # Assert 112 | assert isinstance(gateway, ADTPulseGateway) 113 | 114 | # No zones exist 115 | def test_no_zones_exist(self): 116 | # Arrange 117 | site_id = "12345" 118 | site_name = "My ADT Pulse Site" 119 | site_properties = ADTPulseSiteProperties(site_id, site_name) 120 | 121 | # Act & Assert 122 | with pytest.raises(RuntimeError): 123 | site_properties.zones 124 | 125 | # Attempting to retrieve site data while another thread is modifying it 126 | def test_retrieve_site_data_while_modifying(self, mocker): 127 | # Arrange 128 | site_id = "12345" 129 | site_name = "My ADT Pulse Site" 130 | site_properties = ADTPulseSiteProperties(site_id, site_name) 131 | 132 | def modify_site_data(): 133 | with site_properties.site_lock: 134 | time.sleep(2) 135 | site_properties._last_updated = int(time()) 136 | 137 | mocker.patch.object(site_properties, "_last_updated", 0) 138 | mocker.patch.object(site_properties, "_site_lock", RLock()) 139 | 140 | # Act 141 | with site_properties.site_lock: 142 | retrieved_last_updated = site_properties.last_updated 143 | 144 | # Assert 145 | assert retrieved_last_updated == 0 146 | 147 | # Attempting to set alarm status to existing status 148 | def test_set_alarm_status_to_existing_status(self, mocker): 149 | # Arrange 150 | site_id = "12345" 151 | site_name = "My ADT Pulse Site" 152 | site_properties = ADTPulseSiteProperties(site_id, site_name) 153 | 154 | mocker.patch.object(site_properties._alarm_panel, "_status", "Armed Away") 155 | 156 | # Check if updates exist 157 | def test_check_updates_exist(self, mocker): 158 | # Arrange 159 | from time import time 160 | 161 | site_properties = ADTPulseSiteProperties("12345", "My ADT Pulse Site") 162 | mocker.patch.object(site_properties, "_last_updated", return_value=time()) 163 | 164 | # Act 165 | result = site_properties.updates_may_exist 166 | 167 | # Assert 168 | assert result is False 169 | 170 | # Update site/zone data async with current data 171 | @pytest.mark.asyncio 172 | async def test_update_site_zone_data_async(self, mocker): 173 | # Arrange 174 | site_id = "12345" 175 | site_name = "My ADT Pulse Site" 176 | site_properties = ADTPulseSiteProperties(site_id, site_name) 177 | mock_zones = mocker.Mock() 178 | mock_zones.flatten.return_value = [ADTPulseFlattendZone()] 179 | site_properties._zones = mock_zones 180 | 181 | # Act 182 | result = await site_properties.async_update() 183 | 184 | # Assert 185 | assert result == False 186 | 187 | # Cannot set alarm status from one state to another 188 | @pytest.mark.asyncio 189 | async def test_cannot_set_alarm_status(self, mocker): 190 | # Arrange 191 | site_id = "12345" 192 | site_name = "My ADT Pulse Site" 193 | site_properties = ADTPulseSiteProperties(site_id, site_name) 194 | cp = PulseConnectionProperties(DEFAULT_API_HOST) 195 | cs = PulseConnectionStatus() 196 | pa = PulseAuthenticationProperties( 197 | "test@example.com", "testpassword", "testfingerprint" 198 | ) 199 | 200 | connection = PulseConnection(cs, cp, pa) 201 | 202 | # Act 203 | result = await site_properties._alarm_panel._arm( 204 | connection, "Armed Home", False 205 | ) 206 | 207 | # Assert 208 | assert result == False 209 | 210 | # Failed updating ADT Pulse alarm to new mode 211 | @pytest.mark.asyncio 212 | async def test_failed_updating_alarm_mode(self, mocker): 213 | # Arrange 214 | site_id = "12345" 215 | site_name = "My ADT Pulse Site" 216 | site_properties = ADTPulseSiteProperties(site_id, site_name) 217 | 218 | # Mock the _arm method to return False 219 | async def mock_arm(*args, **kwargs): 220 | return False 221 | 222 | mocker.patch.object(ADTPulseAlarmPanel, "_arm", side_effect=mock_arm) 223 | 224 | # Act 225 | result = await site_properties.alarm_control_panel._arm(None, "new_mode", False) 226 | 227 | # Assert 228 | assert result == False 229 | 230 | # Retrieve last update time with invalid input 231 | def test_retrieve_last_update_invalid_input(self): 232 | # Arrange 233 | site_id = "12345" 234 | site_name = "My ADT Pulse Site" 235 | site_properties = ADTPulseSiteProperties(site_id, site_name) 236 | 237 | # Act 238 | last_updated = site_properties.last_updated 239 | 240 | # Assert 241 | assert last_updated == 0 242 | 243 | # Retrieve site id and name with invalid input 244 | def test_retrieve_site_id_and_name_with_invalid_input(self): 245 | # Arrange 246 | site_id = "12345" 247 | site_name = "My ADT Pulse Site" 248 | site_properties = ADTPulseSiteProperties(site_id, site_name) 249 | 250 | # Act 251 | retrieved_id = site_properties.id 252 | retrieved_name = site_properties.name 253 | 254 | # Assert 255 | assert retrieved_id == site_id 256 | assert retrieved_name == site_name 257 | 258 | # Retrieve zone information in dictionary form with invalid input 259 | def test_retrieve_zone_info_invalid_input(self, mocker): 260 | # Arrange 261 | site_id = "12345" 262 | site_name = "My ADT Pulse Site" 263 | site_properties = ADTPulseSiteProperties(site_id, site_name) 264 | mocker.patch.object(site_properties, "_zones", None) 265 | 266 | # Act and Assert 267 | with pytest.raises(RuntimeError): 268 | site_properties.zones 269 | 270 | with pytest.raises(RuntimeError): 271 | site_properties.zones_as_dict 272 | 273 | # Retrieve all zones registered with ADT Pulse account with invalid input 274 | def test_retrieve_zones_with_invalid_input(self, mocker): 275 | # Arrange 276 | site_id = "12345" 277 | site_name = "My ADT Pulse Site" 278 | site_properties = ADTPulseSiteProperties(site_id, site_name) 279 | mocker.patch.object(site_properties, "_zones", None) 280 | 281 | # Act and Assert 282 | with pytest.raises(RuntimeError): 283 | _ = site_properties.zones 284 | 285 | with pytest.raises(RuntimeError): 286 | _ = site_properties.zones_as_dict 287 | 288 | # Retrieve alarm panel object for the site with invalid input 289 | def test_retrieve_alarm_panel_invalid_input(self, mocker): 290 | # Arrange 291 | site_id = "12345" 292 | site_name = "My ADT Pulse Site" 293 | site_properties = ADTPulseSiteProperties(site_id, site_name) 294 | 295 | # Mock the ADTPulseAlarmPanel object 296 | mock_alarm_panel = mocker.Mock() 297 | site_properties._alarm_panel = mock_alarm_panel 298 | 299 | # Act 300 | retrieved_alarm_panel = site_properties.alarm_control_panel 301 | 302 | # Assert 303 | assert retrieved_alarm_panel == mock_alarm_panel 304 | --------------------------------------------------------------------------------