├── .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 | [](https://pypi.python.org/pypi/pyadtpulse)
6 | [](https://opensource.org/licenses/Apache-2.0)
7 | [](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 |
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 |
72 |
73 |
74 |
75 |
126 |
127 |
128 |
129 |
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 |
72 |
73 |
74 |
75 |
126 |
127 |
128 |
129 |
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 |
71 |
72 |
73 |
74 |
125 |
126 |
127 |
128 |
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 |
71 |
72 |
73 |
74 |
125 |
126 |
127 |
128 |
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 |
--------------------------------------------------------------------------------