├── .coveragerc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── build.yml │ ├── coverage.yml │ ├── lint.yml │ ├── publish.yml │ ├── stale.yml │ └── tests.yml ├── .gitignore ├── API.md ├── CHANGES.rst ├── CONTRIBUTING.rst ├── Dockerfile ├── LICENSE.md ├── MANIFEST.in ├── README.rst ├── blinkapp ├── __init__.py ├── blinkapp.py ├── build.sh └── run.sh ├── blinkpy ├── __init__.py ├── api.py ├── auth.py ├── blinkpy.py ├── camera.py ├── helpers │ ├── __init__.py │ ├── constants.py │ ├── errors.py │ └── util.py └── sync_module.py ├── blinksync ├── blinksync.py └── forms.py ├── codecov.yml ├── pylintrc ├── pyproject.toml ├── pytest.ini ├── requirements.txt ├── requirements_test.txt ├── tests ├── __init__.py ├── mock_responses.py ├── test_api.py ├── test_auth.py ├── test_blink_functions.py ├── test_blinkpy.py ├── test_camera_functions.py ├── test_cameras.py ├── test_doorbell_as_sync.py ├── test_errors.py ├── test_mini_as_sync.py ├── test_sync_functions.py ├── test_sync_module.py └── test_util.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | parallel = true 3 | omit = 4 | tests/* 5 | setup.py 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 13 | 1. 14 | 2. 15 | 3. 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Home Assistant version (if applicable):** 21 | 22 | **`blinkpy` version (not needed if filling out Home Assistant version):** 23 | 24 | **Log Output/Additional Information** 25 | If using home-assistant, please paste the output of the log showing your error below. If not, please include any additional useful information. 26 | 27 | ``` 28 | PASTE LOG OUTPUT HERE 29 | ``` 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Additional context** 14 | Add any other context about the feature request (such as API endpoint responses, etc). 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description: 2 | 3 | 4 | **Related issue (if applicable):** fixes # 5 | 6 | ## Checklist: 7 | - [ ] Local tests with `tox` run successfully **PR cannot be meged unless tests pass** 8 | - [ ] Changes tested locally to ensure platform still works as intended 9 | - [ ] Tests added to verify new code works 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | reviewers: 9 | - fronzbot 10 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ master, dev ] 6 | pull_request: 7 | branches: [ master, dev ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.platform }} 12 | strategy: 13 | matrix: 14 | platform: 15 | - ubuntu-latest 16 | python-version: ['3.11'] 17 | steps: 18 | - name: Check out code from GitHub 19 | uses: actions/checkout@v3 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install -r requirements.txt 28 | pip install -r requirements_test.txt 29 | pip install tox 30 | - name: Build Wheel 31 | run: | 32 | tox -r -e build 33 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | 3 | on: 4 | push: 5 | branches: [ master, dev ] 6 | pull_request: 7 | branches: [ master, dev ] 8 | 9 | jobs: 10 | coverage: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ['3.11'] 15 | steps: 16 | - name: Check out code from GitHub 17 | uses: actions/checkout@v4.1.6 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install -r requirements.txt 26 | pip install -r requirements_test.txt 27 | pip install tox 28 | - name: Run Coverage 29 | run: | 30 | tox -r -e cov 31 | - name: Upload coverage 32 | uses: actions/upload-artifact@v4.3.3 33 | with: 34 | name: coverage-${{ matrix.python-version }} 35 | path: coverage.xml 36 | overwrite: true 37 | upload-coverage: 38 | runs-on: ubuntu-latest 39 | strategy: 40 | matrix: 41 | python-version: ['3.11'] 42 | needs: 43 | - coverage 44 | timeout-minutes: 10 45 | steps: 46 | - name: Check out code from GitHub 47 | uses: actions/checkout@v4.1.6 48 | - name: Download all coverage artifacts 49 | uses: actions/download-artifact@v4.1.7 50 | with: 51 | name: coverage-${{ matrix.python-version }} 52 | path: coverage.xml 53 | - name: Upload coverage to Codecov 54 | uses: codecov/codecov-action@v4.4.1 55 | with: 56 | fail_ci_if_error: true 57 | token: ${{ secrets.CODECOV_TOKEN }} 58 | name: blinkpy 59 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Lint 5 | 6 | on: 7 | push: 8 | branches: [ master, dev ] 9 | pull_request: 10 | branches: [ master, dev ] 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | max-parallel: 2 16 | matrix: 17 | python-version: ['3.9', '3.10', '3.11', '3.12'] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install -r requirements.txt 29 | pip install -r requirements_test.txt 30 | - name: Ruff 31 | run: | 32 | ruff check blinkpy tests blinkapp 33 | - name: Black 34 | run: | 35 | black --check --color --diff blinkpy tests blinkapp 36 | - name: RST-Lint 37 | run: | 38 | rst-lint README.rst CHANGES.rst CONTRIBUTING.rst 39 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: '3.11' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install twine build 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python -m build 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. 2 | # 3 | # You can adjust the behavior by modifying this file. 4 | # For more information, see: 5 | # https://github.com/actions/stale 6 | name: Stale 7 | 8 | on: 9 | schedule: 10 | - cron: '13 * * * *' 11 | 12 | jobs: 13 | stale: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | issues: write 17 | pull-requests: write 18 | 19 | steps: 20 | - name: stale-issues 21 | uses: actions/stale@v5 22 | with: 23 | repo-token: ${{ secrets.GITHUB_TOKEN }} 24 | days-before-stale: 60 25 | days-before-close: 7 26 | days-before-pr-stale: -1 27 | days-before-pr-close: -1 28 | remove-stale-when-updated: true 29 | stale-issue-label: "stale" 30 | exempt-issue-labels: "no-stale,help-wanted,priority" 31 | stale-issue-message: > 32 | There hasn't been any activity on this issue recently. 33 | Please make sure to update to the latest blinkpy version and 34 | check if that solves the issue. Let us know if that works for you by 35 | adding a comment 👍 36 | 37 | This issue has now been marked as stale and will be closed if no 38 | further activity occurs. Thank you for your contributions. 39 | - name: stale-pulls 40 | uses: actions/stale@v5 41 | with: 42 | repo-token: ${{ secrets.GITHUB_TOKEN }} 43 | days-before-stale: 90 44 | days-before-close: 7 45 | days-before-issue-stale: -1 46 | days-before-issue-close: -1 47 | remove-stale-when-updated: true 48 | stale-issue-label: "stale" 49 | exempt-issue-labels: "no-stale" 50 | stale-pr-message: > 51 | There hasn't been any activity on this pull request recently. This 52 | pull request has been automatically marked as stale because of that 53 | and will be closed if no further activity occurs within 7 days. 54 | 55 | Thank you for your contributions. 56 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: [ master, dev ] 6 | pull_request: 7 | branches: [ master, dev ] 8 | 9 | jobs: 10 | pytest: 11 | runs-on: ${{ matrix.platform }} 12 | strategy: 13 | max-parallel: 4 14 | matrix: 15 | platform: 16 | - ubuntu-latest 17 | python-version: ['3.9', '3.10', '3.11', '3.12'] 18 | steps: 19 | - name: Check out code from GitHub 20 | uses: actions/checkout@v3 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install -r requirements.txt 29 | pip install -r requirements_test.txt 30 | pip install . 31 | - name: Tests 32 | run: | 33 | python -m pytest \ 34 | --timeout=30 \ 35 | --durations=10 \ 36 | --cov=blinkpy \ 37 | --cov-report term-missing 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pytest_cache/* 2 | .cache/* 3 | .tox/* 4 | __pycache__/* 5 | htmlcov/* 6 | .coverage 7 | .coverage.* 8 | coverage.xml 9 | *.pyc 10 | *.egg*/* 11 | dist/* 12 | .sh 13 | build/* 14 | docs/_build 15 | *.log 16 | venv 17 | .session* 18 | Pipfile 19 | Pipfile.lock 20 | blink.json 21 | blinktest.py 22 | .vscode/* 23 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # BlinkMonitorProtocol 2 | Unofficial documentation for the Client API of the Blink Wire-Free HD Home Monitoring & Alert System. 3 | 4 | Copied from https://github.com/MattTW/BlinkMonitorProtocol 5 | 6 | I am not affiliated with the company in any way - this documentation is strictly **"AS-IS"**. My goal was to uncover enough to arm and disarm the system programatically so that I can issue those commands in sync with my home alarm system arm/disarm. Just some raw notes at this point but should be enough for creating programmatic APIs. Lots more to be discovered and documented - feel free to contribute! 7 | 8 | The Client API is a straightforward REST API using JSON and HTTPS. 9 | 10 | ## Login 11 | 12 | Client login to the Blink Servers. 13 | 14 | **Request:** 15 | >curl -H "Host: prod.immedia-semi.com" -H "Content-Type: application/json" --data-binary '{ 16 | > "password" : "*your blink password*", 17 | > "client_specifier" : "iPhone 9.2 | 2.2 | 222", 18 | > "email" : "*your blink login/email*" 19 | >}' --compressed https://rest.prod.immedia-semi.com/login 20 | 21 | **Response:** 22 | >{"authtoken":{"authtoken":"*an auth token*","message":"auth"},"networks":{"*network id*":{"name":"*name*","onboarded":true}},"region":{"*regioncode for endpoint*":"*region name"}} 23 | 24 | **Notes:** 25 | The authtoken value is passed in a header in future calls. 26 | The region code for endpoint is required to form the URL of the REST endpoint for future calls. 27 | Depending on the region you are registered you will need to change the REST endpoints below: 28 | - from `https://rest.prod.immedia-semi.com` 29 | - to `https://rest.prde.immedia-semi.com` if e.g. your device is registered in Germany 30 | Please note that at this moment it seems that all regions are not implemented equally: not all endpoints are available in all regions 31 | 32 | ## Networks 33 | 34 | Obtain information about the Blink networks defined for the logged in user. 35 | 36 | **Request:** 37 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/networks 38 | 39 | **Response:** 40 | JSON response containing information including Network ID and Account ID. 41 | 42 | **Notes:** 43 | Network ID is needed to issue arm/disarm calls 44 | 45 | 46 | ## Sync Modules 47 | 48 | Obtain information about the Blink Sync Modules on the given network. 49 | 50 | **Request:** 51 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/network/*network_id_from_networks_call*/syncmodules 52 | 53 | **Response:** 54 | JSON response containing information about the known state of the Sync module, most notably if it is online 55 | 56 | **Notes:** 57 | Probably not strictly needed but checking result can verify that the sync module is online and will respond to requests to arm/disarm, etc. 58 | 59 | 60 | ## Arm 61 | 62 | Arm the given network (start recording/reporting motion events) 63 | 64 | **Request:** 65 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --data-binary --compressed https://rest.prod.immedia-semi.com/network/*network_id_from_networks_call*/arm 66 | 67 | **Response:** 68 | JSON response containing information about the arm command request, including the command/request ID 69 | 70 | **Notes:** 71 | When this call returns, it does not mean the arm request is complete, the client must gather the request ID from the response and poll for the status of the command. 72 | 73 | ## Disarm 74 | 75 | Disarm the given network (stop recording/reporting motion events) 76 | 77 | **Request:** 78 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --data-binary --compressed https://rest.prod.immedia-semi.com/network/*network_id_from_networks_call*/disarm 79 | 80 | **Response:** 81 | JSON response containing information about the disarm command request, including the command/request ID 82 | 83 | **Notes:** 84 | When this call returns, it does not mean the disarm request is complete, the client must gather the request ID from the response and poll for the status of the command. 85 | 86 | 87 | ## Command Status 88 | 89 | Get status info on the given command 90 | 91 | **Request:** 92 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/network/*network_id*/command/*command_id* 93 | 94 | **Response:** 95 | JSON response containing state information of the given command, most notably whether it has completed and was successful. 96 | 97 | **Notes:** 98 | After an arm/disarm command, the client appears to poll this URL every second or so until the response indicates the command is complete. 99 | 100 | **Known Commands:** 101 | lv_relay, arm, disarm, thumbnail, clip 102 | 103 | ## Home Screen 104 | 105 | Return information displayed on the home screen of the mobile client 106 | 107 | **Request:** 108 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/homescreen 109 | 110 | **Response:** 111 | JSON response containing information that the mobile client displays on the home page, including: status, armed state, links to thumbnails for each camera, etc. 112 | 113 | **Notes:** 114 | Not necessary to as part of issuing arm/disarm commands, but contains good summary info. 115 | 116 | ## Events, thumbnails & video captures 117 | 118 | **Request** 119 | Get events for a given network (sync module) -- Need network ID from home 120 | 121 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/events/network/*network__id* 122 | 123 | **Response** 124 | A json list of evets incluing URL's. Replace the "mp4" with "jpg" extension to get the thumbnail of each clip 125 | 126 | 127 | **Request** 128 | Get a video clip from the events list 129 | 130 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed **video url from events list.mp4** > video.mp4 131 | 132 | **Response** 133 | The mp4 video 134 | 135 | **Request** 136 | Get a thumbnail from the events list 137 | 138 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed **video url from events list.jpg** > video_thumb.jpg 139 | 140 | **Response** 141 | The jpg bytes. 142 | 143 | **Notes** 144 | Note that you replace the 'mp4' with a 'jpg' to get the thumbnail 145 | 146 | **Request** 147 | Captures a new thumbnail for a camera 148 | 149 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --data-binary --compressed https://rest.prod.immedia-semi.com/network/*network_id*/camera/*camera_id*/thumbnail 150 | 151 | **Response** 152 | Command information. 153 | 154 | **Request** 155 | Captures a new video for a camera 156 | 157 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --data-binary --compressed https://rest.prod.immedia-semi.com/network/*network_id*/camera/*camera_id*/clip 158 | 159 | **Response** 160 | Command information. 161 | 162 | ## Video Information 163 | 164 | **Request** 165 | Get the total number of videos in the system 166 | 167 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/api/v2/videos/count 168 | 169 | **Response** 170 | JSON response containing the total video count. 171 | 172 | **Request** 173 | Gets a paginated set of video information 174 | 175 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/api/v2/videos/page/0 176 | 177 | **Response** 178 | JSON response containing a set of video information, including: camera name, creation time, thumbnail URI, size, length 179 | 180 | **Request** 181 | Gets information for a specific video by ID 182 | 183 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/api/v2/video/*video_id* 184 | 185 | **Response** 186 | JSON response containing video information 187 | 188 | **Request** 189 | Gets a list of unwatched videos 190 | 191 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/api/v2/videos/unwatched 192 | 193 | **Response** 194 | JSON response containing unwatched video information 195 | 196 | **Request** 197 | Deletes a video 198 | 199 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --data-binary --compressed https://rest.prod.immedia-semi.com/api/v2/video/*video_id*/delete 200 | 201 | **Response** 202 | Unknown - not tested 203 | 204 | **Request** 205 | Deletes all videos 206 | 207 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --data-binary --compressed https://rest.prod.immedia-semi.com/api/v2/videos/deleteall 208 | 209 | **Response** 210 | Unknown - not tested 211 | 212 | ## Cameras 213 | 214 | **Request** 215 | Gets a list of cameras 216 | 217 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/network/*network_id*/cameras 218 | 219 | **Response** 220 | JSON response containing camera information 221 | 222 | **Request** 223 | Gets information for one camera 224 | 225 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/network/*network_id*/camera/*camera_id* 226 | 227 | **Response** 228 | JSON response containing camera information 229 | 230 | **Request** 231 | Gets camera sensor information 232 | 233 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/network/*network_id*/camera/*camera_id*/signals 234 | 235 | **Response** 236 | JSON response containing camera sensor information, such as wifi strength, temperature, and battery level 237 | 238 | **Request** 239 | Enables motion detection for one camera 240 | 241 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: $auth_token" --data-binary --compressed https://rest.prod.immedia-semi.com/network/*network_id*/camera/*camera_id*/enable 242 | 243 | **Response** 244 | JSON response containing camera information 245 | 246 | **Request** 247 | Disables motion detection for one camera 248 | 249 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: $auth_token" --data-binary --compressed https://rest.prod.immedia-semi.com/network/*network_id*/camera/*camera_id*/disable 250 | 251 | **Response** 252 | JSON response containing camera information 253 | 254 | *Note*: enabling or disabling motion detection is independent of arming or disarming the system. No motion detection or video recording will take place unless the system is armed. 255 | 256 | 257 | ## Miscellaneous 258 | 259 | **Request** 260 | Gets information about devices that have connected to the blink service 261 | 262 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/account/clients 263 | 264 | **Response** 265 | JSON response containing client information, including: type, name, connection time, user ID 266 | 267 | **Request** 268 | Gets information about supported regions 269 | 270 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/regions 271 | 272 | **Response** 273 | JSON response containing region information 274 | 275 | **Request** 276 | Gets information about system health 277 | 278 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/health 279 | 280 | **Response** 281 | "all ports tested are open" 282 | 283 | **Request** 284 | Gets information about programs 285 | 286 | >curl -H "Host: prod.immedia-semi.com" -H "TOKEN_AUTH: *authtoken from login*" --compressed https://rest.prod.immedia-semi.com/api/v1/networks/*network_id*/programs 287 | 288 | **Response** 289 | Unknown. 290 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ======================== 2 | Contributing to blinkpy 3 | ======================== 4 | 5 | Everyone is welcome to contribute to blinkpy! The process to get started is described below. 6 | 7 | 8 | Fork the Repository 9 | ------------------- 10 | 11 | You can do this right in github: just click the 'fork' button at the top right. 12 | 13 | Start Developing 14 | ----------------- 15 | 16 | 1. Setup Local Repository 17 | 18 | .. code:: bash 19 | 20 | $ git clone https://github.com//blinkpy.git 21 | $ cd blinkpy 22 | $ git remote add upstream https://github.com/fronzbot/blinkpy.git 23 | 24 | 2. Create virtualenv and install dependencies 25 | 26 | .. code:: bash 27 | 28 | $ python -m venv venv 29 | $ source venv/bin/activate 30 | $ pip install -r requirements.txt 31 | $ pip install -r requirements_test.txt 32 | $ pre-commit install 33 | 34 | 3. Create a Local Branch 35 | 36 | First, you will want to create a new branch to hold your changes: 37 | ``git checkout -b `` 38 | 39 | 40 | 4. Make changes 41 | 42 | Now you can make changes to your code. It is worthwhile to test your code as you progress (see the **Testing** section) 43 | 44 | 5. Commit Your Changes 45 | 46 | To commit changes to your branch, simply add the files you want and the commit them to the branch. After that, you can push to your fork on GitHub: 47 | 48 | .. code:: bash 49 | 50 | $ git add . 51 | $ git commit 52 | $ git push origin HEAD 53 | 54 | 6. Submit your pull request on GitHub 55 | 56 | - On GitHub, navigate to the `blinkpy `__ repository. 57 | - In the "Branch" menu, choose the branch that contains your commits (from your fork). 58 | - To the right of the Branch menu, click New pull request. 59 | - The base branch dropdown menu should read ``dev``. Use the compare branch drop-down menu to choose the branch you made your changes in. 60 | - Type a title and complete the provided description for your pull request. 61 | - Click Create pull request. 62 | - More detailed instructions can be found here: `Creating a Pull Request` `__ 63 | 64 | 7. Prior to merge approval 65 | 66 | Finally, the ``blinkpy`` repository uses continuous integration tools to run tests prior to merging. If there are any problems, you will see a red 'X' next to your pull request. 67 | 68 | 69 | Testing 70 | ------- 71 | 72 | It is important to test the code to make sure your changes don't break anything major and that they pass PEP8 style conventions. 73 | First, you need to locally install ``tox`` 74 | 75 | .. code:: bash 76 | 77 | $ pip install tox 78 | 79 | 80 | You can then run all of the tests with the following command: 81 | 82 | .. code:: bash 83 | 84 | $ tox 85 | 86 | **Tips** 87 | 88 | If you only want to see if you can pass the local tests, you can run ``tox -e py39`` (or whatever python version you have installed. Only ``py39`` through ``py312`` will be accepted). If you just want to check for style violations, you can run ``tox -e lint``. Regardless, when you submit a pull request, your code MUST pass both the unit tests, and the linters. 89 | 90 | If you need to change anything in ``requirements.txt`` for any reason, you'll want to regenerate the virtual envrionments used by ``tox`` by running with the ``-r`` flag: ``tox -r`` 91 | 92 | If you want to run a single test (perhaps you only changed a small thing in one file) you can run ``tox -e py37 -- tests/.py -x``. This will run the test ``.py`` and stop testing upon the first failure, making it easier to figure out why a particular test might be failing. The test structure mimics the library structure, so if you changed something in ``sync_module.py``, the associated test file would be in ``test_sync_module.py`` (ie. the filename is prepended with ``test_``. 93 | 94 | 95 | Catching Up With Reality 96 | ------------------------- 97 | 98 | If your code is taking a while to develop, you may be behind the ``dev`` branch, in which case you need to catch up before creating your pull-request. To do this you can run ``git rebase`` as follows (running this on your local branch): 99 | 100 | .. code:: bash 101 | 102 | $ git fetch upstream dev 103 | $ git rebase upstream/dev 104 | 105 | If rebase detects conflicts, repeat the following process until all changes have been resolved: 106 | 107 | 1. ``git status`` shows you the file with a conflict. You will need to edit that file and resolve the lines between ``<<<< | >>>>``. 108 | 2. Add the modified file: ``git add `` or ``git add .``. 109 | 3. Continue rebase: ``git rebase --continue``. 110 | 4. Repeat until all conflicts resolved. 111 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-alpine 2 | LABEL maintainer="Kevin Fronczak " 3 | 4 | VOLUME /media 5 | 6 | RUN python -m pip install --upgrade pip 7 | RUN pip3 install blinkpy 8 | 9 | COPY blinkapp/ . 10 | 11 | ENTRYPOINT ["python", "./blinkapp.py"] 12 | CMD [] 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Kevin Fronczak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE.md 3 | include API.md 4 | include tests/*.py 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | blinkpy |Build Status| |Coverage Status| |PyPi Version| |Codestyle| 2 | ============================================================================================= 3 | A Python library for the Blink Camera system (Python 3.9+) 4 | 5 | Like the library? Consider buying me a cup of coffee! 6 | 7 | `Buy me a Coffee! `__ 8 | 9 | **BREAKING CHANGE WARNING:** 10 | As of ``0.22.0`` the library uses asyncio which will break any user scripts used prior to this version. Please see the updated examples below and the ``blinkapp.py`` or ``blinksync.py`` examples in the ``blinkapp/`` directory for examples on how to migrate. 11 | 12 | **Disclaimer:** 13 | Published under the MIT license - See LICENSE file for more details. 14 | 15 | "Blink Wire-Free HS Home Monitoring & Alert Systems" is a trademark owned by Immedia Inc., see www.blinkforhome.com for more information. 16 | I am in no way affiliated with Blink, nor Immedia Inc. 17 | 18 | Original protocol hacking by MattTW : https://github.com/MattTW/BlinkMonitorProtocol 19 | 20 | API calls faster than 60 seconds is not recommended as it can overwhelm Blink's servers. Please use this module responsibly. 21 | 22 | Installation 23 | ------------- 24 | ``pip install blinkpy`` 25 | 26 | Installing Development Version 27 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 28 | To install the current development version, perform the following steps. Note that the following will create a blinkpy directory in your home area: 29 | 30 | .. code:: bash 31 | 32 | $ cd ~ 33 | $ git clone https://github.com/fronzbot/blinkpy.git 34 | $ cd blinkpy 35 | $ pip install . 36 | 37 | 38 | If you'd like to contribute to this library, please read the `contributing instructions `__. 39 | 40 | 41 | Purpose 42 | ------- 43 | This library was built with the intention of allowing easy communication with Blink camera systems, specifically to support the `Blink component `__ in `homeassistant `__. 44 | 45 | Quick Start 46 | ============= 47 | The simplest way to use this package from a terminal is to call ``await Blink.start()`` which will prompt for your Blink username and password and then log you in. In addition, http requests are throttled internally via use of the ``Blink.refresh_rate`` variable, which can be set at initialization and defaults to 30 seconds. 48 | 49 | .. code:: python 50 | 51 | import asyncio 52 | from aiohttp import ClientSession 53 | from blinkpy.blinkpy import Blink 54 | 55 | async def start(): 56 | blink = Blink(session=ClientSession()) 57 | await blink.start() 58 | return blink 59 | 60 | blink = asyncio.run(start()) 61 | 62 | 63 | This flow will prompt you for your username and password. Once entered, if you likely will need to send a 2FA key to the blink servers (this pin is sent to your email address). When you receive this pin, enter at the prompt and the Blink library will proceed with setup. 64 | 65 | Starting blink without a prompt 66 | ------------------------------- 67 | In some cases, having an interactive command-line session is not desired. In this case, you will need to set the ``Blink.auth.no_prompt`` value to ``True``. In addition, since you will not be prompted with a username and password, you must supply the login data to the blink authentication handler. This is best done by instantiating your own auth handler with a dictionary containing at least your username and password. 68 | 69 | .. code:: python 70 | 71 | import asyncio 72 | from aiohttp import ClientSession 73 | from blinkpy.blinkpy import Blink 74 | from blinkpy.auth import Auth 75 | 76 | async def start(): 77 | blink = Blink(session=ClientSession()) 78 | # Can set no_prompt when initializing auth handler 79 | auth = Auth({"username": , "password": }, no_prompt=True) 80 | blink.auth = auth 81 | await blink.start() 82 | return blink 83 | 84 | blink = asyncio.run(start()) 85 | 86 | 87 | Since you will not be prompted for any 2FA pin, you must call the ``blink.auth.send_auth_key`` function. There are two required parameters: the ``blink`` object as well as the ``key`` you received from Blink for 2FA: 88 | 89 | .. code:: python 90 | 91 | await auth.send_auth_key(blink, ) 92 | await blink.setup_post_verify() 93 | 94 | 95 | Supplying credentials from file 96 | -------------------------------- 97 | Other use cases may involved loading credentials from a file. This file must be ``json`` formatted and contain a minimum of ``username`` and ``password``. A built in function in the ``blinkpy.helpers.util`` module can aid in loading this file. Note, if ``no_prompt`` is desired, a similar flow can be followed as above. 98 | 99 | .. code:: python 100 | 101 | import asyncio 102 | from aiohttp import ClientSession 103 | from blinkpy.blinkpy import Blink 104 | from blinkpy.auth import Auth 105 | from blinkpy.helpers.util import json_load 106 | 107 | async def start(): 108 | blink = Blink() 109 | auth = Auth(await json_load("")) 110 | blink.auth = auth 111 | await blink.start() 112 | return blink 113 | 114 | blink = asyncio.run(start()) 115 | 116 | 117 | Saving credentials 118 | ------------------- 119 | This library also allows you to save your credentials to use in future sessions. Saved information includes authentication tokens as well as unique ids which should allow for a more streamlined experience and limits the frequency of login requests. This data can be saved as follows (it can then be loaded by following the instructions above for supplying credentials from a file): 120 | 121 | .. code:: python 122 | 123 | await blink.save("") 124 | 125 | 126 | Getting cameras 127 | ---------------- 128 | Cameras are instantiated as individual ``BlinkCamera`` classes within a ``BlinkSyncModule`` instance. All of your sync modules are stored within the ``Blink.sync`` dictionary and can be accessed using the name of the sync module as the key (this is the name of your sync module in the Blink App). 129 | 130 | The below code will display cameras and their available attributes: 131 | 132 | .. code:: python 133 | 134 | for name, camera in blink.cameras.items(): 135 | print(name) # Name of the camera 136 | print(camera.attributes) # Print available attributes of camera 137 | 138 | 139 | The most recent images and videos can be accessed as a bytes-object via internal variables. These can be updated with calls to ``Blink.refresh()`` but will only make a request if motion has been detected or other changes have been found. This can be overridden with the ``force`` flag, but this should be used for debugging only since it overrides the internal request throttling. 140 | 141 | .. code:: python 142 | 143 | camera = blink.cameras['SOME CAMERA NAME'] 144 | await blink.refresh(force=True) # force a cache update USE WITH CAUTION 145 | camera.image_from_cache # bytes-like image object (jpg) 146 | camera.video_from_cache # bytes-like video object (mp4) 147 | 148 | The ``blinkpy`` api also allows for saving images and videos to a file and snapping a new picture from the camera remotely: 149 | 150 | .. code:: python 151 | 152 | camera = blink.cameras['SOME CAMERA NAME'] 153 | await camera.snap_picture() # Take a new picture with the camera 154 | await blink.refresh() # Get new information from server 155 | await camera.image_to_file('/local/path/for/image.jpg') 156 | await camera.video_to_file('/local/path/for/video.mp4') 157 | 158 | 159 | Arming Blink 160 | ------------- 161 | Methods exist to arm/disarm the sync module, as well as enable/disable motion detection for individual cameras. This is done as follows: 162 | 163 | .. code:: python 164 | 165 | # Arm a sync module 166 | await blink.sync["SYNC MODULE NAME"].async_arm(True) 167 | 168 | # Disarm a sync module 169 | await blink.sync["SYNC MODULE NAME"].async_arm(False) 170 | 171 | # Print arm status of a sync module - a system refresh should be performed first 172 | await blink.refresh() 173 | sync = blink.sync["SYNC MODULE NAME"] 174 | print(f"{sync.name} status: {sync.arm}") 175 | 176 | Similar methods exist for individual cameras: 177 | 178 | .. code:: python 179 | 180 | camera = blink.cameras["SOME CAMERA NAME"] 181 | 182 | # Enable motion detection on a camera 183 | await camera.async_arm(True) 184 | 185 | # Disable motion detection on a camera 186 | await camera.async_arm( False) 187 | 188 | # Print arm status of a sync module - a system refresh should be performed first 189 | await blink.refresh() 190 | print(f"{camera.name} status: {camera.arm}") 191 | 192 | 193 | Download videos 194 | ---------------- 195 | You can also use this library to download all videos from the server. In order to do this, you must specify a ``path``. You may also specifiy a how far back in time to go to retrieve videos via the ``since=`` variable (a simple string such as ``"2017/09/21"`` is sufficient), as well as how many pages to traverse via the ``stop=`` variable. Note that by default, the library will search the first ten pages which is sufficient in most use cases. Additionally, you can specify one or more cameras via the ``camera=`` property. This can be a single string indicating the name of the camera, or a list of camera names. By default, it is set to the string ``'all'`` to grab videos from all cameras. If you are downloading many items, setting the ``delay`` parameter is advised in order to throttle sequential calls to the API. By default this is set to ``1`` but can be any integer representing the number of seconds to delay between calls. 196 | 197 | Example usage, which downloads all videos recorded since July 4th, 2018 at 9:34am to the ``/home/blink`` directory with a 2s delay between calls: 198 | 199 | .. code:: python 200 | 201 | await blink.download_videos('/home/blink', since='2018/07/04 09:34', delay=2) 202 | 203 | 204 | Sync Module Local Storage 205 | ========================= 206 | 207 | Description of how I think the local storage API is used by Blink 208 | ----------------------------------------------------------------- 209 | 210 | Since local storage is within a customer's residence, there are no guarantees for latency 211 | and availability. As a result, the API seems to be built to deal with these conditions. 212 | 213 | In general, the approach appears to be this: The Blink app has to query the sync 214 | module for all information regarding the stored clips. On a click to view a clip, the app asks 215 | for the full list of stored clips, finds the clip in question, uploads the clip to the 216 | cloud, and then downloads the clip back from a cloud URL. Each interaction requires polling for 217 | the response since networking conditions are uncertain. The app also caches recent clips and the manifest. 218 | 219 | API steps 220 | --------- 221 | 1. Request the local storage manifest be created by the sync module. 222 | 223 | * POST **{base_url}/api/v1/accounts/{account_id}/networks/{network_id}/sync_modules/{sync_id}/local_storage/manifest/request** 224 | * Returns an ID that is used to get the manifest. 225 | 226 | 2. Retrieve the local storage manifest. 227 | 228 | * GET **{base_url}/api/v1/accounts/{account_id}/networks/{network_id}/sync_modules/{sync_id}/local_storage/manifest/request/{manifest_request_id}** 229 | * Returns full manifest. 230 | * Extract the manifest ID from the response. 231 | 232 | 3. Find a clip ID in the clips list from the manifest to retrieve, and request an upload. 233 | 234 | * POST **{base_url}/api/v1/accounts/{account_id}/networks/{network_id}/sync_modules/{sync_id}/local_storage/manifest/{manifest_id}/clip/request/{clip_id}** 235 | * When the response is returned, the upload has finished. 236 | 237 | 4. Download the clip using the same clip ID. 238 | 239 | * GET **{base_url}/api/v1/accounts/{account_id}/networks/{network_id}/sync_modules/{sync_id}/local_storage/manifest/{manifest_id}/clip/request/{clip_id}** 240 | 241 | 242 | 243 | .. |Build Status| image:: https://github.com/fronzbot/blinkpy/workflows/build/badge.svg 244 | :target: https://github.com/fronzbot/blinkpy/actions?query=workflow%3Abuild 245 | .. |Coverage Status| image:: https://codecov.io/gh/fronzbot/blinkpy/branch/dev/graph/badge.svg 246 | :target: https://codecov.io/gh/fronzbot/blinkpy 247 | .. |PyPi Version| image:: https://img.shields.io/pypi/v/blinkpy.svg 248 | :target: https://pypi.python.org/pypi/blinkpy 249 | .. |Codestyle| image:: https://img.shields.io/badge/code%20style-black-000000.svg 250 | :target: https://github.com/psf/black 251 | -------------------------------------------------------------------------------- /blinkapp/__init__.py: -------------------------------------------------------------------------------- 1 | """Python init file for blinkapp.py.""" 2 | -------------------------------------------------------------------------------- /blinkapp/blinkapp.py: -------------------------------------------------------------------------------- 1 | """Script to run blinkpy as an blinkapp.""" 2 | 3 | from os import environ 4 | import asyncio 5 | from datetime import datetime, timedelta 6 | from aiohttp import ClientSession 7 | from blinkpy.blinkpy import Blink 8 | from blinkpy.auth import Auth 9 | from blinkpy.helpers.util import json_load 10 | 11 | CREDFILE = environ.get("CREDFILE") 12 | TIMEDELTA = timedelta(environ.get("TIMEDELTA", "1")) 13 | 14 | 15 | def get_date(): 16 | """Return now - timedelta for blinkpy.""" 17 | return (datetime.now() - TIMEDELTA).isoformat() 18 | 19 | 20 | async def download_videos(blink, save_dir="/media"): 21 | """Make request to download videos.""" 22 | await blink.download_videos(save_dir, since=get_date()) 23 | 24 | 25 | async def start(session: ClientSession): 26 | """Startup blink app.""" 27 | blink = Blink(session=session) 28 | blink.auth = Auth(await json_load(CREDFILE), session=session) 29 | await blink.start() 30 | return blink 31 | 32 | 33 | async def main(): 34 | """Run the blink app.""" 35 | session = ClientSession() 36 | blink = await start(session) 37 | await download_videos(blink) 38 | await blink.save(CREDFILE) 39 | await session.close() 40 | 41 | 42 | if __name__ == "__main__": 43 | loop = asyncio.get_event_loop() 44 | loop.run_until_complete(main()) 45 | -------------------------------------------------------------------------------- /blinkapp/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker build -t fronzbot/blinkpy:latest ./ 3 | -------------------------------------------------------------------------------- /blinkapp/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # bash run.sh [username] [password] 3 | 4 | if [ "$#" -ne 2 ]; then 5 | echo "" 6 | echo "ERROR: Requries Blink username and password as arguments." 7 | echo "bash run.sh [username] [password]" 8 | echo "" 9 | exit 1 10 | fi 11 | 12 | set -ex 13 | USER=fronzbot 14 | IMAGE=blinkpy 15 | CONFIG=$HOME/blinkpy_media 16 | USERNAME=$1 17 | PASSWORD=$2 18 | 19 | mkdir -p $CONFIG 20 | 21 | result=$(docker images -q $IMAGE) 22 | if [ $result ]; then 23 | docker rm $IMAGE 24 | fi 25 | docker run -it --name ${IMAGE} \ 26 | -v $CONFIG:/media \ 27 | -e USERNAME=${USERNAME} \ 28 | -e PASSWORD=${PASSWORD} \ 29 | $USER/$IMAGE \ 30 | /bin/bash 31 | 32 | -------------------------------------------------------------------------------- /blinkpy/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for blinkpy.""" 2 | -------------------------------------------------------------------------------- /blinkpy/api.py: -------------------------------------------------------------------------------- 1 | """Implements known blink API calls.""" 2 | 3 | import logging 4 | import string 5 | from json import dumps 6 | from asyncio import sleep 7 | from blinkpy.helpers.util import ( 8 | get_time, 9 | Throttle, 10 | local_storage_clip_url_template, 11 | ) 12 | from blinkpy.helpers.constants import DEFAULT_URL, TIMEOUT, DEFAULT_USER_AGENT 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | MIN_THROTTLE_TIME = 5 17 | COMMAND_POLL_TIME = 1 18 | MAX_RETRY = 120 19 | 20 | 21 | async def request_login( 22 | auth, 23 | url, 24 | login_data, 25 | is_retry=False, 26 | ): 27 | """ 28 | Login request. 29 | 30 | :param auth: Auth instance. 31 | :param url: Login url. 32 | :param login_data: Dictionary containing blink login data. 33 | :param is_retry: 34 | """ 35 | headers = { 36 | "Host": DEFAULT_URL, 37 | "Content-Type": "application/json", 38 | "user-agent": DEFAULT_USER_AGENT, 39 | } 40 | 41 | data = dumps( 42 | { 43 | "email": login_data["username"], 44 | "password": login_data["password"], 45 | "unique_id": login_data["uid"], 46 | "device_identifier": login_data["device_id"], 47 | "client_name": "Computer", 48 | "reauth": True, 49 | } 50 | ) 51 | 52 | return await auth.query( 53 | url=url, 54 | headers=headers, 55 | data=data, 56 | json_resp=False, 57 | reqtype="post", 58 | is_retry=is_retry, 59 | ) 60 | 61 | 62 | async def request_verify(auth, blink, verify_key): 63 | """Send verification key to blink servers.""" 64 | url = ( 65 | f"{blink.urls.base_url}/api/v5/accounts/{blink.account_id}" 66 | f"/users/{blink.auth.user_id}" 67 | f"/clients/{blink.client_id}/client_verification/pin/verify" 68 | ) 69 | data = dumps({"pin": verify_key}) 70 | return await auth.query( 71 | url=url, 72 | headers=auth.header, 73 | data=data, 74 | json_resp=False, 75 | reqtype="post", 76 | ) 77 | 78 | 79 | async def request_logout(blink): 80 | """Logout of blink servers.""" 81 | url = ( 82 | f"{blink.urls.base_url}/api/v4/account/{blink.account_id}" 83 | f"/client/{blink.client_id}/logout" 84 | ) 85 | return await http_post(blink, url=url) 86 | 87 | 88 | async def request_networks(blink): 89 | """Request all networks information.""" 90 | url = f"{blink.urls.base_url}/networks" 91 | return await http_get(blink, url) 92 | 93 | 94 | async def request_network_update(blink, network): 95 | """ 96 | Request network update. 97 | 98 | :param blink: Blink instance. 99 | :param network: Sync module network id. 100 | """ 101 | url = f"{blink.urls.base_url}/network/{network}/update" 102 | response = await http_post(blink, url) 103 | await wait_for_command(blink, response) 104 | return response 105 | 106 | 107 | async def request_user(blink): 108 | """Get user information from blink servers.""" 109 | url = f"{blink.urls.base_url}/user" 110 | return await http_get(blink, url) 111 | 112 | 113 | async def request_network_status(blink, network): 114 | """ 115 | Request network information. 116 | 117 | :param blink: Blink instance. 118 | :param network: Sync module network id. 119 | """ 120 | url = f"{blink.urls.base_url}/network/{network}" 121 | return await http_get(blink, url) 122 | 123 | 124 | async def request_syncmodule(blink, network): 125 | """ 126 | Request sync module info. 127 | 128 | :param blink: Blink instance. 129 | :param network: Sync module network id. 130 | """ 131 | url = f"{blink.urls.base_url}/network/{network}/syncmodules" 132 | return await http_get(blink, url) 133 | 134 | 135 | @Throttle(seconds=MIN_THROTTLE_TIME) 136 | async def request_system_arm(blink, network, **kwargs): 137 | """ 138 | Arm system. 139 | 140 | :param blink: Blink instance. 141 | :param network: Sync module network id. 142 | """ 143 | url = ( 144 | f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" 145 | f"/networks/{network}/state/arm" 146 | ) 147 | response = await http_post(blink, url) 148 | await wait_for_command(blink, response) 149 | return response 150 | 151 | 152 | @Throttle(seconds=MIN_THROTTLE_TIME) 153 | async def request_system_disarm(blink, network, **kwargs): 154 | """ 155 | Disarm system. 156 | 157 | :param blink: Blink instance. 158 | :param network: Sync module network id. 159 | """ 160 | url = ( 161 | f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" 162 | f"/networks/{network}/state/disarm" 163 | ) 164 | response = await http_post(blink, url) 165 | await wait_for_command(blink, response) 166 | return response 167 | 168 | 169 | async def request_notification_flags(blink, **kwargs): 170 | """ 171 | Get system notification flags. 172 | 173 | :param blink: Blink instance. 174 | """ 175 | url = ( 176 | f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" 177 | "/notifications/configuration" 178 | ) 179 | response = await http_get(blink, url) 180 | await wait_for_command(blink, response) 181 | return response 182 | 183 | 184 | async def request_set_notification_flag(blink, data_dict): 185 | """ 186 | Set a system notification flag. 187 | 188 | :param blink: Blink instance. 189 | :param data_dict: Dictionary of notifications to set. 190 | """ 191 | url = ( 192 | f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" 193 | "/notifications/configuration" 194 | ) 195 | data = dumps({"notifications": data_dict}) 196 | response = await http_post(blink, url, data=data, json=False) 197 | await wait_for_command(blink, response) 198 | return response 199 | 200 | 201 | async def request_command_status(blink, network, command_id): 202 | """ 203 | Request command status. 204 | 205 | :param blink: Blink instance. 206 | :param network: Sync module network id. 207 | :param command_id: Command id to check. 208 | """ 209 | url = f"{blink.urls.base_url}/network/{network}/command/{command_id}" 210 | return await http_get(blink, url) 211 | 212 | 213 | @Throttle(seconds=MIN_THROTTLE_TIME) 214 | async def request_homescreen(blink, **kwargs): 215 | """Request homescreen info.""" 216 | url = f"{blink.urls.base_url}/api/v3/accounts/{blink.account_id}/homescreen" 217 | return await http_get(blink, url) 218 | 219 | 220 | @Throttle(seconds=MIN_THROTTLE_TIME) 221 | async def request_sync_events(blink, network, **kwargs): 222 | """ 223 | Request events from sync module. 224 | 225 | :param blink: Blink instance. 226 | :param network: Sync module network id. 227 | """ 228 | url = f"{blink.urls.base_url}/events/network/{network}" 229 | return await http_get(blink, url) 230 | 231 | 232 | @Throttle(seconds=MIN_THROTTLE_TIME) 233 | async def request_new_image(blink, network, camera_id, **kwargs): 234 | """ 235 | Request to capture new thumbnail for camera. 236 | 237 | :param blink: Blink instance. 238 | :param network: Sync module network id. 239 | :param camera_id: Camera ID of camera to request new image from. 240 | """ 241 | url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/thumbnail" 242 | response = await http_post(blink, url) 243 | await wait_for_command(blink, response) 244 | return response 245 | 246 | 247 | @Throttle(seconds=MIN_THROTTLE_TIME) 248 | async def request_new_video(blink, network, camera_id, **kwargs): 249 | """ 250 | Request to capture new video clip. 251 | 252 | :param blink: Blink instance. 253 | :param network: Sync module network id. 254 | :param camera_id: Camera ID of camera to request new video from. 255 | """ 256 | url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/clip" 257 | response = await http_post(blink, url) 258 | await wait_for_command(blink, response) 259 | return response 260 | 261 | 262 | @Throttle(seconds=MIN_THROTTLE_TIME) 263 | async def request_video_count(blink, **kwargs): 264 | """Request total video count.""" 265 | url = f"{blink.urls.base_url}/api/v2/videos/count" 266 | return await http_get(blink, url) 267 | 268 | 269 | async def request_videos(blink, time=None, page=0): 270 | """ 271 | Perform a request for videos. 272 | 273 | :param blink: Blink instance. 274 | :param time: Get videos since this time. In epoch seconds. 275 | :param page: Page number to get videos from. 276 | """ 277 | timestamp = get_time(time) 278 | url = ( 279 | f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" 280 | f"/media/changed?since={timestamp}&page={page}" 281 | ) 282 | return await http_get(blink, url) 283 | 284 | 285 | async def request_cameras(blink, network): 286 | """ 287 | Request all camera information. 288 | 289 | :param Blink: Blink instance. 290 | :param network: Sync module network id. 291 | """ 292 | url = f"{blink.urls.base_url}/network/{network}/cameras" 293 | return await http_get(blink, url) 294 | 295 | 296 | async def request_camera_info(blink, network, camera_id): 297 | """ 298 | Request camera info for one camera. 299 | 300 | :param blink: Blink instance. 301 | :param network: Sync module network id. 302 | :param camera_id: Camera ID of camera to request info from. 303 | """ 304 | url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/config" 305 | return await http_get(blink, url) 306 | 307 | 308 | async def request_camera_usage(blink): 309 | """ 310 | Request camera status. 311 | 312 | :param blink: Blink instance. 313 | """ 314 | url = f"{blink.urls.base_url}/api/v1/camera/usage" 315 | return await http_get(blink, url) 316 | 317 | 318 | async def request_camera_liveview(blink, network, camera_id): 319 | """ 320 | Request camera liveview. 321 | 322 | :param blink: Blink instance. 323 | :param network: Sync module network id. 324 | :param camera_id: Camera ID of camera to request liveview from. 325 | """ 326 | url = ( 327 | f"{blink.urls.base_url}/api/v5/accounts/{blink.account_id}" 328 | f"/networks/{network}/cameras/{camera_id}/liveview" 329 | ) 330 | response = await http_post(blink, url) 331 | await wait_for_command(blink, response) 332 | return response 333 | 334 | 335 | async def request_camera_sensors(blink, network, camera_id): 336 | """ 337 | Request camera sensor info for one camera. 338 | 339 | :param blink: Blink instance. 340 | :param network: Sync module network id. 341 | :param camera_id: Camera ID of camera to request sensor info from. 342 | """ 343 | url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/signals" 344 | return await http_get(blink, url) 345 | 346 | 347 | @Throttle(seconds=MIN_THROTTLE_TIME) 348 | async def request_motion_detection_enable(blink, network, camera_id, **kwargs): 349 | """ 350 | Enable motion detection for a camera. 351 | 352 | :param blink: Blink instance. 353 | :param network: Sync module network id. 354 | :param camera_id: Camera ID of camera to enable. 355 | """ 356 | url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/enable" 357 | response = await http_post(blink, url) 358 | await wait_for_command(blink, response) 359 | return response 360 | 361 | 362 | @Throttle(seconds=MIN_THROTTLE_TIME) 363 | async def request_motion_detection_disable(blink, network, camera_id, **kwargs): 364 | """ 365 | Disable motion detection for a camera. 366 | 367 | :param blink: Blink instance. 368 | :param network: Sync module network id. 369 | :param camera_id: Camera ID of camera to disable. 370 | """ 371 | url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/disable" 372 | response = await http_post(blink, url) 373 | await wait_for_command(blink, response) 374 | return response 375 | 376 | 377 | async def request_local_storage_manifest(blink, network, sync_id): 378 | """ 379 | Update local manifest. 380 | 381 | Request creation of an updated manifest of video clips stored in 382 | sync module local storage. 383 | 384 | :param blink: Blink instance. 385 | :param network: Sync module network id. 386 | :param sync_id: ID of sync module. 387 | """ 388 | url = ( 389 | f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" 390 | f"/networks/{network}/sync_modules/{sync_id}" 391 | f"/local_storage/manifest/request" 392 | ) 393 | response = await http_post(blink, url) 394 | await wait_for_command(blink, response) 395 | return response 396 | 397 | 398 | async def get_local_storage_manifest(blink, network, sync_id, manifest_request_id): 399 | """ 400 | Request manifest of video clips stored in sync module local storage. 401 | 402 | :param blink: Blink instance. 403 | :param network: Sync module network id. 404 | :param sync_id: ID of sync module. 405 | :param manifest_request_id: Request ID of local storage manifest \ 406 | (requested creation of new manifest). 407 | """ 408 | url = ( 409 | f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" 410 | f"/networks/{network}/sync_modules/{sync_id}" 411 | f"/local_storage/manifest/request/{manifest_request_id}" 412 | ) 413 | return await http_get(blink, url) 414 | 415 | 416 | async def request_local_storage_clip(blink, network, sync_id, manifest_id, clip_id): 417 | """ 418 | Prepare video clip stored in the sync module to be downloaded. 419 | 420 | :param blink: Blink instance. 421 | :param network: Sync module network id. 422 | :param sync_id: ID of sync module. 423 | :param manifest_id: ID of local storage manifest (returned in manifest response). 424 | :param clip_id: ID of the clip. 425 | """ 426 | url = blink.urls.base_url + string.Template( 427 | local_storage_clip_url_template() 428 | ).substitute( 429 | account_id=blink.account_id, 430 | network_id=network, 431 | sync_id=sync_id, 432 | manifest_id=manifest_id, 433 | clip_id=clip_id, 434 | ) 435 | response = await http_post(blink, url) 436 | await wait_for_command(blink, response) 437 | return response 438 | 439 | 440 | async def request_get_config(blink, network, camera_id, product_type="owl"): 441 | """ 442 | Get camera configuration. 443 | 444 | :param blink: Blink instance. 445 | :param network: Sync module network id. 446 | :param camera_id: ID of camera 447 | :param product_type: Camera product type "owl" or "catalina" 448 | """ 449 | if product_type == "owl": 450 | url = ( 451 | f"{blink.urls.base_url}/api/v1/accounts/{blink.account_id}" 452 | f"/networks/{network}/owls/{camera_id}/config" 453 | ) 454 | elif product_type == "catalina": 455 | url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/config" 456 | else: 457 | _LOGGER.info( 458 | "Camera %s with product type %s config get not implemented.", 459 | camera_id, 460 | product_type, 461 | ) 462 | return None 463 | return await http_get(blink, url) 464 | 465 | 466 | async def request_update_config( 467 | blink, network, camera_id, product_type="owl", data=None 468 | ): 469 | """ 470 | Update camera configuration. 471 | 472 | :param blink: Blink instance. 473 | :param network: Sync module network id. 474 | :param camera_id: ID of camera 475 | :param product_type: Camera product type "owl" or "catalina" 476 | :param data: string w/JSON dict of parameters/values to update 477 | """ 478 | if product_type == "owl": 479 | url = ( 480 | f"{blink.urls.base_url}/api/v1/accounts/" 481 | f"{blink.account_id}/networks/{network}/owls/{camera_id}/config" 482 | ) 483 | elif product_type == "catalina": 484 | url = f"{blink.urls.base_url}/network/{network}/camera/{camera_id}/update" 485 | else: 486 | _LOGGER.info( 487 | "Camera %s with product type %s config update not implemented.", 488 | camera_id, 489 | product_type, 490 | ) 491 | return None 492 | return await http_post(blink, url, json=False, data=data) 493 | 494 | 495 | async def http_get( 496 | blink, url, stream=False, json=True, is_retry=False, timeout=TIMEOUT 497 | ): 498 | """ 499 | Perform an http get request. 500 | 501 | :param url: URL to perform get request. 502 | :param stream: Stream response? True/FALSE 503 | :param json: Return json response? TRUE/False 504 | :param is_retry: Is this part of a re-auth attempt? 505 | """ 506 | _LOGGER.debug("Making GET request to %s", url) 507 | return await blink.auth.query( 508 | url=url, 509 | headers=blink.auth.header, 510 | reqtype="get", 511 | stream=stream, 512 | json_resp=json, 513 | is_retry=is_retry, 514 | ) 515 | 516 | 517 | async def http_post(blink, url, is_retry=False, data=None, json=True, timeout=TIMEOUT): 518 | """ 519 | Perform an http post request. 520 | 521 | :param url: URL to perform post request. 522 | :param is_retry: Is this part of a re-auth attempt? 523 | :param data: str body for post request 524 | :param json: Return json response? TRUE/False 525 | """ 526 | _LOGGER.debug("Making POST request to %s", url) 527 | return await blink.auth.query( 528 | url=url, 529 | headers=blink.auth.header, 530 | reqtype="post", 531 | is_retry=is_retry, 532 | json_resp=json, 533 | data=data, 534 | ) 535 | 536 | 537 | async def wait_for_command(blink, json_data: dict) -> bool: 538 | """Wait for command to complete.""" 539 | _LOGGER.debug("Command Wait %s", json_data) 540 | try: 541 | network_id = json_data.get("network_id") 542 | command_id = json_data.get("id") 543 | except AttributeError: 544 | return False 545 | if command_id and network_id: 546 | for _ in range(0, MAX_RETRY): 547 | _LOGGER.debug("Making GET request waiting for command") 548 | status = await request_command_status(blink, network_id, command_id) 549 | _LOGGER.debug("command status %s", status) 550 | if status: 551 | if status.get("status_code", 0) != 908: 552 | return False 553 | if status.get("complete"): 554 | return True 555 | await sleep(COMMAND_POLL_TIME) 556 | -------------------------------------------------------------------------------- /blinkpy/auth.py: -------------------------------------------------------------------------------- 1 | """Login handler for blink.""" 2 | 3 | import logging 4 | from aiohttp import ( 5 | ClientSession, 6 | ClientConnectionError, 7 | ContentTypeError, 8 | ClientResponse, 9 | ) 10 | from blinkpy import api 11 | from blinkpy.helpers import util 12 | from blinkpy.helpers.constants import ( 13 | BLINK_URL, 14 | APP_BUILD, 15 | DEFAULT_USER_AGENT, 16 | LOGIN_ENDPOINT, 17 | TIMEOUT, 18 | ) 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | class Auth: 24 | """Class to handle login communication.""" 25 | 26 | def __init__( 27 | self, 28 | login_data=None, 29 | no_prompt=False, 30 | session=None, 31 | agent=DEFAULT_USER_AGENT, 32 | app_build=APP_BUILD, 33 | ): 34 | """ 35 | Initialize auth handler. 36 | 37 | :param login_data: dictionary for login data 38 | must contain the following: 39 | - username 40 | - password 41 | :param no_prompt: Should any user input prompts 42 | be suppressed? True/FALSE 43 | """ 44 | if login_data is None: 45 | login_data = {} 46 | self.data = login_data 47 | self.token = login_data.get("token", None) 48 | self.host = login_data.get("host", None) 49 | self.region_id = login_data.get("region_id", None) 50 | self.client_id = login_data.get("client_id", None) 51 | self.account_id = login_data.get("account_id", None) 52 | self.user_id = login_data.get("user_id", None) 53 | self.login_response = None 54 | self.is_errored = False 55 | self.no_prompt = no_prompt 56 | self._agent = agent 57 | self._app_build = app_build 58 | self.session = session if session else ClientSession() 59 | 60 | @property 61 | def login_attributes(self): 62 | """Return a dictionary of login attributes.""" 63 | self.data["token"] = self.token 64 | self.data["host"] = self.host 65 | self.data["region_id"] = self.region_id 66 | self.data["client_id"] = self.client_id 67 | self.data["account_id"] = self.account_id 68 | self.data["user_id"] = self.user_id 69 | return self.data 70 | 71 | @property 72 | def header(self): 73 | """Return authorization header.""" 74 | if self.token is None: 75 | return None 76 | return { 77 | "APP-BUILD": self._app_build, 78 | "TOKEN_AUTH": self.token, 79 | "User-Agent": self._agent, 80 | "Content-Type": "application/json", 81 | } 82 | 83 | def validate_login(self): 84 | """Check login information and prompt if not available.""" 85 | self.data["username"] = self.data.get("username", None) 86 | self.data["password"] = self.data.get("password", None) 87 | if not self.no_prompt: 88 | self.data = util.prompt_login_data(self.data) 89 | self.data = util.validate_login_data(self.data) 90 | 91 | async def login(self, login_url=LOGIN_ENDPOINT): 92 | """Attempt login to blink servers.""" 93 | self.validate_login() 94 | _LOGGER.info("Attempting login with %s", login_url) 95 | response = await api.request_login( 96 | self, 97 | login_url, 98 | self.data, 99 | is_retry=False, 100 | ) 101 | try: 102 | if response.status == 200: 103 | return await response.json() 104 | raise LoginError 105 | except AttributeError as error: 106 | raise LoginError from error 107 | 108 | def logout(self, blink): 109 | """Log out.""" 110 | return api.request_logout(blink) 111 | 112 | async def refresh_token(self): 113 | """Refresh auth token.""" 114 | self.is_errored = True 115 | try: 116 | _LOGGER.info("Token expired, attempting automatic refresh.") 117 | self.login_response = await self.login() 118 | self.extract_login_info() 119 | self.is_errored = False 120 | except LoginError as error: 121 | _LOGGER.error("Login endpoint failed. Try again later.") 122 | raise TokenRefreshFailed from error 123 | except (TypeError, KeyError) as error: 124 | _LOGGER.error("Malformed login response: %s", self.login_response) 125 | raise TokenRefreshFailed from error 126 | return True 127 | 128 | def extract_login_info(self): 129 | """Extract login info from login response.""" 130 | self.region_id = self.login_response["account"]["tier"] 131 | self.host = f"{self.region_id}.{BLINK_URL}" 132 | self.token = self.login_response["auth"]["token"] 133 | self.client_id = self.login_response["account"]["client_id"] 134 | self.account_id = self.login_response["account"]["account_id"] 135 | self.user_id = self.login_response["account"].get("user_id", None) 136 | 137 | async def startup(self): 138 | """Initialize tokens for communication.""" 139 | self.validate_login() 140 | if None in self.login_attributes.values(): 141 | await self.refresh_token() 142 | 143 | async def validate_response(self, response: ClientResponse, json_resp): 144 | """Check for valid response.""" 145 | if not json_resp: 146 | self.is_errored = False 147 | return response 148 | self.is_errored = True 149 | try: 150 | if response.status in [101, 401]: 151 | raise UnauthorizedError 152 | if response.status == 404: 153 | raise ClientConnectionError 154 | json_data = await response.json() 155 | except (AttributeError, ValueError) as error: 156 | raise BlinkBadResponse from error 157 | except ContentTypeError as error: 158 | _LOGGER.warning("Got text for JSON response: %s", await response.text()) 159 | raise BlinkBadResponse from error 160 | 161 | self.is_errored = False 162 | return json_data 163 | 164 | async def query( 165 | self, 166 | url=None, 167 | data=None, 168 | headers=None, 169 | reqtype="get", 170 | stream=False, 171 | json_resp=True, 172 | is_retry=False, 173 | timeout=TIMEOUT, 174 | ): 175 | """Perform server requests. 176 | 177 | :param url: URL to perform request 178 | :param data: Data to send 179 | :param headers: Headers to send 180 | :param reqtype: Can be 'get' or 'post' (default: 'get') 181 | :param stream: Stream response? True/FALSE 182 | :param json_resp: Return JSON response? TRUE/False 183 | :param is_retry: Is this part of a re-auth attempt? True/FALSE 184 | """ 185 | try: 186 | if reqtype == "get": 187 | response = await self.session.get( 188 | url=url, data=data, headers=headers, timeout=timeout 189 | ) 190 | else: 191 | response = await self.session.post( 192 | url=url, data=data, headers=headers, timeout=timeout 193 | ) 194 | return await self.validate_response(response, json_resp) 195 | except (ClientConnectionError, TimeoutError) as er: 196 | _LOGGER.error( 197 | "Connection error. Endpoint %s possibly down or throttled. Error: %s", 198 | url, 199 | er, 200 | ) 201 | except BlinkBadResponse: 202 | code = None 203 | reason = None 204 | try: 205 | code = response.status 206 | reason = response.reason 207 | except AttributeError: 208 | pass 209 | _LOGGER.error( 210 | "Expected json response from %s, but received: %s: %s", 211 | url, 212 | code, 213 | reason, 214 | ) 215 | except UnauthorizedError: 216 | try: 217 | if not is_retry: 218 | await self.refresh_token() 219 | return await self.query( 220 | url=url, 221 | data=data, 222 | headers=self.header, 223 | reqtype=reqtype, 224 | stream=stream, 225 | json_resp=json_resp, 226 | is_retry=True, 227 | timeout=timeout, 228 | ) 229 | _LOGGER.error("Unable to access %s after token refresh.", url) 230 | except TokenRefreshFailed: 231 | _LOGGER.error("Unable to refresh token.") 232 | return None 233 | 234 | async def send_auth_key(self, blink, key): 235 | """Send 2FA key to blink servers.""" 236 | if key is not None: 237 | response = await api.request_verify(self, blink, key) 238 | try: 239 | json_resp = await response.json() 240 | blink.available = json_resp["valid"] 241 | if not blink.available: 242 | _LOGGER.error("%s", json_resp["message"]) 243 | return False 244 | except (KeyError, TypeError, ContentTypeError) as er: 245 | _LOGGER.error( 246 | "Did not receive valid response from server. Error: %s", 247 | er, 248 | ) 249 | return False 250 | return True 251 | 252 | def check_key_required(self): 253 | """Check if 2FA key is required.""" 254 | try: 255 | if self.login_response["account"]["client_verification_required"]: 256 | return True 257 | except (KeyError, TypeError): 258 | pass 259 | return False 260 | 261 | 262 | class TokenRefreshFailed(Exception): 263 | """Class to throw failed refresh exception.""" 264 | 265 | 266 | class LoginError(Exception): 267 | """Class to throw failed login exception.""" 268 | 269 | 270 | class BlinkBadResponse(Exception): 271 | """Class to throw bad json response exception.""" 272 | 273 | 274 | class UnauthorizedError(Exception): 275 | """Class to throw an unauthorized access error.""" 276 | -------------------------------------------------------------------------------- /blinkpy/blinkpy.py: -------------------------------------------------------------------------------- 1 | """ 2 | blinkpy is an unofficial api for the Blink security camera system. 3 | 4 | repo url: https://github.com/fronzbot/blinkpy 5 | 6 | Original protocol hacking by MattTW : 7 | https://github.com/MattTW/BlinkMonitorProtocol 8 | 9 | Published under the MIT license - See LICENSE file for more details. 10 | "Blink Wire-Free HS Home Monitoring & Alert Systems" is a trademark 11 | owned by Immedia Inc., see www.blinkforhome.com for more information. 12 | blinkpy is in no way affiliated with Blink, nor Immedia Inc. 13 | """ 14 | 15 | import os.path 16 | import time 17 | import logging 18 | import datetime 19 | import aiofiles 20 | import aiofiles.ospath 21 | from requests.structures import CaseInsensitiveDict 22 | from dateutil.parser import parse 23 | from slugify import slugify 24 | 25 | from blinkpy import api 26 | from blinkpy.sync_module import BlinkSyncModule, BlinkOwl, BlinkLotus 27 | from blinkpy.helpers import util 28 | from blinkpy.helpers.constants import ( 29 | DEFAULT_MOTION_INTERVAL, 30 | DEFAULT_REFRESH, 31 | MIN_THROTTLE_TIME, 32 | TIMEOUT_MEDIA, 33 | ) 34 | from blinkpy.helpers.constants import __version__ 35 | from blinkpy.auth import Auth, TokenRefreshFailed, LoginError 36 | 37 | _LOGGER = logging.getLogger(__name__) 38 | 39 | 40 | class Blink: 41 | """Class to initialize communication.""" 42 | 43 | def __init__( 44 | self, 45 | refresh_rate=DEFAULT_REFRESH, 46 | motion_interval=DEFAULT_MOTION_INTERVAL, 47 | no_owls=False, 48 | session=None, 49 | ): 50 | """ 51 | Initialize Blink system. 52 | 53 | :param refresh_rate: Refresh rate of blink information. 54 | Defaults to 30 (seconds) 55 | :param motion_interval: How far back to register motion in minutes. 56 | Defaults to last refresh time. 57 | Useful for preventing motion_detected property 58 | from de-asserting too quickly. 59 | :param no_owls: Disable searching for owl entries (blink mini cameras \ 60 | only known entity). Prevents an unnecessary API call \ 61 | if you don't have these in your network. 62 | """ 63 | self.auth = Auth(session=session) 64 | self.account_id = None 65 | self.client_id = None 66 | self.network_ids = [] 67 | self.urls = None 68 | self.sync = CaseInsensitiveDict({}) 69 | self.last_refresh = None 70 | self.refresh_rate = refresh_rate 71 | self.networks = [] 72 | self.cameras = CaseInsensitiveDict({}) 73 | self.video_list = CaseInsensitiveDict({}) 74 | self.motion_interval = motion_interval 75 | self.version = __version__ 76 | self.available = False 77 | self.key_required = False 78 | self.homescreen = {} 79 | self.no_owls = no_owls 80 | 81 | @util.Throttle(seconds=MIN_THROTTLE_TIME) 82 | async def refresh(self, force=False, force_cache=False): 83 | """ 84 | Perform a system refresh. 85 | 86 | :param force: Used to override throttle, resets refresh 87 | :param force_cache: Used to force update without overriding throttle 88 | """ 89 | if force or force_cache or self.check_if_ok_to_update(): 90 | if not self.available: 91 | await self.setup_post_verify() 92 | 93 | await self.get_homescreen() 94 | 95 | for sync_name, sync_module in self.sync.items(): 96 | _LOGGER.debug("Attempting refresh of blink.sync['%s']", sync_name) 97 | await sync_module.refresh(force_cache=(force or force_cache)) 98 | 99 | if not force_cache: 100 | # Prevents rapid clearing of motion detect property 101 | self.last_refresh = int(time.time()) 102 | last_refresh = datetime.datetime.fromtimestamp(self.last_refresh) 103 | _LOGGER.debug("last_refresh = %s", last_refresh) 104 | 105 | return True 106 | return False 107 | 108 | async def start(self): 109 | """Perform full system setup.""" 110 | try: 111 | await self.auth.startup() 112 | self.setup_login_ids() 113 | self.setup_urls() 114 | await self.get_homescreen() 115 | except (LoginError, TokenRefreshFailed, BlinkSetupError): 116 | _LOGGER.error("Cannot setup Blink platform.") 117 | self.available = False 118 | return False 119 | 120 | self.key_required = self.auth.check_key_required() 121 | if self.key_required: 122 | if self.auth.no_prompt: 123 | return True 124 | await self.setup_prompt_2fa() 125 | 126 | if not self.last_refresh: 127 | # Initialize last_refresh to be just before the refresh delay period. 128 | self.last_refresh = int(time.time() - self.refresh_rate * 1.05) 129 | _LOGGER.debug( 130 | "Initialized last_refresh to %s == %s", 131 | self.last_refresh, 132 | datetime.datetime.fromtimestamp(self.last_refresh), 133 | ) 134 | 135 | return await self.setup_post_verify() 136 | 137 | async def setup_prompt_2fa(self): 138 | """Prompt for 2FA.""" 139 | email = self.auth.data["username"] 140 | pin = input(f"Enter code sent to {email}: ") 141 | result = await self.auth.send_auth_key(self, pin) 142 | self.key_required = not result 143 | 144 | async def setup_post_verify(self): 145 | """Initialize blink system after verification.""" 146 | try: 147 | if not self.homescreen: 148 | await self.get_homescreen() 149 | await self.setup_networks() 150 | networks = self.setup_network_ids() 151 | cameras = await self.setup_camera_list() 152 | except BlinkSetupError: 153 | self.available = False 154 | return False 155 | 156 | for name, network_id in networks.items(): 157 | sync_cameras = cameras.get(network_id, {}) 158 | await self.setup_sync_module(name, network_id, sync_cameras) 159 | 160 | self.cameras = self.merge_cameras() 161 | 162 | self.available = True 163 | self.key_required = False 164 | return True 165 | 166 | async def setup_sync_module(self, name, network_id, cameras): 167 | """Initialize a sync module.""" 168 | self.sync[name] = BlinkSyncModule(self, name, network_id, cameras) 169 | await self.sync[name].start() 170 | 171 | async def get_homescreen(self): 172 | """Get homescreen information.""" 173 | if self.no_owls: 174 | _LOGGER.debug("Skipping owl extraction.") 175 | self.homescreen = {} 176 | return 177 | self.homescreen = await api.request_homescreen(self) 178 | _LOGGER.debug("homescreen = %s", util.json_dumps(self.homescreen)) 179 | 180 | async def setup_owls(self): 181 | """Check for mini cameras.""" 182 | network_list = [] 183 | camera_list = [] 184 | try: 185 | for owl in self.homescreen["owls"]: 186 | name = owl["name"] 187 | network_id = str(owl["network_id"]) 188 | if network_id in self.network_ids: 189 | camera_list.append( 190 | {network_id: {"name": name, "id": network_id, "type": "mini"}} 191 | ) 192 | continue 193 | if owl["onboarded"]: 194 | network_list.append(str(network_id)) 195 | self.sync[name] = BlinkOwl(self, name, network_id, owl) 196 | await self.sync[name].start() 197 | except (KeyError, TypeError): 198 | # No sync-less devices found 199 | pass 200 | 201 | self.network_ids.extend(network_list) 202 | return camera_list 203 | 204 | async def setup_lotus(self): 205 | """Check for doorbells cameras.""" 206 | network_list = [] 207 | camera_list = [] 208 | try: 209 | for lotus in self.homescreen["doorbells"]: 210 | name = lotus["name"] 211 | network_id = str(lotus["network_id"]) 212 | if network_id in self.network_ids: 213 | camera_list.append( 214 | { 215 | network_id: { 216 | "name": name, 217 | "id": network_id, 218 | "type": "doorbell", 219 | } 220 | } 221 | ) 222 | continue 223 | if lotus["onboarded"]: 224 | network_list.append(str(network_id)) 225 | self.sync[name] = BlinkLotus(self, name, network_id, lotus) 226 | await self.sync[name].start() 227 | except (KeyError, TypeError): 228 | # No sync-less devices found 229 | pass 230 | 231 | self.network_ids.extend(network_list) 232 | return camera_list 233 | 234 | async def setup_camera_list(self): 235 | """Create camera list for onboarded networks.""" 236 | all_cameras = {} 237 | response = await api.request_camera_usage(self) 238 | try: 239 | for network in response["networks"]: 240 | _LOGGER.info("network = %s", util.json_dumps(network)) 241 | camera_network = str(network["network_id"]) 242 | if camera_network not in all_cameras: 243 | all_cameras[camera_network] = [] 244 | for camera in network["cameras"]: 245 | all_cameras[camera_network].append( 246 | {"name": camera["name"], "id": camera["id"], "type": "default"} 247 | ) 248 | mini_cameras = await self.setup_owls() 249 | lotus_cameras = await self.setup_lotus() 250 | for camera in mini_cameras: 251 | for network, camera_info in camera.items(): 252 | all_cameras[network].append(camera_info) 253 | for camera in lotus_cameras: 254 | for network, camera_info in camera.items(): 255 | all_cameras[network].append(camera_info) 256 | return all_cameras 257 | except (KeyError, TypeError) as ex: 258 | _LOGGER.error("Unable to retrieve cameras from response %s", response) 259 | raise BlinkSetupError from ex 260 | 261 | def setup_login_ids(self): 262 | """Retrieve login id numbers from login response.""" 263 | self.client_id = self.auth.client_id 264 | self.account_id = self.auth.account_id 265 | 266 | def setup_urls(self): 267 | """Create urls for api.""" 268 | try: 269 | self.urls = util.BlinkURLHandler(self.auth.region_id) 270 | except TypeError as ex: 271 | _LOGGER.error( 272 | "Unable to extract region is from response %s", self.auth.login_response 273 | ) 274 | raise BlinkSetupError from ex 275 | 276 | async def setup_networks(self): 277 | """Get network information.""" 278 | response = await api.request_networks(self) 279 | try: 280 | self.networks = response["summary"] 281 | except (KeyError, TypeError) as ex: 282 | raise BlinkSetupError from ex 283 | 284 | def setup_network_ids(self): 285 | """Create the network ids for onboarded networks.""" 286 | all_networks = [] 287 | network_dict = {} 288 | try: 289 | for network, status in self.networks.items(): 290 | if status["onboarded"]: 291 | all_networks.append(f"{network}") 292 | network_dict[status["name"]] = network 293 | except AttributeError as ex: 294 | _LOGGER.error( 295 | "Unable to retrieve network information from %s", self.networks 296 | ) 297 | raise BlinkSetupError from ex 298 | 299 | self.network_ids = all_networks 300 | return network_dict 301 | 302 | def check_if_ok_to_update(self): 303 | """Check if it is ok to perform an http request.""" 304 | current_time = int(time.time()) 305 | last_refresh = self.last_refresh 306 | if last_refresh is None: 307 | last_refresh = 0 308 | if current_time >= (last_refresh + self.refresh_rate): 309 | return True 310 | return False 311 | 312 | def merge_cameras(self): 313 | """Merge all sync camera dicts into one.""" 314 | combined = CaseInsensitiveDict({}) 315 | for sync in self.sync: 316 | combined = util.merge_dicts(combined, self.sync[sync].cameras) 317 | return combined 318 | 319 | async def save(self, file_name): 320 | """Save login data to file.""" 321 | await util.json_save(self.auth.login_attributes, file_name) 322 | 323 | async def get_status(self): 324 | """Get the blink system notification status.""" 325 | response = await api.request_notification_flags(self) 326 | return response.get("notifications", response) 327 | 328 | async def set_status(self, data_dict={}): 329 | """ 330 | Set the blink system notification status. 331 | 332 | :param data_dict: Dictionary of notification keys to modify. 333 | Example: {'low_battery': False, 'motion': False} 334 | """ 335 | response = await api.request_set_notification_flag(self, data_dict) 336 | return response 337 | 338 | async def download_videos( 339 | self, path, since=None, camera="all", stop=10, delay=1, debug=False 340 | ): 341 | """ 342 | Download all videos from server since specified time. 343 | 344 | :param path: Path to write files. /path/_.mp4 345 | :param since: Date and time to get videos from. 346 | Ex: "2018/07/28 12:33:00" to retrieve videos since 347 | July 28th 2018 at 12:33:00 348 | :param camera: Camera name to retrieve. Defaults to "all". 349 | Use a list for multiple cameras. 350 | :param stop: Page to stop on (~25 items per page. Default page 10). 351 | :param delay: Number of seconds to wait in between subsequent video downloads. 352 | :param debug: Set to TRUE to prevent downloading of items. 353 | Instead of downloading, entries will be printed to log. 354 | """ 355 | if not isinstance(camera, list): 356 | camera = [camera] 357 | 358 | results = await self.get_videos_metadata(since=since, stop=stop) 359 | await self._parse_downloaded_items(results, camera, path, delay, debug) 360 | 361 | async def get_videos_metadata(self, since=None, camera="all", stop=10): 362 | """ 363 | Fetch and return video metadata. 364 | 365 | :param since: Date and time to get videos from. 366 | Ex: "2018/07/28 12:33:00" to retrieve videos since 367 | July 28th 2018 at 12:33:00 368 | :param stop: Page to stop on (~25 items per page. Default page 10). 369 | """ 370 | videos = [] 371 | if since is None: 372 | since_epochs = self.last_refresh 373 | else: 374 | parsed_datetime = parse(since, fuzzy=True) 375 | since_epochs = parsed_datetime.timestamp() 376 | 377 | formatted_date = util.get_time(time_to_convert=since_epochs) 378 | _LOGGER.info("Retrieving videos since %s", formatted_date) 379 | 380 | for page in range(1, stop): 381 | response = await api.request_videos(self, time=since_epochs, page=page) 382 | _LOGGER.debug("Processing page %s", page) 383 | try: 384 | result = response["media"] 385 | if not result: 386 | raise KeyError 387 | videos.extend(result) 388 | except (KeyError, TypeError): 389 | _LOGGER.info("No videos found on page %s. Exiting.", page) 390 | break 391 | return videos 392 | 393 | async def do_http_get(self, address): 394 | """ 395 | Do an http_get on address. 396 | 397 | :param address: address to be added to base_url. 398 | """ 399 | response = await api.http_get( 400 | self, 401 | url=f"{self.urls.base_url}{address}", 402 | stream=True, 403 | json=False, 404 | timeout=TIMEOUT_MEDIA, 405 | ) 406 | return response 407 | 408 | async def _parse_downloaded_items(self, result, camera, path, delay, debug): 409 | """Parse downloaded videos.""" 410 | for item in result: 411 | try: 412 | created_at = item["created_at"] 413 | camera_name = item["device_name"] 414 | is_deleted = item["deleted"] 415 | address = item["media"] 416 | except KeyError: 417 | _LOGGER.info("Missing clip information, skipping...") 418 | continue 419 | 420 | if camera_name not in camera and "all" not in camera: 421 | _LOGGER.debug("Skipping videos for %s.", camera_name) 422 | continue 423 | 424 | if is_deleted: 425 | _LOGGER.debug("%s: %s is marked as deleted.", camera_name, address) 426 | continue 427 | 428 | filename = f"{camera_name}-{created_at}" 429 | filename = f"{slugify(filename)}.mp4" 430 | filename = os.path.join(path, filename) 431 | 432 | if not debug: 433 | if await aiofiles.ospath.isfile(filename): 434 | _LOGGER.info("%s already exists, skipping...", filename) 435 | continue 436 | 437 | response = await self.do_http_get(address) 438 | async with aiofiles.open(filename, "wb") as vidfile: 439 | await vidfile.write(await response.read()) 440 | 441 | _LOGGER.info("Downloaded video to %s", filename) 442 | else: 443 | print( 444 | f"Camera: {camera_name}, Timestamp: {created_at}, " 445 | f"Address: {address}, Filename: {filename}" 446 | ) 447 | if delay > 0: 448 | time.sleep(delay) 449 | 450 | 451 | class BlinkSetupError(Exception): 452 | """Class to handle setup errors.""" 453 | -------------------------------------------------------------------------------- /blinkpy/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for blinkpy helper functions.""" 2 | -------------------------------------------------------------------------------- /blinkpy/helpers/constants.py: -------------------------------------------------------------------------------- 1 | """Generates constants for use in blinkpy.""" 2 | 3 | import importlib.metadata 4 | 5 | __version__ = importlib.metadata.version("blinkpy") 6 | 7 | """ 8 | URLS 9 | """ 10 | BLINK_URL = "immedia-semi.com" 11 | DEFAULT_URL = f"rest-prod.{BLINK_URL}" 12 | BASE_URL = f"https://{DEFAULT_URL}" 13 | LOGIN_ENDPOINT = f"{BASE_URL}/api/v5/account/login" 14 | 15 | """ 16 | Dictionaries 17 | """ 18 | ONLINE = {"online": True, "offline": False} 19 | 20 | """ 21 | OTHER 22 | """ 23 | APP_BUILD = "ANDROID_28373244" 24 | DEFAULT_USER_AGENT = "27.0ANDROID_28373244" 25 | DEVICE_ID = "Blinkpy" 26 | TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S%z" 27 | DEFAULT_MOTION_INTERVAL = 1 28 | DEFAULT_REFRESH = 30 29 | MIN_THROTTLE_TIME = 2 30 | SIZE_NOTIFICATION_KEY = 152 31 | SIZE_UID = 16 32 | TIMEOUT = 10 33 | TIMEOUT_MEDIA = 90 34 | -------------------------------------------------------------------------------- /blinkpy/helpers/errors.py: -------------------------------------------------------------------------------- 1 | """Module to define error types.""" 2 | 3 | USERNAME = (0, "Username must be a string") 4 | PASSWORD = (1, "Password must be a string") 5 | AUTHENTICATE = ( 6 | 2, 7 | "Cannot authenticate since either password or username has not been set", 8 | ) 9 | AUTH_TOKEN = ( 10 | 3, 11 | "Authentication header incorrect. Are you sure you received your token?", 12 | ) 13 | REQUEST = (4, "Cannot perform request (get/post type incorrect)") 14 | 15 | BLINK_ERRORS = [400, 404] 16 | -------------------------------------------------------------------------------- /blinkpy/helpers/util.py: -------------------------------------------------------------------------------- 1 | """Useful functions for blinkpy.""" 2 | 3 | import json 4 | import random 5 | import logging 6 | import time 7 | import secrets 8 | import re 9 | from asyncio import sleep 10 | from calendar import timegm 11 | from functools import wraps 12 | from getpass import getpass 13 | import aiofiles 14 | import dateutil.parser 15 | from blinkpy.helpers import constants as const 16 | 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | async def json_load(file_name): 22 | """Load json credentials from file.""" 23 | try: 24 | async with aiofiles.open(file_name, "r") as json_file: 25 | test = await json_file.read() 26 | data = json.loads(test) 27 | return data 28 | except FileNotFoundError: 29 | _LOGGER.error("Could not find %s", file_name) 30 | except json.decoder.JSONDecodeError: 31 | _LOGGER.error("File %s has improperly formatted json", file_name) 32 | return None 33 | 34 | 35 | async def json_save(data, file_name): 36 | """Save data to file location.""" 37 | async with aiofiles.open(file_name, "w") as json_file: 38 | await json_file.write(json.dumps(data, indent=4)) 39 | 40 | 41 | def json_dumps(json_in, indent=2): 42 | """Return a well formated json string.""" 43 | return json.dumps(json_in, indent=indent) 44 | 45 | 46 | def gen_uid(size, uid_format=False): 47 | """Create a random string.""" 48 | if uid_format: 49 | token = ( 50 | f"BlinkCamera_{secrets.token_hex(4)}-" 51 | f"{secrets.token_hex(2)}-{secrets.token_hex(2)}-" 52 | f"{secrets.token_hex(2)}-{secrets.token_hex(6)}" 53 | ) 54 | else: 55 | token = secrets.token_hex(size) 56 | return token 57 | 58 | 59 | def time_to_seconds(timestamp): 60 | """Convert TIMESTAMP_FORMAT time to seconds.""" 61 | try: 62 | dtime = dateutil.parser.isoparse(timestamp) 63 | except ValueError: 64 | _LOGGER.error("Incorrect timestamp format for conversion: %s.", timestamp) 65 | return False 66 | return timegm(dtime.timetuple()) 67 | 68 | 69 | def get_time(time_to_convert=None): 70 | """Create blink-compatible timestamp.""" 71 | if time_to_convert is None: 72 | time_to_convert = time.time() 73 | return time.strftime(const.TIMESTAMP_FORMAT, time.gmtime(time_to_convert)) 74 | 75 | 76 | def merge_dicts(dict_a, dict_b): 77 | """Merge two dictionaries into one.""" 78 | duplicates = [val for val in dict_a if val in dict_b] 79 | if duplicates: 80 | _LOGGER.warning( 81 | ("Duplicates found during merge: %s. " "Renaming is recommended."), 82 | duplicates, 83 | ) 84 | return {**dict_a, **dict_b} 85 | 86 | 87 | def prompt_login_data(data): 88 | """Prompt user for username and password.""" 89 | if data["username"] is None: 90 | data["username"] = input("Username:") 91 | if data["password"] is None: 92 | data["password"] = getpass("Password:") 93 | 94 | return data 95 | 96 | 97 | def validate_login_data(data): 98 | """Check for missing keys.""" 99 | data["uid"] = data.get("uid", gen_uid(const.SIZE_UID, uid_format=True)) 100 | data["device_id"] = data.get("device_id", const.DEVICE_ID) 101 | 102 | return data 103 | 104 | 105 | def local_storage_clip_url_template(): 106 | """Return URL template for local storage clip download location.""" 107 | return ( 108 | "/api/v1/accounts/$account_id/networks/$network_id/sync_modules/$sync_id" 109 | "/local_storage/manifest/$manifest_id/clip/request/$clip_id" 110 | ) 111 | 112 | 113 | def backoff_seconds(retry=0, default_time=1): 114 | """Calculate number of seconds to back off for retry.""" 115 | return default_time * 2**retry + random.uniform(0, 1) 116 | 117 | 118 | def to_alphanumeric(name): 119 | """Convert name to one with only alphanumeric characters.""" 120 | return re.sub(r"\W+", "", name) 121 | 122 | 123 | class BlinkException(Exception): 124 | """Class to throw general blink exception.""" 125 | 126 | def __init__(self, errcode): 127 | """Initialize BlinkException.""" 128 | super().__init__() 129 | self.errid = errcode[0] 130 | self.message = errcode[1] 131 | 132 | 133 | class BlinkAuthenticationException(BlinkException): 134 | """Class to throw authentication exception.""" 135 | 136 | 137 | class BlinkURLHandler: 138 | """Class that handles Blink URLS.""" 139 | 140 | def __init__(self, region_id): 141 | """Initialize the urls.""" 142 | if region_id is None: 143 | raise TypeError 144 | self.subdomain = f"rest-{region_id}" 145 | self.base_url = f"https://{self.subdomain}.{const.BLINK_URL}" 146 | self.home_url = f"{self.base_url}/homescreen" 147 | self.event_url = f"{self.base_url}/events/network" 148 | self.network_url = f"{self.base_url}/network" 149 | self.networks_url = f"{self.base_url}/networks" 150 | self.video_url = f"{self.base_url}/api/v2/videos" 151 | _LOGGER.debug("Setting base url to %s.", self.base_url) 152 | 153 | 154 | class Throttle: 155 | """Class for throttling api calls.""" 156 | 157 | def __init__(self, seconds=10): 158 | """Initialize throttle class.""" 159 | self.throttle_time = seconds 160 | self.last_call = 0 161 | 162 | def __call__(self, method): 163 | """Throttle caller method.""" 164 | 165 | @wraps(method) 166 | async def wrapper(*args, **kwargs): 167 | """Wrap that checks for throttling.""" 168 | force = kwargs.get("force", False) 169 | now = int(time.time()) 170 | last_call_delta = now - self.last_call 171 | if force or last_call_delta > self.throttle_time: 172 | self.last_call = now 173 | else: 174 | self.last_call = now + last_call_delta 175 | await sleep(self.throttle_time - last_call_delta) 176 | 177 | return await method(*args, **kwargs) 178 | 179 | return wrapper 180 | -------------------------------------------------------------------------------- /blinksync/blinksync.py: -------------------------------------------------------------------------------- 1 | import json 2 | import asyncio 3 | import wx 4 | import logging 5 | import aiohttp 6 | import sys 7 | from sortedcontainers import SortedSet 8 | from forms import LoginDialog, VideosForm, DELAY, CLOSE, DELETE, DOWNLOAD, REFRESH 9 | from blinkpy.blinkpy import Blink, BlinkSyncModule 10 | from blinkpy.auth import Auth 11 | 12 | 13 | async def main(): 14 | """Main loop for blink test.""" 15 | session = aiohttp.ClientSession() 16 | blink = Blink(session=session) 17 | app = wx.App() 18 | try: 19 | with wx.DirDialog(None) as dlg: 20 | if dlg.ShowModal() == wx.ID_OK: 21 | path = dlg.GetPath() 22 | else: 23 | sys.exit(0) 24 | 25 | with open(f"{path}/blink.json", "rt", encoding="ascii") as j: 26 | blink.auth = Auth(json.loads(j.read()), session=session) 27 | 28 | except (StopIteration, FileNotFoundError): 29 | with LoginDialog() as userdlg: 30 | userdlg.ShowModal() 31 | userpass = userdlg.getUserPassword() 32 | if userpass is not None: 33 | blink.auth = Auth( 34 | userpass, 35 | session=session, 36 | ) 37 | await blink.save(f"{path}/blink.json") 38 | else: 39 | sys.exit(0) 40 | with wx.BusyInfo("Blink is Working....") as working: 41 | cursor = wx.BusyCursor() 42 | if await blink.start(): 43 | await blink.setup_post_verify() 44 | elif blink.auth.check_key_required(): 45 | print("I failed to authenticate") 46 | 47 | print(f"Sync status: {blink.network_ids}") 48 | print(f"Sync :{blink.networks}") 49 | if len(blink.networks) == 0: 50 | exit() 51 | my_sync: BlinkSyncModule = blink.sync[ 52 | blink.networks[list(blink.networks)[0]]["name"] 53 | ] 54 | cursor = None 55 | working = None 56 | 57 | while True: 58 | with wx.BusyInfo("Blink is Working....") as working: 59 | cursor = wx.BusyCursor() 60 | for name, camera in blink.cameras.items(): 61 | print(name) 62 | print(camera.attributes) 63 | 64 | my_sync._local_storage["manifest"] = SortedSet() 65 | await my_sync.refresh() 66 | if my_sync.local_storage and my_sync.local_storage_manifest_ready: 67 | print("Manifest is ready") 68 | print(f"Manifest {my_sync._local_storage['manifest']}") 69 | else: 70 | print("Manifest not ready") 71 | for name, camera in blink.cameras.items(): 72 | print(f"{camera.name} status: {blink.cameras[name].arm}") 73 | new_vid = await my_sync.check_new_videos() 74 | print(f"New videos?: {new_vid}") 75 | 76 | manifest = my_sync._local_storage["manifest"] 77 | cursor = None 78 | working = None 79 | frame = VideosForm(manifest) 80 | button = frame.ShowModal() 81 | with wx.BusyInfo("Blink is Working....") as working: 82 | cursor = wx.BusyCursor() 83 | if button == CLOSE: 84 | break 85 | if button == REFRESH: 86 | continue 87 | # Download and delete all videos from sync module 88 | for item in reversed(manifest): 89 | if item.id in frame.ItemList: 90 | if button == DOWNLOAD: 91 | await item.prepare_download(blink) 92 | await item.download_video( 93 | blink, 94 | f"{path}/{item.name}_{item.created_at.astimezone().isoformat().replace(':','_')}.mp4", 95 | ) 96 | if button == DELETE: 97 | await item.delete_video(blink) 98 | await asyncio.sleep(DELAY) 99 | cursor = None 100 | working = None 101 | frame = None 102 | await session.close() 103 | await blink.save(f"{path}/blink.json") 104 | 105 | 106 | # Run the program 107 | if __name__ == "__main__": 108 | logging.basicConfig(level=logging.DEBUG) 109 | loop = asyncio.get_event_loop() 110 | loop.run_until_complete(main()) 111 | -------------------------------------------------------------------------------- /blinksync/forms.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | DELETE = 1 4 | CLOSE = 2 5 | DOWNLOAD = 3 6 | REFRESH = 4 7 | DELAY = 5 8 | 9 | class VideosForm(wx.Dialog): 10 | """My delete form.""" 11 | def __init__(self,manifest): 12 | wx.Frame.__init__(self, None, wx.ID_ANY, "Select List to Download and Delete",size = (450,550)) 13 | 14 | # Add a panel so it looks the correct on all platforms 15 | panel = wx.Panel(self, wx.ID_ANY) 16 | #self.Bind(wx.EVT,self._when_closed) 17 | self.index = 0 18 | self.ItemList = [] 19 | self.list_ctrl = wx.ListCtrl(panel, size=(-1,400), 20 | style=wx.LC_REPORT 21 | |wx.BORDER_SUNKEN 22 | ) 23 | self.list_ctrl.InsertColumn(0, 'Name') 24 | self.list_ctrl.InsertColumn(1, 'Camera') 25 | self.list_ctrl.InsertColumn(2, 'Date', width=225) 26 | self.list_ctrl.Bind(wx.EVT_LIST_ITEM_RIGHT_CLICK,self.download_line) 27 | 28 | btn = wx.Button(panel, label="Download") 29 | btn.Bind(wx.EVT_BUTTON, self.download_line) 30 | 31 | deletebtn = wx.Button(panel, label="Delete") 32 | deletebtn.Bind(wx.EVT_BUTTON, self.delete_line) 33 | 34 | closeBtn = wx.Button(panel, label="Close") 35 | closeBtn.Bind(wx.EVT_BUTTON, self._when_closed) 36 | 37 | refrestBtn = wx.Button(panel, label="Refresh") 38 | refrestBtn.Bind(wx.EVT_BUTTON, self._refresh) 39 | 40 | sizer = wx.BoxSizer(wx.VERTICAL) 41 | sizer.Add(self.list_ctrl, 0, wx.ALL|wx.EXPAND, 20) 42 | sizer_buttons = wx.BoxSizer(wx.HORIZONTAL) 43 | sizer_buttons.Add(btn, 0, wx.ALL|wx.CENTER, 5) 44 | sizer_buttons.Add(deletebtn,0,wx.ALL|wx.CENTER,5) 45 | sizer_buttons.Add(refrestBtn,0,wx.ALL|wx.CENTER,5) 46 | sizer_buttons.Add(closeBtn,0,wx.ALL|wx.CENTER, 5) 47 | sizer.Add(sizer_buttons,0,wx.ALL|wx.CENTER,5) 48 | panel.SetSizer(sizer) 49 | 50 | for item in reversed(manifest): 51 | self.list_ctrl.InsertItem(self.index, str(item.id)) 52 | self.list_ctrl.SetItem(self.index, 1, item.name) 53 | self.list_ctrl.SetItem(self.index, 2, item.created_at.astimezone().isoformat()) 54 | self.index += 1 55 | #---------------------------------------------------------------------- 56 | def download_line(self, event): 57 | """Add to list and return DOWNLOAD""" 58 | for count in range(self.list_ctrl.ItemCount): 59 | if self.list_ctrl.IsSelected(count): 60 | self.ItemList.append(int(self.list_ctrl.GetItem(count).Text)) 61 | self.EndModal(DOWNLOAD) 62 | 63 | def delete_line(self, event): 64 | """Add to list and return DOWNLOAD""" 65 | for count in range(self.list_ctrl.ItemCount): 66 | if self.list_ctrl.IsSelected(count): 67 | self.ItemList.append(int(self.list_ctrl.GetItem(count).Text)) 68 | self.EndModal(DELETE) 69 | 70 | 71 | def _when_closed(self,event): 72 | self.EndModal(CLOSE) 73 | 74 | def _refresh(self,event): 75 | self.EndModal(REFRESH) 76 | 77 | class LoginDialog(wx.Dialog): 78 | """ 79 | Class to define login dialog 80 | """ 81 | #---------------------------------------------------------------------- 82 | def __init__(self): 83 | """Constructor""" 84 | wx.Dialog.__init__(self, None, title="Login") 85 | 86 | # user info 87 | user_sizer = wx.BoxSizer(wx.HORIZONTAL) 88 | 89 | user_lbl = wx.StaticText(self, label="Username:") 90 | user_sizer.Add(user_lbl, 0, wx.ALL|wx.CENTER, 5) 91 | self.user = wx.TextCtrl(self) 92 | user_sizer.Add(self.user, 0, wx.ALL, 5) 93 | 94 | # pass info 95 | p_sizer = wx.BoxSizer(wx.HORIZONTAL) 96 | 97 | p_lbl = wx.StaticText(self, label="Password:") 98 | p_sizer.Add(p_lbl, 0, wx.ALL|wx.CENTER, 5) 99 | self.password = wx.TextCtrl(self, style=wx.TE_PASSWORD|wx.TE_PROCESS_ENTER) 100 | p_sizer.Add(self.password, 0, wx.ALL, 5) 101 | 102 | main_sizer = wx.BoxSizer(wx.VERTICAL) 103 | main_sizer.Add(user_sizer, 0, wx.ALL, 5) 104 | main_sizer.Add(p_sizer, 0, wx.ALL, 5) 105 | 106 | btn = wx.Button(self, label="Login") 107 | btn.Bind(wx.EVT_BUTTON, self.onLogin) 108 | main_sizer.Add(btn, 0, wx.ALL|wx.CENTER, 5) 109 | 110 | self.SetSizer(main_sizer) 111 | 112 | #---------------------------------------------------------------------- 113 | def onLogin(self, event): 114 | """ 115 | Check credentials and login 116 | """ 117 | self.account = {"username":self.user.Value,"password":self.password.Value} 118 | self.EndModal(wx.ID_OK) 119 | 120 | def getUserPassword(self): 121 | return self.account 122 | 123 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | branch: dev 3 | bot: codecov-io 4 | max_report_age: 24 5 | disable_default_path_fixes: no 6 | require_ci_to_pass: yes 7 | notify: 8 | wait_for_ci: yes 9 | coverage: 10 | precision: 1 11 | round: down 12 | range: 85..100 13 | status: 14 | project: 15 | default: 16 | target: auto 17 | threshold: 5% 18 | 19 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | reports=no 3 | 4 | # Reasons disabled: 5 | # locally-disabled - it spams too much 6 | # duplicate-code - it's annoying 7 | # unused-argument - generic callbacks and setup methods create a lot of warnings 8 | # too-many-* - are not enforced for the sake of readability 9 | # too-few-* - same as too-many-* 10 | # no-else-return - I don't see any reason to enforce this. both forms are readable 11 | # no-self-use - stupid and only annoying 12 | # unexpected-keyword-arg - doesn't allow for use of **kwargs, which is dumb 13 | 14 | disable= 15 | format, 16 | bad-continuation, 17 | locally-disabled, 18 | unused-argument, 19 | duplicate-code, 20 | implicit-str-concat, 21 | too-many-arguments, 22 | too-many-branches, 23 | too-many-instance-attributes, 24 | too-many-locals, 25 | too-many-public-methods, 26 | too-many-return-statements, 27 | too-many-statements, 28 | too-many-lines, 29 | too-few-public-methods, 30 | no-else-return, 31 | no-self-use, 32 | unexpected-keyword-arg, 33 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=68,<81", "wheel~=0.40.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "blinkpy" 7 | version = "0.24.0b0" 8 | license = {text = "MIT"} 9 | description = "A Blink camera Python Library." 10 | readme = "README.rst" 11 | authors = [ 12 | {name = "Kevin Fronczak", email = "kfronczak@gmail.com"}, 13 | ] 14 | classifiers = [ 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: OS Independent", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Topic :: Home Automation", 23 | ] 24 | requires-python = ">=3.9.0" 25 | dynamic = ["dependencies"] 26 | 27 | [tool.setuptools.dynamic] 28 | dependencies = {file = ["requirements.txt"]} 29 | 30 | [project.urls] 31 | "Source Code" = "https://github.com/fronzbot/blinkpy" 32 | "Bug Reports" = "https://github.com/fronzbot/blinkpy/issues" 33 | 34 | [tool.setuptools] 35 | platforms = ["any"] 36 | include-package-data = true 37 | 38 | [tool.setuptools.packages.find] 39 | include = ["blinkpy*"] 40 | 41 | [tool.ruff] 42 | lint.select = [ 43 | "C", # complexity 44 | "D", # docstrings 45 | "E", # pydocstyle 46 | "F", # pyflakes/autoflake 47 | "G", # flake8-logging-format 48 | "N815", # Varible {name} in class scope should not be mixedCase 49 | "PGH004", # Use specific rule codes when using noqa 50 | "PLC", # pylint 51 | "PLE", # pylint 52 | "PLR", # pylint 53 | "PLW", # pylint 54 | "Q000", # Double quotes found but single quotes preferred 55 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 56 | "TRY004", # Prefer TypeError exception for invalid type 57 | "B904", # Use raise from to specify exception cause 58 | "UP", # pyupgrade 59 | "W", # pycodestyle 60 | ] 61 | lint.ignore = [ 62 | "D202", # No blank lines allowed after function docstring 63 | "D203", # 1 blank line required before class docstring 64 | "D212", # Multi-line docstring summary should start at the first line 65 | "D213", # Multi-line docstring summary should start at the second line 66 | "D406", # Section name should end with a newline 67 | "D407", # Section name underlining 68 | "E731", # do not assign a lambda expression, use a def 69 | "G004", # I don't care if logging uses an f string 70 | "PLC1901", # Lots of false positives 71 | # False positives https://github.com/astral-sh/ruff/issues/5386 72 | "PLC0208", # Use a sequence type instead of a `set` when iterating over values 73 | "PLR0911", # Too many return statements ({returns} > {max_returns}) 74 | "PLR0912", # Too many branches ({branches} > {max_branches}) 75 | "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) 76 | "PLR0915", # Too many statements ({statements} > {max_statements}) 77 | "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable 78 | "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target 79 | "UP006", # keep type annotation style as is 80 | "UP007", # keep type annotation style as is 81 | "UP015", # Unnecessary open mode parameters 82 | "UP017", # UTC stuff 83 | # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 84 | "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` 85 | ] 86 | 87 | line-length = 88 88 | 89 | target-version = "py312" 90 | 91 | [tool.ruff.lint.per-file-ignores] 92 | 93 | [tool.ruff.lint.mccabe] 94 | max-complexity = 25 95 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | ignore::DeprecationWarning -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-dateutil>=2.8.1 2 | requests>=2.24.0 3 | python-slugify>=4.0.1 4 | sortedcontainers~=2.4.0 5 | aiohttp>=3.8.4 6 | aiofiles>=23.1.0 -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | ruff==0.11.13 2 | black==24.4.2 3 | build==1.2.1 4 | coverage==7.8.2 5 | pytest==8.4.0 6 | pytest-cov==6.1.1 7 | pytest-sugar==1.0.0 8 | pytest-timeout==2.4.0 9 | restructuredtext-lint==1.4.0 10 | pygments==2.18.0 11 | testtools>=2.4.0 12 | sortedcontainers~=2.4.0 13 | pytest-asyncio>=0.21.0 14 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for tests directory.""" 2 | -------------------------------------------------------------------------------- /tests/mock_responses.py: -------------------------------------------------------------------------------- 1 | """Simple mock responses definitions.""" 2 | 3 | from unittest import mock 4 | 5 | 6 | class MockResponse: 7 | """Class for mock request response.""" 8 | 9 | def __init__( 10 | self, 11 | json_data, 12 | status_code, 13 | headers={}, 14 | raw_data=None, 15 | raise_error=None, 16 | ): 17 | """Initialize mock get response.""" 18 | self.json_data = json_data 19 | self.status = status_code 20 | self.raw_data = raw_data 21 | self.reason = "foobar" 22 | self.headers = headers 23 | self.read = mock.AsyncMock(return_value=self.raw_data) 24 | self.raise_error = raise_error 25 | self.text = mock.AsyncMock(return_vlaue="some text") 26 | 27 | async def json(self): 28 | """Return json data from get_request.""" 29 | if self.raise_error: 30 | raise self.raise_error("I'm broken", "") 31 | return self.json_data 32 | 33 | def get(self, name): 34 | """Return field for json.""" 35 | return self.json_data[name] 36 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | """Test api functions.""" 2 | 3 | from unittest import mock 4 | from unittest import IsolatedAsyncioTestCase 5 | from blinkpy import api 6 | from blinkpy.blinkpy import Blink, util 7 | from blinkpy.auth import Auth 8 | import tests.mock_responses as mresp 9 | 10 | COMMAND_RESPONSE = {"network_id": "12345", "id": "54321"} 11 | COMMAND_COMPLETE = {"complete": True, "status_code": 908} 12 | COMMAND_COMPLETE_BAD = {"complete": True, "status_code": 999} 13 | COMMAND_NOT_COMPLETE = {"complete": False, "status_code": 908} 14 | 15 | 16 | @mock.patch("blinkpy.auth.Auth.query") 17 | class TestAPI(IsolatedAsyncioTestCase): 18 | """Test the API class in blinkpy.""" 19 | 20 | async def asyncSetUp(self): 21 | """Set up Login Handler.""" 22 | self.blink = Blink(session=mock.AsyncMock()) 23 | self.auth = Auth() 24 | self.blink.available = True 25 | self.blink.urls = util.BlinkURLHandler("region_id") 26 | self.blink.account_id = 1234 27 | self.blink.client_id = 5678 28 | 29 | def tearDown(self): 30 | """Clean up after test.""" 31 | self.blink = None 32 | self.auth = None 33 | 34 | async def test_request_verify(self, mock_resp): 35 | """Test api request verify.""" 36 | mock_resp.return_value = mresp.MockResponse({}, 200) 37 | response = await api.request_verify(self.auth, self.blink, "test key") 38 | self.assertEqual(response.status, 200) 39 | 40 | async def test_request_logout(self, mock_resp): 41 | """Test request_logout.""" 42 | mock_resp.return_value = mresp.MockResponse({}, 200) 43 | response = await api.request_logout(self.blink) 44 | self.assertEqual(response.status, 200) 45 | 46 | async def test_request_networks(self, mock_resp): 47 | """Test request networks.""" 48 | mock_resp.return_value = {"networks": "1234"} 49 | self.assertEqual(await api.request_networks(self.blink), {"networks": "1234"}) 50 | 51 | async def test_request_user(self, mock_resp): 52 | """Test request_user.""" 53 | mock_resp.return_value = {"user": "userid"} 54 | self.assertEqual(await api.request_user(self.blink), {"user": "userid"}) 55 | 56 | async def test_request_network_status(self, mock_resp): 57 | """Test request network status.""" 58 | mock_resp.return_value = {"user": "userid"} 59 | self.assertEqual( 60 | await api.request_network_status(self.blink, "network"), {"user": "userid"} 61 | ) 62 | 63 | async def test_request_command_status(self, mock_resp): 64 | """Test command_status.""" 65 | mock_resp.side_effect = ({"command": "done"}, COMMAND_COMPLETE) 66 | self.assertEqual( 67 | await api.request_command_status(self.blink, "network", "command"), 68 | {"command": "done"}, 69 | ) 70 | 71 | async def test_request_new_image(self, mock_resp): 72 | """Test api request new image.""" 73 | mock_resp.side_effect = ( 74 | mresp.MockResponse(COMMAND_RESPONSE, 200), 75 | COMMAND_COMPLETE, 76 | ) 77 | response = await api.request_new_image(self.blink, "network", "camera") 78 | self.assertEqual(response.status, 200) 79 | 80 | async def test_request_new_video(self, mock_resp): 81 | """Test api request new Video.""" 82 | mock_resp.side_effect = ( 83 | mresp.MockResponse(COMMAND_RESPONSE, 200), 84 | COMMAND_COMPLETE, 85 | ) 86 | response = await api.request_new_video(self.blink, "network", "camera") 87 | self.assertEqual(response.status, 200) 88 | 89 | async def test_request_video_count(self, mock_resp): 90 | """Test api request video count.""" 91 | mock_resp.return_value = {"count": "10"} 92 | self.assertEqual(await api.request_video_count(self.blink), {"count": "10"}) 93 | 94 | async def test_request_cameras(self, mock_resp): 95 | """Test api request cameras.""" 96 | mock_resp.return_value = {"cameras": {"camera_id": 1}} 97 | self.assertEqual( 98 | await api.request_cameras(self.blink, "network"), 99 | {"cameras": {"camera_id": 1}}, 100 | ) 101 | 102 | async def test_request_camera_usage(self, mock_resp): 103 | """Test api request cameras.""" 104 | mock_resp.return_value = {"cameras": "1111"} 105 | self.assertEqual( 106 | await api.request_camera_usage(self.blink), {"cameras": "1111"} 107 | ) 108 | 109 | async def test_request_notification_flags(self, mock_resp): 110 | """Test notification flag request.""" 111 | mock_resp.return_value = {"notifications": {"some_key": False}} 112 | self.assertEqual( 113 | await api.request_notification_flags(self.blink), 114 | {"notifications": {"some_key": False}}, 115 | ) 116 | 117 | async def test_request_set_notification_flag(self, mock_resp): 118 | """Test set of notifiaction flags.""" 119 | mock_resp.side_effect = ( 120 | mresp.MockResponse(COMMAND_RESPONSE, 200), 121 | COMMAND_COMPLETE, 122 | ) 123 | response = await api.request_set_notification_flag(self.blink, {}) 124 | self.assertEqual(response.status, 200) 125 | 126 | async def test_request_motion_detection_enable(self, mock_resp): 127 | """Test Motion detect enable.""" 128 | mock_resp.side_effect = ( 129 | mresp.MockResponse(COMMAND_RESPONSE, 200), 130 | COMMAND_COMPLETE, 131 | ) 132 | response = await api.request_motion_detection_enable( 133 | self.blink, "network", "camera" 134 | ) 135 | self.assertEqual(response.status, 200) 136 | 137 | async def test_request_motion_detection_disable(self, mock_resp): 138 | """Test Motion detect enable.""" 139 | mock_resp.side_effect = ( 140 | mresp.MockResponse(COMMAND_RESPONSE, 200), 141 | COMMAND_COMPLETE, 142 | ) 143 | response = await api.request_motion_detection_disable( 144 | self.blink, "network", "camera" 145 | ) 146 | self.assertEqual(response.status, 200) 147 | 148 | async def test_request_local_storage_clip(self, mock_resp): 149 | """Test Motion detect enable.""" 150 | mock_resp.side_effect = ( 151 | mresp.MockResponse(COMMAND_RESPONSE, 200), 152 | COMMAND_COMPLETE, 153 | ) 154 | response = await api.request_local_storage_clip( 155 | self.blink, "network", "sync_id", "manifest_id", "clip_id" 156 | ) 157 | self.assertEqual(response.status, 200) 158 | 159 | async def test_request_get_config(self, mock_resp): 160 | """Test request get config.""" 161 | mock_resp.return_value = {"config": "values"} 162 | self.assertEqual( 163 | await api.request_get_config(self.blink, "network", "camera_id", "owl"), 164 | {"config": "values"}, 165 | ) 166 | self.assertEqual( 167 | await api.request_get_config( 168 | self.blink, "network", "camera_id", "catalina" 169 | ), 170 | {"config": "values"}, 171 | ) 172 | 173 | async def test_request_update_config(self, mock_resp): 174 | """Test Motion detect enable.""" 175 | mock_resp.return_value = mresp.MockResponse(COMMAND_RESPONSE, 200) 176 | response = await api.request_update_config( 177 | self.blink, "network", "camera_id", "owl" 178 | ) 179 | self.assertEqual(response.status, 200) 180 | response = await api.request_update_config( 181 | self.blink, "network", "camera_id", "catalina" 182 | ) 183 | self.assertEqual(response.status, 200) 184 | self.assertIsNone( 185 | await api.request_update_config( 186 | self.blink, "network", "camera_id", "other_camera" 187 | ) 188 | ) 189 | 190 | async def test_wait_for_command(self, mock_resp): 191 | """Test Motion detect enable.""" 192 | mock_resp.side_effect = (COMMAND_NOT_COMPLETE, COMMAND_COMPLETE) 193 | response = await api.wait_for_command(self.blink, COMMAND_RESPONSE) 194 | assert response 195 | 196 | # mock_resp.side_effect = (COMMAND_NOT_COMPLETE, COMMAND_NOT_COMPLETE, None) 197 | # response = await api.wait_for_command(self.blink, COMMAND_RESPONSE) 198 | # self.assertFalse(response) 199 | 200 | mock_resp.side_effect = (COMMAND_COMPLETE_BAD, {}) 201 | response = await api.wait_for_command(self.blink, COMMAND_RESPONSE) 202 | self.assertFalse(response) 203 | 204 | response = await api.wait_for_command(self.blink, None) 205 | self.assertFalse(response) 206 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | """Test login handler.""" 2 | 3 | from unittest import mock 4 | from unittest import IsolatedAsyncioTestCase 5 | from aiohttp import ClientConnectionError, ContentTypeError 6 | from blinkpy.auth import ( 7 | Auth, 8 | TokenRefreshFailed, 9 | BlinkBadResponse, 10 | UnauthorizedError, 11 | ) 12 | import blinkpy.helpers.constants as const 13 | import tests.mock_responses as mresp 14 | 15 | USERNAME = "foobar" 16 | PASSWORD = "deadbeef" 17 | 18 | 19 | class TestAuth(IsolatedAsyncioTestCase): 20 | """Test the Auth class in blinkpy.""" 21 | 22 | async def asyncSetUp(self): 23 | """Set up Login Handler.""" 24 | self.auth = Auth() 25 | 26 | def tearDown(self): 27 | """Clean up after test.""" 28 | self.auth = None 29 | 30 | @mock.patch("blinkpy.helpers.util.gen_uid") 31 | @mock.patch("blinkpy.auth.util.getpass") 32 | async def test_empty_init(self, getpwd, genuid): 33 | """Test initialization with no params.""" 34 | auth = Auth() 35 | self.assertDictEqual(auth.data, {}) 36 | getpwd.return_value = "bar" 37 | genuid.return_value = 1234 38 | with mock.patch("builtins.input", return_value="foo"): 39 | auth.validate_login() 40 | expected_data = { 41 | "username": "foo", 42 | "password": "bar", 43 | "uid": 1234, 44 | "device_id": const.DEVICE_ID, 45 | } 46 | self.assertDictEqual(auth.data, expected_data) 47 | 48 | @mock.patch("blinkpy.helpers.util.gen_uid") 49 | @mock.patch("blinkpy.auth.util.getpass") 50 | async def test_barebones_init(self, getpwd, genuid): 51 | """Test basebones initialization.""" 52 | login_data = {"username": "foo", "password": "bar"} 53 | auth = Auth(login_data) 54 | self.assertDictEqual(auth.data, login_data) 55 | getpwd.return_value = "bar" 56 | genuid.return_value = 1234 57 | with mock.patch("builtins.input", return_value="foo"): 58 | auth.validate_login() 59 | expected_data = { 60 | "username": "foo", 61 | "password": "bar", 62 | "uid": 1234, 63 | "device_id": const.DEVICE_ID, 64 | } 65 | self.assertDictEqual(auth.data, expected_data) 66 | 67 | async def test_full_init(self): 68 | """Test full initialization.""" 69 | login_data = { 70 | "username": "foo", 71 | "password": "bar", 72 | "token": "token", 73 | "host": "host", 74 | "region_id": "region_id", 75 | "client_id": "client_id", 76 | "account_id": "account_id", 77 | "uid": 1234, 78 | "notification_key": 4321, 79 | "device_id": "device_id", 80 | } 81 | auth = Auth(login_data) 82 | self.assertEqual(auth.token, "token") 83 | self.assertEqual(auth.host, "host") 84 | self.assertEqual(auth.region_id, "region_id") 85 | self.assertEqual(auth.client_id, "client_id") 86 | self.assertEqual(auth.account_id, "account_id") 87 | auth.validate_login() 88 | self.assertDictEqual(auth.login_attributes, login_data) 89 | 90 | async def test_bad_response_code(self): 91 | """Check bad response code from server.""" 92 | self.auth.is_errored = False 93 | fake_resp = mresp.MockResponse({"code": 404}, 404) 94 | with self.assertRaises(ClientConnectionError): 95 | await self.auth.validate_response(fake_resp, True) 96 | self.assertTrue(self.auth.is_errored) 97 | 98 | self.auth.is_errored = False 99 | fake_resp = mresp.MockResponse({"code": 101}, 401) 100 | with self.assertRaises(UnauthorizedError): 101 | await self.auth.validate_response(fake_resp, True) 102 | self.assertTrue(self.auth.is_errored) 103 | fake_resp = mresp.MockResponse({"code": 101}, 406, raise_error=ContentTypeError) 104 | with self.assertRaises(BlinkBadResponse): 105 | await self.auth.validate_response(fake_resp, True) 106 | self.assertTrue(self.auth.is_errored) 107 | 108 | async def test_good_response_code(self): 109 | """Check good response code from server.""" 110 | fake_resp = mresp.MockResponse({"foo": "bar"}, 200) 111 | self.auth.is_errored = True 112 | self.assertEqual( 113 | await self.auth.validate_response(fake_resp, True), {"foo": "bar"} 114 | ) 115 | self.assertFalse(self.auth.is_errored) 116 | 117 | async def test_response_not_json(self): 118 | """Check response when not json.""" 119 | fake_resp = "foobar" 120 | self.auth.is_errored = True 121 | self.assertEqual(await self.auth.validate_response(fake_resp, False), "foobar") 122 | self.assertFalse(self.auth.is_errored) 123 | 124 | async def test_response_bad_json(self): 125 | """Check response when not json but expecting json.""" 126 | self.auth.is_errored = False 127 | with self.assertRaises(BlinkBadResponse): 128 | await self.auth.validate_response(None, True) 129 | self.assertTrue(self.auth.is_errored) 130 | 131 | def test_header(self): 132 | """Test header data.""" 133 | self.auth.token = "bar" 134 | expected_header = { 135 | "APP-BUILD": const.APP_BUILD, 136 | "TOKEN_AUTH": "bar", 137 | "User-Agent": const.DEFAULT_USER_AGENT, 138 | "Content-Type": "application/json", 139 | } 140 | self.assertDictEqual(self.auth.header, expected_header) 141 | 142 | def test_header_no_token(self): 143 | """Test header without token.""" 144 | self.auth.token = None 145 | self.assertEqual(self.auth.header, None) 146 | 147 | @mock.patch("blinkpy.auth.Auth.validate_login") 148 | @mock.patch("blinkpy.auth.Auth.refresh_token") 149 | async def test_auth_startup(self, mock_validate, mock_refresh): 150 | """Test auth startup.""" 151 | await self.auth.startup() 152 | 153 | @mock.patch("blinkpy.auth.Auth.query") 154 | async def test_refresh_token(self, mock_resp): 155 | """Test refresh token method.""" 156 | mock_resp.return_value.json = mock.AsyncMock( 157 | return_value={ 158 | "account": {"account_id": 5678, "client_id": 1234, "tier": "test"}, 159 | "auth": {"token": "foobar"}, 160 | } 161 | ) 162 | mock_resp.return_value.status = 200 163 | 164 | self.auth.no_prompt = True 165 | self.assertTrue(await self.auth.refresh_token()) 166 | self.assertEqual(self.auth.region_id, "test") 167 | self.assertEqual(self.auth.token, "foobar") 168 | self.assertEqual(self.auth.client_id, 1234) 169 | self.assertEqual(self.auth.account_id, 5678) 170 | self.assertEqual(self.auth.user_id, None) 171 | 172 | mock_resp.return_value.status = 400 173 | with self.assertRaises(TokenRefreshFailed): 174 | await self.auth.refresh_token() 175 | 176 | mock_resp.return_value.status = 200 177 | mock_resp.return_value.json = mock.AsyncMock(side_effect=AttributeError) 178 | with self.assertRaises(TokenRefreshFailed): 179 | await self.auth.refresh_token() 180 | 181 | @mock.patch("blinkpy.auth.Auth.login") 182 | async def test_refresh_token_failed(self, mock_login): 183 | """Test refresh token failed.""" 184 | mock_login.return_value = {} 185 | self.auth.is_errored = False 186 | with self.assertRaises(TokenRefreshFailed): 187 | await self.auth.refresh_token() 188 | self.assertTrue(self.auth.is_errored) 189 | 190 | def test_check_key_required(self): 191 | """Check key required method.""" 192 | self.auth.login_response = {} 193 | self.assertFalse(self.auth.check_key_required()) 194 | 195 | self.auth.login_response = {"account": {"client_verification_required": False}} 196 | self.assertFalse(self.auth.check_key_required()) 197 | 198 | self.auth.login_response = {"account": {"client_verification_required": True}} 199 | self.assertTrue(self.auth.check_key_required()) 200 | 201 | @mock.patch("blinkpy.auth.api.request_logout") 202 | async def test_logout(self, mock_req): 203 | """Test logout method.""" 204 | mock_blink = MockBlink(None) 205 | mock_req.return_value = True 206 | self.assertTrue(await self.auth.logout(mock_blink)) 207 | 208 | @mock.patch("blinkpy.auth.api.request_verify") 209 | async def test_send_auth_key(self, mock_req): 210 | """Check sending of auth key.""" 211 | mock_blink = MockBlink(None) 212 | mock_req.return_value = mresp.MockResponse({"valid": True}, 200) 213 | self.assertTrue(await self.auth.send_auth_key(mock_blink, 1234)) 214 | self.assertTrue(mock_blink.available) 215 | 216 | mock_req.return_value = mresp.MockResponse(None, 200) 217 | self.assertFalse(await self.auth.send_auth_key(mock_blink, 1234)) 218 | 219 | mock_req.return_value = mresp.MockResponse({}, 200) 220 | self.assertFalse(await self.auth.send_auth_key(mock_blink, 1234)) 221 | 222 | self.assertTrue(await self.auth.send_auth_key(mock_blink, None)) 223 | 224 | @mock.patch("blinkpy.auth.api.request_verify") 225 | async def test_send_auth_key_fail(self, mock_req): 226 | """Check handling of auth key failure.""" 227 | mock_blink = MockBlink(None) 228 | mock_req.return_value = mresp.MockResponse(None, 200) 229 | self.assertFalse(await self.auth.send_auth_key(mock_blink, 1234)) 230 | mock_req.return_value = mresp.MockResponse({}, 200) 231 | self.assertFalse(await self.auth.send_auth_key(mock_blink, 1234)) 232 | mock_req.return_value = mresp.MockResponse( 233 | {"valid": False, "message": "Not good"}, 200 234 | ) 235 | self.assertFalse(await self.auth.send_auth_key(mock_blink, 1234)) 236 | 237 | @mock.patch( 238 | "blinkpy.auth.Auth.validate_response", 239 | mock.AsyncMock(side_effect=[UnauthorizedError, "foobar"]), 240 | ) 241 | @mock.patch("blinkpy.auth.Auth.refresh_token", mock.AsyncMock(return_value=True)) 242 | @mock.patch("blinkpy.auth.Auth.query", mock.AsyncMock(return_value="foobar")) 243 | async def test_query_retry(self): # , mock_refresh, mock_validate): 244 | """Check handling of request retry.""" 245 | self.auth.session = MockSession() 246 | self.assertEqual(await self.auth.query(url="http://example.com"), "foobar") 247 | 248 | @mock.patch("blinkpy.auth.Auth.validate_response") 249 | @mock.patch("blinkpy.auth.Auth.refresh_token") 250 | async def test_query_retry_failed(self, mock_refresh, mock_validate): 251 | """Check handling of failed retry request.""" 252 | self.auth.session = MockSession() 253 | mock_validate.side_effect = [ 254 | BlinkBadResponse, 255 | UnauthorizedError, 256 | TokenRefreshFailed, 257 | ] 258 | mock_refresh.return_value = True 259 | self.assertEqual(await self.auth.query(url="http://example.com"), None) 260 | self.assertEqual(await self.auth.query(url="http://example.com"), None) 261 | 262 | @mock.patch("blinkpy.auth.Auth.validate_response") 263 | async def test_query(self, mock_validate): 264 | """Test query functions.""" 265 | self.auth.session = MockSession_with_data() 266 | await self.auth.query("URL", "data", "headers", "get") 267 | await self.auth.query("URL", "data", "headers", "post") 268 | 269 | mock_validate.side_effect = ClientConnectionError 270 | self.assertIsNone(await self.auth.query("URL", "data", "headers", "get")) 271 | 272 | mock_validate.side_effect = BlinkBadResponse 273 | self.assertIsNone(await self.auth.query("URL", "data", "headers", "post")) 274 | 275 | mock_validate.side_effect = UnauthorizedError 276 | self.auth.refresh_token = mock.AsyncMock() 277 | self.assertIsNone(await self.auth.query("URL", "data", "headers", "post")) 278 | 279 | 280 | class MockSession: 281 | """Object to mock a session.""" 282 | 283 | async def get(self, *args, **kwargs): 284 | """Mock send function.""" 285 | return None 286 | 287 | async def post(self, *args, **kwargs): 288 | """Mock send function.""" 289 | return None 290 | 291 | 292 | class MockSession_with_data: 293 | """Object to mock a session.""" 294 | 295 | async def get(self, *args, **kwargs): 296 | """Mock send function.""" 297 | response = mock.AsyncMock 298 | response.status = 400 299 | response.reason = "Some Reason" 300 | return response 301 | 302 | async def post(self, *args, **kwargs): 303 | """Mock send function.""" 304 | response = mock.AsyncMock 305 | response.status = 400 306 | response.reason = "Some Reason" 307 | return response 308 | 309 | 310 | class MockBlink: 311 | """Object to mock basic blink class.""" 312 | 313 | def __init__(self, login_response): 314 | """Initialize mock blink class.""" 315 | self.available = False 316 | self.login_response = login_response 317 | -------------------------------------------------------------------------------- /tests/test_blink_functions.py: -------------------------------------------------------------------------------- 1 | """Tests camera and system functions.""" 2 | 3 | from unittest import mock, IsolatedAsyncioTestCase 4 | import time 5 | import random 6 | from io import BufferedIOBase 7 | import aiofiles 8 | from blinkpy import blinkpy 9 | from blinkpy.sync_module import BlinkSyncModule 10 | from blinkpy.camera import BlinkCamera 11 | from blinkpy.helpers.util import get_time, BlinkURLHandler 12 | 13 | 14 | class MockSyncModule(BlinkSyncModule): 15 | """Mock blink sync module object.""" 16 | 17 | async def get_network_info(self): 18 | """Mock network info method.""" 19 | return True 20 | 21 | 22 | class MockCamera(BlinkCamera): 23 | """Mock blink camera object.""" 24 | 25 | def __init__(self, sync): 26 | """Initialize mock camera.""" 27 | super().__init__(sync) 28 | self.camera_id = random.randint(1, 100000) 29 | 30 | async def update(self, config, force_cache=False, **kwargs): 31 | """Mock camera update method.""" 32 | 33 | 34 | class TestBlinkFunctions(IsolatedAsyncioTestCase): 35 | """Test Blink and BlinkCamera functions in blinkpy.""" 36 | 37 | def setUp(self): 38 | """Set up Blink module.""" 39 | self.blink = blinkpy.Blink(session=mock.AsyncMock()) 40 | self.blink.urls = BlinkURLHandler("test") 41 | 42 | def tearDown(self): 43 | """Clean up after test.""" 44 | self.blink = None 45 | 46 | def test_merge_cameras(self): 47 | """Test merge camera functionality.""" 48 | first_dict = {"foo": "bar", "test": 123} 49 | next_dict = {"foobar": 456, "bar": "foo"} 50 | self.blink.sync["foo"] = BlinkSyncModule(self.blink, "foo", 1, []) 51 | self.blink.sync["bar"] = BlinkSyncModule(self.blink, "bar", 2, []) 52 | self.blink.sync["foo"].cameras = first_dict 53 | self.blink.sync["bar"].cameras = next_dict 54 | result = self.blink.merge_cameras() 55 | expected = {"foo": "bar", "test": 123, "foobar": 456, "bar": "foo"} 56 | self.assertEqual(expected, result) 57 | 58 | @mock.patch("blinkpy.blinkpy.api.request_videos") 59 | async def test_download_video_exit(self, mock_req): 60 | """Test we exit method when provided bad response.""" 61 | blink = blinkpy.Blink(session=mock.AsyncMock()) 62 | blink.last_refresh = 0 63 | mock_req.return_value = {} 64 | formatted_date = get_time(blink.last_refresh) 65 | expected_log = [ 66 | f"INFO:blinkpy.blinkpy:Retrieving videos since {formatted_date}", 67 | "DEBUG:blinkpy.blinkpy:Processing page 1", 68 | "INFO:blinkpy.blinkpy:No videos found on page 1. Exiting.", 69 | ] 70 | with self.assertLogs(level="DEBUG") as dl_log: 71 | await blink.download_videos("/tmp") 72 | self.assertListEqual(dl_log.output, expected_log) 73 | 74 | @mock.patch("blinkpy.blinkpy.api.request_videos") 75 | async def test_parse_downloaded_items(self, mock_req): 76 | """Test ability to parse downloaded items list.""" 77 | blink = blinkpy.Blink(session=mock.AsyncMock()) 78 | generic_entry = { 79 | "created_at": "1970", 80 | "device_name": "foo", 81 | "deleted": True, 82 | "media": "/bar.mp4", 83 | } 84 | result = [generic_entry] 85 | mock_req.return_value = {"media": result} 86 | blink.last_refresh = 0 87 | formatted_date = get_time(blink.last_refresh) 88 | expected_log = [ 89 | f"INFO:blinkpy.blinkpy:Retrieving videos since {formatted_date}", 90 | "DEBUG:blinkpy.blinkpy:Processing page 1", 91 | "DEBUG:blinkpy.blinkpy:foo: /bar.mp4 is marked as deleted.", 92 | ] 93 | with self.assertLogs(level="DEBUG") as dl_log: 94 | await blink.download_videos("/tmp", stop=2, delay=0) 95 | self.assertListEqual(dl_log.output, expected_log) 96 | 97 | @mock.patch("blinkpy.blinkpy.api.request_videos") 98 | async def test_parse_downloaded_throttle(self, mock_req): 99 | """Test ability to parse downloaded items list.""" 100 | generic_entry = { 101 | "created_at": "1970", 102 | "device_name": "foo", 103 | "deleted": False, 104 | "media": "/bar.mp4", 105 | } 106 | result = [generic_entry] 107 | mock_req.return_value = {"media": result} 108 | self.blink.last_refresh = 0 109 | start = time.time() 110 | await self.blink.download_videos("/tmp", stop=2, delay=0, debug=True) 111 | now = time.time() 112 | delta = now - start 113 | self.assertTrue(delta < 0.1) 114 | 115 | start = time.time() 116 | await self.blink.download_videos("/tmp", stop=2, delay=0.1, debug=True) 117 | now = time.time() 118 | delta = now - start 119 | self.assertTrue(delta >= 0.1) 120 | 121 | @mock.patch("blinkpy.blinkpy.api.request_videos") 122 | async def test_get_videos_metadata(self, mock_req): 123 | """Test ability to fetch videos metadata.""" 124 | generic_entry = { 125 | "created_at": "1970", 126 | "device_name": "foo", 127 | "deleted": True, 128 | "media": "/bar.mp4", 129 | } 130 | result = [generic_entry] 131 | mock_req.return_value = {"media": result} 132 | self.blink.last_refresh = 0 133 | 134 | results = await self.blink.get_videos_metadata(stop=2) 135 | self.assertListEqual(results, result) 136 | 137 | results = await self.blink.get_videos_metadata( 138 | since="2018/07/28 12:33:00", stop=2 139 | ) 140 | self.assertListEqual(results, result) 141 | 142 | mock_req.return_value = {"media": None} 143 | results = await self.blink.get_videos_metadata(stop=2) 144 | self.assertListEqual(results, []) 145 | 146 | @mock.patch("blinkpy.blinkpy.api.http_get") 147 | async def test_do_http_get(self, mock_req): 148 | """Test ability to do_http_get.""" 149 | blink = blinkpy.Blink(session=mock.AsyncMock()) 150 | blink.urls = BlinkURLHandler("test") 151 | response = await blink.do_http_get("/path/to/request") 152 | self.assertTrue(response is not None) 153 | 154 | @mock.patch("blinkpy.blinkpy.api.request_videos") 155 | async def test_download_videos_deleted(self, mock_req): 156 | """Test ability to download videos.""" 157 | generic_entry = { 158 | "created_at": "1970", 159 | "device_name": "foo", 160 | "deleted": True, 161 | "media": "/bar.mp4", 162 | } 163 | result = [generic_entry] 164 | mock_req.return_value = {"media": result} 165 | self.blink.last_refresh = 0 166 | formatted_date = get_time(self.blink.last_refresh) 167 | expected_log = [ 168 | f"INFO:blinkpy.blinkpy:Retrieving videos since {formatted_date}", 169 | "DEBUG:blinkpy.blinkpy:Processing page 1", 170 | "DEBUG:blinkpy.blinkpy:foo: /bar.mp4 is marked as deleted.", 171 | ] 172 | with self.assertLogs(level="DEBUG") as dl_log: 173 | await self.blink.download_videos("/tmp", camera="foo", stop=2, delay=0) 174 | self.assertListEqual(dl_log.output, expected_log) 175 | 176 | @mock.patch("blinkpy.blinkpy.api.request_videos") 177 | @mock.patch("aiofiles.ospath.isfile") 178 | async def test_download_videos_file(self, mock_isfile, mock_req): 179 | """Test ability to download videos to a file.""" 180 | generic_entry = { 181 | "created_at": "1970", 182 | "device_name": "foo", 183 | "deleted": False, 184 | "media": "/bar.mp4", 185 | } 186 | result = [generic_entry] 187 | mock_req.return_value = {"media": result} 188 | mock_isfile.return_value = False 189 | self.blink.last_refresh = 0 190 | 191 | aiofiles.threadpool.wrap.register(mock.MagicMock)( 192 | lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase( 193 | *args, **kwargs 194 | ) 195 | ) 196 | mock_file = mock.MagicMock(spec=BufferedIOBase) 197 | with mock.patch("aiofiles.threadpool.sync_open", return_value=mock_file): 198 | await self.blink.download_videos("/tmp", camera="foo", stop=2, delay=0) 199 | assert mock_file.write.call_count == 1 200 | 201 | @mock.patch("blinkpy.blinkpy.api.request_videos") 202 | @mock.patch("aiofiles.ospath.isfile") 203 | async def test_download_videos_file_exists(self, mock_isfile, mock_req): 204 | """Test ability to download videos with file exists.""" 205 | generic_entry = { 206 | "created_at": "1970", 207 | "device_name": "foo", 208 | "deleted": False, 209 | "media": "/bar.mp4", 210 | } 211 | result = [generic_entry] 212 | mock_req.return_value = {"media": result} 213 | mock_isfile.return_value = True 214 | 215 | self.blink.last_refresh = 0 216 | formatted_date = get_time(self.blink.last_refresh) 217 | expected_log = [ 218 | f"INFO:blinkpy.blinkpy:Retrieving videos since {formatted_date}", 219 | "DEBUG:blinkpy.blinkpy:Processing page 1", 220 | "INFO:blinkpy.blinkpy:/tmp/foo-1970.mp4 already exists, skipping...", 221 | ] 222 | with self.assertLogs(level="DEBUG") as dl_log: 223 | await self.blink.download_videos("/tmp", camera="foo", stop=2, delay=0) 224 | assert expected_log[0] in dl_log.output 225 | assert expected_log[1] in dl_log.output 226 | assert expected_log[2] in dl_log.output 227 | 228 | @mock.patch("blinkpy.blinkpy.api.request_videos") 229 | async def test_parse_camera_not_in_list(self, mock_req): 230 | """Test ability to parse downloaded items list.""" 231 | generic_entry = { 232 | "created_at": "1970", 233 | "device_name": "foo", 234 | "deleted": True, 235 | "media": "/bar.mp4", 236 | } 237 | result = [generic_entry] 238 | mock_req.return_value = {"media": result} 239 | self.blink.last_refresh = 0 240 | formatted_date = get_time(self.blink.last_refresh) 241 | expected_log = [ 242 | f"INFO:blinkpy.blinkpy:Retrieving videos since {formatted_date}", 243 | "DEBUG:blinkpy.blinkpy:Processing page 1", 244 | "DEBUG:blinkpy.blinkpy:Skipping videos for foo.", 245 | ] 246 | with self.assertLogs(level="DEBUG") as dl_log: 247 | await self.blink.download_videos("/tmp", camera="bar", stop=2, delay=0) 248 | self.assertListEqual(dl_log.output, expected_log) 249 | 250 | @mock.patch("blinkpy.blinkpy.api.request_videos") 251 | async def test_parse_malformed_entry(self, mock_req): 252 | """Test ability to parse downloaded items in malformed list.""" 253 | self.blink.last_refresh = 0 254 | formatted_date = get_time(self.blink.last_refresh) 255 | generic_entry = { 256 | "created_at": "1970", 257 | } 258 | result = [generic_entry] 259 | mock_req.return_value = {"media": result} 260 | expected_log = [ 261 | f"INFO:blinkpy.blinkpy:Retrieving videos since {formatted_date}", 262 | "DEBUG:blinkpy.blinkpy:Processing page 1", 263 | "INFO:blinkpy.blinkpy:Missing clip information, skipping...", 264 | ] 265 | with self.assertLogs(level="DEBUG") as dl_log: 266 | await self.blink.download_videos("/tmp", camera="bar", stop=2, delay=0) 267 | self.assertListEqual(dl_log.output, expected_log) 268 | 269 | @mock.patch("blinkpy.blinkpy.api.request_network_update") 270 | @mock.patch("blinkpy.auth.Auth.query") 271 | async def test_refresh(self, mock_req, mock_update): 272 | """Test ability to refresh system.""" 273 | mock_update.return_value = {"network": {"sync_module_error": False}} 274 | mock_req.return_value = None 275 | self.blink.last_refresh = 0 276 | self.blink.available = True 277 | self.blink.sync["foo"] = MockSyncModule(self.blink, "foo", 1, []) 278 | self.blink.cameras = {"bar": MockCamera(self.blink.sync)} 279 | self.blink.sync["foo"].cameras = self.blink.cameras 280 | self.assertTrue(await self.blink.refresh()) 281 | 282 | @mock.patch("blinkpy.blinkpy.api.request_notification_flags") 283 | async def test_get_status(self, mock_req): 284 | """Test get of notification flags.""" 285 | mock_req.return_value = {"notifications": {"foo": True}} 286 | self.assertDictEqual(await self.blink.get_status(), {"foo": True}) 287 | 288 | @mock.patch("blinkpy.blinkpy.api.request_notification_flags") 289 | async def test_get_status_malformed(self, mock_req): 290 | """Test get of notification flags with malformed response.""" 291 | mock_req.return_value = {"nobueno": {"foo": False}} 292 | self.assertDictEqual(await self.blink.get_status(), {"nobueno": {"foo": False}}) 293 | 294 | @mock.patch("blinkpy.blinkpy.api.request_set_notification_flag") 295 | async def test_set_status(self, mock_req): 296 | """Test set of notification flags.""" 297 | mock_req.return_value = True 298 | self.assertTrue(await self.blink.set_status()) 299 | -------------------------------------------------------------------------------- /tests/test_blinkpy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test full system. 3 | 4 | Tests the system initialization and attributes of 5 | the main Blink system. Tests if we properly catch 6 | any communication related errors at startup. 7 | """ 8 | 9 | from unittest import mock 10 | from unittest import IsolatedAsyncioTestCase 11 | import time 12 | from blinkpy.blinkpy import Blink, BlinkSetupError, LoginError, TokenRefreshFailed 13 | from blinkpy.sync_module import BlinkOwl, BlinkLotus 14 | from blinkpy.helpers.constants import __version__ 15 | 16 | SPECIAL = "!@#$%^&*()_+-=[]{}|/<>?,.'" 17 | 18 | 19 | class TestBlinkSetup(IsolatedAsyncioTestCase): 20 | """Test the Blink class in blinkpy.""" 21 | 22 | def setUp(self): 23 | """Initialize blink test object.""" 24 | self.blink = Blink(session=mock.AsyncMock()) 25 | self.blink.available = True 26 | 27 | def tearDown(self): 28 | """Cleanup blink test object.""" 29 | self.blink = None 30 | 31 | async def test_initialization(self): 32 | """Verify we can initialize blink.""" 33 | blink = Blink() 34 | self.assertEqual(blink.version, __version__) 35 | 36 | def test_network_id_failure(self): 37 | """Check that with bad network data a setup error is raised.""" 38 | self.blink.networks = None 39 | with self.assertRaises(BlinkSetupError): 40 | self.blink.setup_network_ids() 41 | 42 | def test_multiple_networks(self): 43 | """Check that we handle multiple networks appropriately.""" 44 | self.blink.networks = { 45 | "0000": {"onboarded": False, "name": "foo"}, 46 | "5678": {"onboarded": True, "name": "bar"}, 47 | "1234": {"onboarded": False, "name": "test"}, 48 | } 49 | self.blink.setup_network_ids() 50 | self.assertTrue("5678" in self.blink.network_ids) 51 | 52 | def test_multiple_onboarded_networks(self): 53 | """Check that we handle multiple networks appropriately.""" 54 | self.blink.networks = { 55 | "0000": {"onboarded": False, "name": "foo"}, 56 | "5678": {"onboarded": True, "name": "bar"}, 57 | "1234": {"onboarded": True, "name": "test"}, 58 | } 59 | self.blink.setup_network_ids() 60 | self.assertTrue("0000" not in self.blink.network_ids) 61 | self.assertTrue("5678" in self.blink.network_ids) 62 | self.assertTrue("1234" in self.blink.network_ids) 63 | 64 | @mock.patch("blinkpy.blinkpy.time.time") 65 | async def test_throttle(self, mock_time): 66 | """Check throttling functionality.""" 67 | now = self.blink.refresh_rate + 1 68 | mock_time.return_value = now 69 | self.assertEqual(self.blink.last_refresh, None) 70 | self.assertEqual(self.blink.check_if_ok_to_update(), True) 71 | self.assertEqual(self.blink.last_refresh, None) 72 | with ( 73 | mock.patch( 74 | "blinkpy.sync_module.BlinkSyncModule.refresh", return_value=True 75 | ), 76 | mock.patch("blinkpy.blinkpy.Blink.get_homescreen", return_value=True), 77 | ): 78 | await self.blink.refresh(force=True) 79 | 80 | self.assertEqual(self.blink.last_refresh, now) 81 | self.assertEqual(self.blink.check_if_ok_to_update(), False) 82 | self.assertEqual(self.blink.last_refresh, now) 83 | 84 | async def test_not_available_refresh(self): 85 | """Check that setup_post_verify executes on refresh when not avialable.""" 86 | self.blink.available = False 87 | with ( 88 | mock.patch( 89 | "blinkpy.sync_module.BlinkSyncModule.refresh", return_value=True 90 | ), 91 | mock.patch("blinkpy.blinkpy.Blink.get_homescreen", return_value=True), 92 | mock.patch("blinkpy.blinkpy.Blink.setup_post_verify", return_value=True), 93 | ): 94 | self.assertTrue(await self.blink.refresh(force=True)) 95 | with mock.patch("time.time", return_value=time.time() + 4): 96 | self.assertFalse(await self.blink.refresh()) 97 | 98 | def test_sync_case_insensitive_dict(self): 99 | """Check that we can access sync modules ignoring case.""" 100 | self.blink.sync["test"] = 1234 101 | self.assertEqual(self.blink.sync["test"], 1234) 102 | self.assertEqual(self.blink.sync["TEST"], 1234) 103 | self.assertEqual(self.blink.sync["tEsT"], 1234) 104 | 105 | def test_sync_special_chars(self): 106 | """Check that special chars can be used as sync name.""" 107 | self.blink.sync[SPECIAL] = 1234 108 | self.assertEqual(self.blink.sync[SPECIAL], 1234) 109 | 110 | @mock.patch("blinkpy.api.request_camera_usage") 111 | @mock.patch("blinkpy.api.request_homescreen") 112 | async def test_setup_cameras(self, mock_home, mock_req): 113 | """Check retrieval of camera information.""" 114 | mock_home.return_value = {} 115 | mock_req.return_value = { 116 | "networks": [ 117 | { 118 | "network_id": 1234, 119 | "cameras": [ 120 | {"id": 5678, "name": "foo"}, 121 | {"id": 5679, "name": "bar"}, 122 | {"id": 5779, "name": SPECIAL}, 123 | ], 124 | }, 125 | {"network_id": 4321, "cameras": [{"id": 0000, "name": "test"}]}, 126 | ] 127 | } 128 | result = await self.blink.setup_camera_list() 129 | self.assertEqual( 130 | result, 131 | { 132 | "1234": [ 133 | {"name": "foo", "id": 5678, "type": "default"}, 134 | {"name": "bar", "id": 5679, "type": "default"}, 135 | {"name": SPECIAL, "id": 5779, "type": "default"}, 136 | ], 137 | "4321": [{"name": "test", "id": 0000, "type": "default"}], 138 | }, 139 | ) 140 | 141 | @mock.patch("blinkpy.api.request_camera_usage") 142 | async def test_setup_cameras_failure(self, mock_home): 143 | """Check that on failure we raise a setup error.""" 144 | mock_home.return_value = {} 145 | with self.assertRaises(BlinkSetupError): 146 | await self.blink.setup_camera_list() 147 | mock_home.return_value = None 148 | with self.assertRaises(BlinkSetupError): 149 | await self.blink.setup_camera_list() 150 | 151 | def test_setup_urls(self): 152 | """Check setup of URLS.""" 153 | self.blink.auth.region_id = "test" 154 | self.blink.setup_urls() 155 | self.assertEqual(self.blink.urls.subdomain, "rest-test") 156 | 157 | def test_setup_urls_failure(self): 158 | """Check that on failure we raise a setup error.""" 159 | self.blink.auth.region_id = None 160 | with self.assertRaises(BlinkSetupError): 161 | self.blink.setup_urls() 162 | 163 | @mock.patch("blinkpy.api.request_networks") 164 | async def test_setup_networks(self, mock_networks): 165 | """Check setup of networks.""" 166 | mock_networks.return_value = {"summary": "foobar"} 167 | await self.blink.setup_networks() 168 | self.assertEqual(self.blink.networks, "foobar") 169 | 170 | @mock.patch("blinkpy.api.request_networks") 171 | async def test_setup_networks_failure(self, mock_networks): 172 | """Check that on failure we raise a setup error.""" 173 | mock_networks.return_value = {} 174 | with self.assertRaises(BlinkSetupError): 175 | await self.blink.setup_networks() 176 | mock_networks.return_value = None 177 | with self.assertRaises(BlinkSetupError): 178 | await self.blink.setup_networks() 179 | 180 | @mock.patch("blinkpy.blinkpy.Auth.send_auth_key") 181 | async def test_setup_prompt_2fa(self, mock_key): 182 | """Test setup with 2fa prompt.""" 183 | self.blink.auth.data["username"] = "foobar" 184 | self.blink.key_required = True 185 | mock_key.return_value = True 186 | with mock.patch("builtins.input", return_value="foo"): 187 | await self.blink.setup_prompt_2fa() 188 | self.assertFalse(self.blink.key_required) 189 | mock_key.return_value = False 190 | with mock.patch("builtins.input", return_value="foo"): 191 | await self.blink.setup_prompt_2fa() 192 | self.assertTrue(self.blink.key_required) 193 | 194 | @mock.patch("blinkpy.blinkpy.Blink.setup_camera_list") 195 | @mock.patch("blinkpy.api.request_homescreen") 196 | @mock.patch("blinkpy.api.request_networks") 197 | @mock.patch("blinkpy.blinkpy.Blink.setup_owls") 198 | @mock.patch("blinkpy.blinkpy.Blink.setup_lotus") 199 | @mock.patch("blinkpy.blinkpy.BlinkSyncModule.start") 200 | async def test_setup_post_verify( 201 | self, mock_sync, mock_lotus, mock_owl, mock_networks, mock_home, mock_camera 202 | ): 203 | """Test setup after verification.""" 204 | self.blink.available = False 205 | self.blink.key_required = True 206 | mock_lotus.return_value = True 207 | mock_owl.return_value = True 208 | mock_camera.side_effect = [ 209 | { 210 | "name": "bar", 211 | "id": "1323", 212 | "type": "default", 213 | } 214 | ] 215 | mock_networks.return_value = { 216 | "summary": {"foo": {"onboarded": True, "name": "bar"}} 217 | } 218 | mock_home.return_value = {} 219 | mock_camera.return_value = [] 220 | self.assertTrue(await self.blink.setup_post_verify()) 221 | self.assertTrue(self.blink.available) 222 | self.assertFalse(self.blink.key_required) 223 | 224 | @mock.patch("blinkpy.api.request_homescreen") 225 | @mock.patch("blinkpy.api.request_networks") 226 | async def test_setup_post_verify_failure(self, mock_networks, mock_home): 227 | """Test failed setup after verification.""" 228 | self.blink.available = False 229 | mock_networks.return_value = {} 230 | mock_home.return_value = {} 231 | self.assertFalse(await self.blink.setup_post_verify()) 232 | self.assertFalse(self.blink.available) 233 | 234 | def test_merge_cameras(self): 235 | """Test merging of cameras.""" 236 | self.blink.sync = { 237 | "foo": MockSync({"test": 123, "foo": "bar"}), 238 | "bar": MockSync({"fizz": "buzz", "bar": "foo"}), 239 | } 240 | combined = self.blink.merge_cameras() 241 | self.assertEqual(combined["test"], 123) 242 | self.assertEqual(combined["foo"], "bar") 243 | self.assertEqual(combined["fizz"], "buzz") 244 | self.assertEqual(combined["bar"], "foo") 245 | 246 | @mock.patch("blinkpy.blinkpy.BlinkOwl.start") 247 | async def test_initialize_blink_minis(self, mock_start): 248 | """Test blink mini initialization.""" 249 | mock_start.return_value = True 250 | self.blink.homescreen = { 251 | "owls": [ 252 | { 253 | "enabled": False, 254 | "id": 1, 255 | "name": "foo", 256 | "network_id": 2, 257 | "onboarded": True, 258 | "status": "online", 259 | "thumbnail": "/foo/bar", 260 | "serial": "1234", 261 | }, 262 | { 263 | "enabled": True, 264 | "id": 3, 265 | "name": "bar", 266 | "network_id": 4, 267 | "onboarded": True, 268 | "status": "online", 269 | "thumbnail": "/foo/bar", 270 | "serial": "abcd", 271 | }, 272 | ] 273 | } 274 | self.blink.sync = {} 275 | await self.blink.setup_owls() 276 | self.assertEqual(self.blink.sync["foo"].__class__, BlinkOwl) 277 | self.assertEqual(self.blink.sync["bar"].__class__, BlinkOwl) 278 | self.assertEqual(self.blink.sync["foo"].arm, False) 279 | self.assertEqual(self.blink.sync["bar"].arm, True) 280 | self.assertEqual(self.blink.sync["foo"].name, "foo") 281 | self.assertEqual(self.blink.sync["bar"].name, "bar") 282 | 283 | async def test_blink_mini_cameras_returned(self): 284 | """Test that blink mini cameras are found if attached to sync module.""" 285 | self.blink.network_ids = ["1234"] 286 | self.blink.homescreen = { 287 | "owls": [ 288 | { 289 | "id": 1, 290 | "name": "foo", 291 | "network_id": 1234, 292 | "onboarded": True, 293 | "enabled": True, 294 | "status": "online", 295 | "thumbnail": "/foo/bar", 296 | "serial": "abc123", 297 | } 298 | ] 299 | } 300 | result = await self.blink.setup_owls() 301 | self.assertEqual(self.blink.network_ids, ["1234"]) 302 | self.assertEqual( 303 | result, [{"1234": {"name": "foo", "id": "1234", "type": "mini"}}] 304 | ) 305 | 306 | self.blink.no_owls = True 307 | self.blink.network_ids = [] 308 | await self.blink.get_homescreen() 309 | result = await self.blink.setup_owls() 310 | self.assertEqual(self.blink.network_ids, []) 311 | self.assertEqual(result, []) 312 | 313 | @mock.patch("blinkpy.api.request_camera_usage") 314 | async def test_blink_mini_attached_to_sync(self, mock_usage): 315 | """Test that blink mini cameras are properly attached to sync module.""" 316 | self.blink.network_ids = ["1234"] 317 | self.blink.homescreen = { 318 | "owls": [ 319 | { 320 | "id": 1, 321 | "name": "foo", 322 | "network_id": 1234, 323 | "onboarded": True, 324 | "enabled": True, 325 | "status": "online", 326 | "thumbnail": "/foo/bar", 327 | "serial": "abc123", 328 | } 329 | ] 330 | } 331 | mock_usage.return_value = {"networks": [{"cameras": [], "network_id": 1234}]} 332 | result = await self.blink.setup_camera_list() 333 | self.assertEqual( 334 | result, {"1234": [{"name": "foo", "id": "1234", "type": "mini"}]} 335 | ) 336 | 337 | @mock.patch("blinkpy.blinkpy.BlinkLotus.start") 338 | async def test_initialize_blink_doorbells(self, mock_start): 339 | """Test blink doorbell initialization.""" 340 | mock_start.return_value = True 341 | self.blink.homescreen = { 342 | "doorbells": [ 343 | { 344 | "enabled": False, 345 | "id": 1, 346 | "name": "foo", 347 | "network_id": 2, 348 | "onboarded": True, 349 | "status": "online", 350 | "thumbnail": "/foo/bar", 351 | "serial": "1234", 352 | }, 353 | { 354 | "enabled": True, 355 | "id": 3, 356 | "name": "bar", 357 | "network_id": 4, 358 | "onboarded": True, 359 | "status": "online", 360 | "thumbnail": "/foo/bar", 361 | "serial": "abcd", 362 | }, 363 | ] 364 | } 365 | self.blink.sync = {} 366 | await self.blink.setup_lotus() 367 | self.assertEqual(self.blink.sync["foo"].__class__, BlinkLotus) 368 | self.assertEqual(self.blink.sync["bar"].__class__, BlinkLotus) 369 | self.assertEqual(self.blink.sync["foo"].arm, False) 370 | self.assertEqual(self.blink.sync["bar"].arm, True) 371 | self.assertEqual(self.blink.sync["foo"].name, "foo") 372 | self.assertEqual(self.blink.sync["bar"].name, "bar") 373 | 374 | @mock.patch("blinkpy.api.request_camera_usage") 375 | async def test_blink_doorbell_attached_to_sync(self, mock_usage): 376 | """Test that blink doorbell cameras are properly attached to sync module.""" 377 | self.blink.network_ids = ["1234"] 378 | self.blink.homescreen = { 379 | "doorbells": [ 380 | { 381 | "id": 1, 382 | "name": "foo", 383 | "network_id": 1234, 384 | "onboarded": True, 385 | "enabled": True, 386 | "status": "online", 387 | "thumbnail": "/foo/bar", 388 | "serial": "abc123", 389 | } 390 | ] 391 | } 392 | mock_usage.return_value = {"networks": [{"cameras": [], "network_id": 1234}]} 393 | result = await self.blink.setup_camera_list() 394 | self.assertEqual( 395 | result, {"1234": [{"name": "foo", "id": "1234", "type": "doorbell"}]} 396 | ) 397 | 398 | @mock.patch("blinkpy.api.request_camera_usage") 399 | async def test_blink_multi_doorbell(self, mock_usage): 400 | """Test that multiple doorbells are properly attached to sync module.""" 401 | self.blink.network_ids = ["1234"] 402 | self.blink.homescreen = { 403 | "doorbells": [ 404 | { 405 | "id": 1, 406 | "name": "foo", 407 | "network_id": 1234, 408 | "onboarded": True, 409 | "enabled": True, 410 | "status": "online", 411 | "thumbnail": "/foo/bar", 412 | "serial": "abc123", 413 | }, 414 | { 415 | "id": 2, 416 | "name": "bar", 417 | "network_id": 1234, 418 | "onboarded": True, 419 | "enabled": True, 420 | "status": "online", 421 | "thumbnail": "/bar/foo", 422 | "serial": "zxc456", 423 | }, 424 | ] 425 | } 426 | expected = { 427 | "1234": [ 428 | {"name": "foo", "id": "1234", "type": "doorbell"}, 429 | {"name": "bar", "id": "1234", "type": "doorbell"}, 430 | ] 431 | } 432 | mock_usage.return_value = {"networks": [{"cameras": [], "network_id": 1234}]} 433 | result = await self.blink.setup_camera_list() 434 | self.assertEqual(result, expected) 435 | 436 | @mock.patch("blinkpy.api.request_camera_usage") 437 | async def test_blink_multi_mini(self, mock_usage): 438 | """Test that multiple minis are properly attached to sync module.""" 439 | self.blink.network_ids = ["1234"] 440 | self.blink.homescreen = { 441 | "owls": [ 442 | { 443 | "id": 1, 444 | "name": "foo", 445 | "network_id": 1234, 446 | "onboarded": True, 447 | "enabled": True, 448 | "status": "online", 449 | "thumbnail": "/foo/bar", 450 | "serial": "abc123", 451 | }, 452 | { 453 | "id": 2, 454 | "name": "bar", 455 | "network_id": 1234, 456 | "onboarded": True, 457 | "enabled": True, 458 | "status": "online", 459 | "thumbnail": "/bar/foo", 460 | "serial": "zxc456", 461 | }, 462 | ] 463 | } 464 | expected = { 465 | "1234": [ 466 | {"name": "foo", "id": "1234", "type": "mini"}, 467 | {"name": "bar", "id": "1234", "type": "mini"}, 468 | ] 469 | } 470 | mock_usage.return_value = {"networks": [{"cameras": [], "network_id": 1234}]} 471 | result = await self.blink.setup_camera_list() 472 | self.assertEqual(result, expected) 473 | 474 | @mock.patch("blinkpy.api.request_camera_usage") 475 | async def test_blink_camera_mix(self, mock_usage): 476 | """Test that a mix of cameras are properly attached to sync module.""" 477 | self.blink.network_ids = ["1234"] 478 | self.blink.homescreen = { 479 | "doorbells": [ 480 | { 481 | "id": 1, 482 | "name": "foo", 483 | "network_id": 1234, 484 | "onboarded": True, 485 | "enabled": True, 486 | "status": "online", 487 | "thumbnail": "/foo/bar", 488 | "serial": "abc123", 489 | }, 490 | { 491 | "id": 2, 492 | "name": "bar", 493 | "network_id": 1234, 494 | "onboarded": True, 495 | "enabled": True, 496 | "status": "online", 497 | "thumbnail": "/bar/foo", 498 | "serial": "zxc456", 499 | }, 500 | ], 501 | "owls": [ 502 | { 503 | "id": 3, 504 | "name": "dead", 505 | "network_id": 1234, 506 | "onboarded": True, 507 | "enabled": True, 508 | "status": "online", 509 | "thumbnail": "/dead/beef", 510 | "serial": "qwerty", 511 | }, 512 | { 513 | "id": 4, 514 | "name": "beef", 515 | "network_id": 1234, 516 | "onboarded": True, 517 | "enabled": True, 518 | "status": "online", 519 | "thumbnail": "/beef/dead", 520 | "serial": "dvorak", 521 | }, 522 | ], 523 | } 524 | expected = { 525 | "1234": [ 526 | {"name": "foo", "id": "1234", "type": "doorbell"}, 527 | {"name": "bar", "id": "1234", "type": "doorbell"}, 528 | {"name": "dead", "id": "1234", "type": "mini"}, 529 | {"name": "beef", "id": "1234", "type": "mini"}, 530 | {"name": "normal", "id": "1234", "type": "default"}, 531 | ] 532 | } 533 | mock_usage.return_value = { 534 | "networks": [ 535 | {"cameras": [{"name": "normal", "id": "1234"}], "network_id": 1234} 536 | ] 537 | } 538 | result = await self.blink.setup_camera_list() 539 | self.assertTrue("1234" in result) 540 | for element in result["1234"]: 541 | self.assertTrue(element in expected["1234"]) 542 | 543 | @mock.patch("blinkpy.blinkpy.Blink.get_homescreen") 544 | @mock.patch("blinkpy.blinkpy.Blink.setup_prompt_2fa") 545 | @mock.patch("blinkpy.auth.Auth.startup") 546 | @mock.patch("blinkpy.blinkpy.Blink.setup_login_ids") 547 | @mock.patch("blinkpy.blinkpy.Blink.setup_urls") 548 | @mock.patch("blinkpy.auth.Auth.check_key_required") 549 | @mock.patch("blinkpy.blinkpy.Blink.setup_post_verify") 550 | async def test_blink_start( 551 | self, 552 | mock_verify, 553 | mock_check_key, 554 | mock_urls, 555 | mock_ids, 556 | mock_auth_startup, 557 | mock_2fa, 558 | mock_homescreen, 559 | ): 560 | """Test blink_start funcion.""" 561 | 562 | self.assertTrue(await self.blink.start()) 563 | 564 | self.blink.auth.no_prompt = True 565 | self.assertTrue(await self.blink.start()) 566 | 567 | mock_homescreen.side_effect = [LoginError, TokenRefreshFailed] 568 | self.assertFalse(await self.blink.start()) 569 | self.assertFalse(await self.blink.start()) 570 | 571 | def test_setup_login_ids(self): 572 | """Test setup_login_ids function.""" 573 | 574 | self.blink.auth.client_id = 1 575 | self.blink.auth.account_id = 2 576 | self.blink.setup_login_ids() 577 | self.assertEqual(self.blink.client_id, 1) 578 | self.assertEqual(self.blink.account_id, 2) 579 | 580 | @mock.patch("blinkpy.blinkpy.util.json_save") 581 | async def test_save(self, mock_util): 582 | """Test save function.""" 583 | await self.blink.save("blah") 584 | self.assertEqual(mock_util.call_count, 1) 585 | 586 | 587 | class MockSync: 588 | """Mock sync module class.""" 589 | 590 | def __init__(self, cameras): 591 | """Initialize fake class.""" 592 | 593 | self.cameras = cameras 594 | -------------------------------------------------------------------------------- /tests/test_camera_functions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test all camera attributes. 3 | 4 | Tests the camera initialization and attributes of 5 | individual BlinkCamera instantiations once the 6 | Blink system is set up. 7 | """ 8 | 9 | import datetime 10 | from unittest import mock 11 | from unittest import IsolatedAsyncioTestCase 12 | from blinkpy.blinkpy import Blink 13 | from blinkpy.helpers.util import BlinkURLHandler 14 | from blinkpy.sync_module import BlinkSyncModule 15 | from blinkpy.camera import BlinkCamera, BlinkCameraMini, BlinkDoorbell 16 | import tests.mock_responses as mresp 17 | 18 | CONFIG = { 19 | "name": "new", 20 | "id": 1234, 21 | "network_id": 5678, 22 | "serial": "12345678", 23 | "enabled": False, 24 | "battery_state": "ok", 25 | "battery_voltage": 163, 26 | "wifi_strength": -38, 27 | "signals": {"lfr": 5, "wifi": 4, "battery": 3, "temp": 68}, 28 | "thumbnail": "/thumb", 29 | } 30 | 31 | 32 | @mock.patch("blinkpy.auth.Auth.query", return_value={}) 33 | class TestBlinkCameraSetup(IsolatedAsyncioTestCase): 34 | """Test the Blink class in blinkpy.""" 35 | 36 | def setUp(self): 37 | """Set up Blink module.""" 38 | self.blink = Blink(session=mock.AsyncMock()) 39 | self.blink.urls = BlinkURLHandler("test") 40 | self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", 1234, []) 41 | self.camera = BlinkCamera(self.blink.sync["test"]) 42 | self.camera.name = "foobar" 43 | self.blink.sync["test"].cameras["foobar"] = self.camera 44 | 45 | def tearDown(self): 46 | """Clean up after test.""" 47 | self.blink = None 48 | self.camera = None 49 | 50 | async def test_camera_update(self, mock_resp): 51 | """Test that we can properly update camera properties.""" 52 | self.camera.last_record = ["1"] 53 | self.camera.sync.last_records = { 54 | "new": [{"clip": "/test.mp4", "time": "1970-01-01T00:00:00"}] 55 | } 56 | mock_resp.side_effect = [ 57 | {"temp": 71}, 58 | mresp.MockResponse({"test": 200}, 200, raw_data="test"), 59 | mresp.MockResponse({"foobar": 200}, 200, raw_data="foobar"), 60 | ] 61 | self.assertIsNone(self.camera.image_from_cache) 62 | 63 | await self.camera.update(CONFIG, expire_clips=False) 64 | self.assertEqual(self.camera.name, "new") 65 | self.assertEqual(self.camera.camera_id, "1234") 66 | self.assertEqual(self.camera.network_id, "5678") 67 | self.assertEqual(self.camera.serial, "12345678") 68 | self.assertEqual(self.camera.motion_enabled, False) 69 | self.assertEqual(self.camera.battery, "ok") 70 | self.assertEqual(self.camera.temperature, 68) 71 | self.assertEqual(self.camera.temperature_c, 20) 72 | self.assertEqual(self.camera.temperature_calibrated, 71) 73 | self.assertEqual(self.camera.battery_voltage, 163) 74 | self.assertEqual(self.camera.wifi_strength, -38) 75 | self.assertEqual( 76 | self.camera.thumbnail, "https://rest-test.immedia-semi.com/thumb.jpg" 77 | ) 78 | self.assertEqual( 79 | self.camera.clip, "https://rest-test.immedia-semi.com/test.mp4" 80 | ) 81 | self.assertEqual(self.camera.image_from_cache, "test") 82 | self.assertEqual(self.camera.video_from_cache, "foobar") 83 | 84 | # Check that thumbnail without slash processed properly 85 | mock_resp.side_effect = [ 86 | mresp.MockResponse({"test": 200}, 200, raw_data="thumb_no_slash") 87 | ] 88 | await self.camera.update_images( 89 | {"thumbnail": "thumb_no_slash"}, expire_clips=False 90 | ) 91 | self.assertEqual( 92 | self.camera.thumbnail, 93 | "https://rest-test.immedia-semi.com/thumb_no_slash.jpg", 94 | ) 95 | 96 | async def test_no_thumbnails(self, mock_resp): 97 | """Tests that thumbnail is 'None' if none found.""" 98 | mock_resp.return_value = "foobar" 99 | self.camera.last_record = ["1"] 100 | config = { 101 | **CONFIG, 102 | **{ 103 | "thumbnail": "", 104 | }, 105 | } 106 | 107 | self.camera.sync.homescreen = {"devices": []} 108 | self.assertEqual(self.camera.temperature_calibrated, None) 109 | with self.assertLogs() as logrecord: 110 | await self.camera.update(config, force=True, expire_clips=False) 111 | self.assertEqual(self.camera.thumbnail, None) 112 | self.assertEqual(self.camera.last_record, ["1"]) 113 | self.assertEqual(self.camera.temperature_calibrated, 68) 114 | self.assertEqual( 115 | logrecord.output, 116 | [ 117 | ( 118 | "WARNING:blinkpy.camera:Could not retrieve calibrated " 119 | f"temperature response {mock_resp.return_value}." 120 | ), 121 | ( 122 | f"WARNING:blinkpy.camera:for network_id ({config['network_id']}) " 123 | f"and camera_id ({self.camera.camera_id})" 124 | ), 125 | ("WARNING:blinkpy.camera:Could not find thumbnail for camera new."), 126 | ], 127 | ) 128 | 129 | async def test_no_video_clips(self, mock_resp): 130 | """Tests that we still proceed with camera setup with no videos.""" 131 | mock_resp.return_value = "foobar" 132 | config = { 133 | **CONFIG, 134 | **{ 135 | "thumbnail": "/foobar", 136 | }, 137 | } 138 | mock_resp.return_value = mresp.MockResponse({"test": 200}, 200, raw_data="") 139 | self.camera.sync.homescreen = {"devices": []} 140 | await self.camera.update(config, force_cache=True, expire_clips=False) 141 | self.assertEqual(self.camera.clip, None) 142 | self.assertEqual(self.camera.video_from_cache, None) 143 | 144 | async def test_recent_video_clips(self, mock_resp): 145 | """Test recent video clips. 146 | 147 | Tests that the last records in the sync module are added 148 | to the camera recent clips list. 149 | """ 150 | self.camera.sync.last_records["foobar"] = [] 151 | record2 = {"clip": "/clip2", "time": "2022-12-01 00:00:10+00:00"} 152 | self.camera.sync.last_records["foobar"].append(record2) 153 | record1 = {"clip": "/clip1", "time": "2022-12-01 00:00:00+00:00"} 154 | self.camera.sync.last_records["foobar"].append(record1) 155 | self.camera.sync.motion["foobar"] = True 156 | await self.camera.update_images(CONFIG, expire_clips=False) 157 | record1["clip"] = self.blink.urls.base_url + "/clip1" 158 | record2["clip"] = self.blink.urls.base_url + "/clip2" 159 | self.assertEqual(self.camera.recent_clips[0], record1) 160 | self.assertEqual(self.camera.recent_clips[1], record2) 161 | 162 | async def test_recent_video_clips_missing_key(self, mock_resp): 163 | """Tests that the missing key failst.""" 164 | self.camera.sync.last_records["foobar"] = [] 165 | record2 = {"clip": "/clip2"} 166 | self.camera.sync.last_records["foobar"].append(record2) 167 | self.camera.sync.motion["foobar"] = True 168 | 169 | with self.assertLogs(level="ERROR") as dl_log: 170 | await self.camera.update_images(CONFIG, expire_clips=False) 171 | 172 | self.assertIsNotNone(dl_log.output) 173 | 174 | async def test_expire_recent_clips(self, mock_resp): 175 | """Test expiration of recent clips.""" 176 | self.camera.recent_clips = [] 177 | now = datetime.datetime.now() 178 | self.camera.recent_clips.append( 179 | { 180 | "time": (now - datetime.timedelta(minutes=20)).isoformat(), 181 | "clip": "/clip1", 182 | }, 183 | ) 184 | self.camera.recent_clips.append( 185 | { 186 | "time": (now - datetime.timedelta(minutes=1)).isoformat(), 187 | "clip": "local_storage/clip2", 188 | }, 189 | ) 190 | await self.camera.expire_recent_clips(delta=datetime.timedelta(minutes=5)) 191 | self.assertEqual(len(self.camera.recent_clips), 1) 192 | 193 | @mock.patch( 194 | "blinkpy.api.request_motion_detection_enable", 195 | mock.AsyncMock(return_value="enable"), 196 | ) 197 | @mock.patch( 198 | "blinkpy.api.request_motion_detection_disable", 199 | mock.AsyncMock(return_value="disable"), 200 | ) 201 | async def test_motion_detection_enable_disable(self, mock_rep): 202 | """Test setting motion detection enable properly.""" 203 | self.assertEqual(await self.camera.set_motion_detect(True), "enable") 204 | self.assertEqual(await self.camera.set_motion_detect(False), "disable") 205 | 206 | async def test_night_vision(self, mock_resp): 207 | """Test Night Vision Camera functions.""" 208 | # MJK - I don't know what the "real" response is supposed to look like 209 | # Need to confirm and adjust this test to match reality? 210 | mock_resp.return_value = "blah" 211 | self.assertIsNone(await self.camera.night_vision) 212 | 213 | self.camera.product_type = "catalina" 214 | mock_resp.return_value = {"camera": [{"name": "123", "illuminator_enable": 1}]} 215 | self.assertIsNotNone(await self.camera.night_vision) 216 | 217 | self.assertIsNone(await self.camera.async_set_night_vision("0")) 218 | 219 | mock_resp.return_value = mresp.MockResponse({"code": 200}, 200) 220 | self.assertIsNotNone(await self.camera.async_set_night_vision("on")) 221 | 222 | mock_resp.return_value = mresp.MockResponse({"code": 400}, 400) 223 | self.assertIsNone(await self.camera.async_set_night_vision("on")) 224 | 225 | async def test_record(self, mock_resp): 226 | """Test camera record function.""" 227 | with mock.patch( 228 | "blinkpy.api.request_new_video", mock.AsyncMock(return_value=True) 229 | ): 230 | self.assertTrue(await self.camera.record()) 231 | 232 | with mock.patch( 233 | "blinkpy.api.request_new_video", mock.AsyncMock(return_value=False) 234 | ): 235 | self.assertFalse(await self.camera.record()) 236 | 237 | async def test_get_thumbnail(self, mock_resp): 238 | """Test get thumbnail without URL.""" 239 | self.assertIsNone(await self.camera.get_thumbnail()) 240 | 241 | async def test_get_video(self, mock_resp): 242 | """Test get video clip without URL.""" 243 | self.assertIsNone(await self.camera.get_video_clip()) 244 | 245 | @mock.patch( 246 | "blinkpy.api.request_new_image", mock.AsyncMock(return_value={"json": "Data"}) 247 | ) 248 | async def test_snap_picture(self, mock_resp): 249 | """Test camera snap picture function.""" 250 | self.assertIsNotNone(await self.camera.snap_picture()) 251 | 252 | @mock.patch("blinkpy.api.http_post", mock.AsyncMock(return_value={"json": "Data"})) 253 | async def test_snap_picture_blinkmini(self, mock_resp): 254 | """Test camera snap picture function.""" 255 | self.camera = BlinkCameraMini(self.blink.sync["test"]) 256 | self.assertIsNotNone(await self.camera.snap_picture()) 257 | 258 | @mock.patch("blinkpy.api.http_post", mock.AsyncMock(return_value={"json": "Data"})) 259 | async def test_snap_picture_blinkdoorbell(self, mock_resp): 260 | """Test camera snap picture function.""" 261 | self.camera = BlinkDoorbell(self.blink.sync["test"]) 262 | self.assertIsNotNone(await self.camera.snap_picture()) 263 | 264 | @mock.patch("blinkpy.camera.open", create=True) 265 | async def test_image_to_file(self, mock_open, mock_resp): 266 | """Test camera image to file.""" 267 | mock_resp.return_value = mresp.MockResponse({}, 200, raw_data="raw data") 268 | self.camera.thumbnail = "/thumbnail" 269 | await self.camera.image_to_file("my_path") 270 | 271 | @mock.patch("blinkpy.camera.open", create=True) 272 | async def test_image_to_file_error(self, mock_open, mock_resp): 273 | """Test camera image to file with error.""" 274 | mock_resp.return_value = mresp.MockResponse({}, 400, raw_data="raw data") 275 | self.camera.thumbnail = "/thumbnail" 276 | with self.assertLogs(level="DEBUG") as dl_log: 277 | await self.camera.image_to_file("my_path") 278 | self.assertEqual( 279 | dl_log.output[2], 280 | "ERROR:blinkpy.camera:Cannot write image to file, response 400", 281 | ) 282 | 283 | @mock.patch("blinkpy.camera.open", create=True) 284 | async def test_video_to_file_none_response(self, mock_open, mock_resp): 285 | """Test camera video to file.""" 286 | mock_resp.return_value = mresp.MockResponse({}, 200, raw_data="raw data") 287 | with self.assertLogs(level="DEBUG") as dl_log: 288 | await self.camera.video_to_file("my_path") 289 | self.assertEqual( 290 | dl_log.output[2], 291 | f"ERROR:blinkpy.camera:No saved video exists for {self.camera.name}.", 292 | ) 293 | 294 | @mock.patch("blinkpy.camera.open", create=True) 295 | async def test_video_to_file(self, mock_open, mock_resp): 296 | """Test camera vido to file with error.""" 297 | mock_resp.return_value = mresp.MockResponse({}, 400, raw_data="raw data") 298 | self.camera.clip = "my_clip" 299 | await self.camera.video_to_file("my_path") 300 | mock_open.assert_called_once() 301 | 302 | @mock.patch("blinkpy.camera.open", create=True) 303 | @mock.patch("blinkpy.camera.BlinkCamera.get_video_clip") 304 | async def test_save_recent_clips(self, mock_clip, mock_open, mock_resp): 305 | """Test camera save recent clips.""" 306 | with self.assertLogs(level="DEBUG") as dl_log: 307 | await self.camera.save_recent_clips() 308 | self.assertEqual( 309 | dl_log.output[0], 310 | f"INFO:blinkpy.camera:No recent clips to save for '{self.camera.name}'.", 311 | ) 312 | assert mock_open.call_count == 0 313 | 314 | self.camera.recent_clips = [] 315 | now = datetime.datetime.now() 316 | self.camera.recent_clips.append( 317 | { 318 | "time": (now - datetime.timedelta(minutes=20)).isoformat(), 319 | "clip": "/clip1", 320 | }, 321 | ) 322 | self.camera.recent_clips.append( 323 | { 324 | "time": (now - datetime.timedelta(minutes=1)).isoformat(), 325 | "clip": "local_storage/clip2", 326 | }, 327 | ) 328 | mock_clip.return_value = mresp.MockResponse({}, 200, raw_data="raw data") 329 | with self.assertLogs(level="DEBUG") as dl_log: 330 | await self.camera.save_recent_clips() 331 | self.assertEqual( 332 | dl_log.output[4], 333 | "INFO:blinkpy.camera:Saved 2 of 2 recent clips from " 334 | f"'{self.camera.name}' to directory /tmp/", 335 | ) 336 | assert mock_open.call_count == 2 337 | 338 | def remove_clip(self): 339 | """Remove all clips to raise an exception on second removal.""" 340 | self[0] *= 0 341 | return mresp.MockResponse({}, 200, raw_data="raw data") 342 | 343 | @mock.patch("blinkpy.camera.open", create=True) 344 | @mock.patch( 345 | "blinkpy.camera.BlinkCamera.get_video_clip", 346 | create=True, 347 | side_effect=remove_clip, 348 | ) 349 | async def test_save_recent_clips_exception(self, mock_clip, mock_open, mock_resp): 350 | """Test corruption in recent clip list.""" 351 | self.camera.recent_clips = [] 352 | now = datetime.datetime.now() 353 | self.camera.recent_clips.append( 354 | { 355 | "time": (now - datetime.timedelta(minutes=20)).isoformat(), 356 | "clip": [self.camera.recent_clips], 357 | }, 358 | ) 359 | with self.assertLogs(level="ERROR") as dl_log: 360 | await self.camera.save_recent_clips() 361 | print(f"Output = {dl_log.output}") 362 | self.assertTrue( 363 | "ERROR:blinkpy.camera:Error removing clip from list:" 364 | in "\t".join(dl_log.output) 365 | ) 366 | assert mock_open.call_count == 1 367 | 368 | async def test_missing_keys(self, mock_resp): 369 | """Tests missing signal keys.""" 370 | config = { 371 | **CONFIG, 372 | **{ 373 | "signals": {"junk": 1}, 374 | "thumbnail": "", 375 | }, 376 | } 377 | self.camera.sync.homescreen = {"devices": []} 378 | mock_resp.side_effect = [ 379 | {"temp": 71}, 380 | mresp.MockResponse({"test": 200}, 200, raw_data="test"), 381 | mresp.MockResponse({"foobar": 200}, 200, raw_data="foobar"), 382 | ] 383 | await self.camera.update(config, expire_clips=False, force=True) 384 | self.assertEqual(self.camera.battery_level, None) 385 | -------------------------------------------------------------------------------- /tests/test_cameras.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test all camera attributes. 3 | 4 | Tests the camera initialization and attributes of 5 | individual BlinkCamera instantiations once the 6 | Blink system is set up. 7 | """ 8 | 9 | from unittest import mock 10 | from unittest import IsolatedAsyncioTestCase 11 | from blinkpy.blinkpy import Blink 12 | from blinkpy.helpers.util import BlinkURLHandler 13 | from blinkpy.sync_module import BlinkSyncModule 14 | from blinkpy.camera import BlinkCamera, BlinkCameraMini, BlinkDoorbell 15 | import tests.mock_responses as mresp 16 | 17 | CONFIG = { 18 | "name": "new", 19 | "id": 1234, 20 | "network_id": 5678, 21 | "serial": "12345678", 22 | "enabled": False, 23 | "battery_state": "ok", 24 | "temperature": 68, 25 | "thumbnail": 1357924680, 26 | "signals": {"lfr": 5, "wifi": 4, "battery": 3}, 27 | "type": "test", 28 | } 29 | 30 | 31 | @mock.patch("blinkpy.auth.Auth.query", return_value={}) 32 | class TestBlinkCameraSetup(IsolatedAsyncioTestCase): 33 | """Test the Blink class in blinkpy.""" 34 | 35 | def setUp(self): 36 | """Set up Blink module.""" 37 | self.blink = Blink(session=mock.AsyncMock()) 38 | self.blink.urls = BlinkURLHandler("test") 39 | self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", 1234, []) 40 | self.camera = BlinkCamera(self.blink.sync["test"]) 41 | self.camera.name = "foobar" 42 | self.blink.sync["test"].cameras["foobar"] = self.camera 43 | 44 | def tearDown(self): 45 | """Clean up after test.""" 46 | self.blink = None 47 | self.camera = None 48 | 49 | @mock.patch( 50 | "blinkpy.api.request_motion_detection_enable", 51 | mock.AsyncMock(return_value="enable"), 52 | ) 53 | @mock.patch( 54 | "blinkpy.api.request_motion_detection_disable", 55 | mock.AsyncMock(return_value="disable"), 56 | ) 57 | async def test_camera_arm_status(self, mock_resp): 58 | """Test arming and disarming camera.""" 59 | self.camera.motion_enabled = None 60 | await self.camera.async_arm(None) 61 | self.assertFalse(self.camera.arm) 62 | await self.camera.async_arm(False) 63 | self.camera.motion_enabled = False 64 | self.assertFalse(self.camera.arm) 65 | await self.camera.async_arm(True) 66 | self.camera.motion_enabled = True 67 | self.assertTrue(self.camera.arm) 68 | 69 | self.camera = BlinkCameraMini(self.blink.sync["test"]) 70 | self.camera.motion_enabled = None 71 | await self.camera.async_arm(None) 72 | self.assertFalse(self.camera.arm) 73 | 74 | async def test_doorbell_camera_arm(self, mock_resp): 75 | """Test arming and disarming camera.""" 76 | self.blink.sync.arm = False 77 | doorbell_camera = BlinkDoorbell(self.blink.sync["test"]) 78 | doorbell_camera.motion_enabled = None 79 | await doorbell_camera.async_arm(None) 80 | self.assertFalse(doorbell_camera.arm) 81 | await doorbell_camera.async_arm(False) 82 | doorbell_camera.motion_enabled = False 83 | self.assertFalse(doorbell_camera.arm) 84 | await doorbell_camera.async_arm(True) 85 | doorbell_camera.motion_enabled = True 86 | self.assertTrue(doorbell_camera.arm) 87 | 88 | def test_missing_attributes(self, mock_resp): 89 | """Test that attributes return None if missing.""" 90 | self.camera.temperature = None 91 | self.camera.serial = None 92 | self.camera._version = None 93 | attr = self.camera.attributes 94 | self.assertEqual(attr["serial"], None) 95 | self.assertEqual(attr["temperature"], None) 96 | self.assertEqual(attr["temperature_c"], None) 97 | self.assertEqual(attr["version"], None) 98 | self.assertEqual(self.camera.version, None) 99 | 100 | def test_mini_missing_attributes(self, mock_resp): 101 | """Test that attributes return None if missing.""" 102 | camera = BlinkCameraMini(self.blink.sync) 103 | self.blink.sync.network_id = None 104 | self.blink.sync.name = None 105 | attr = camera.attributes 106 | for key in attr: 107 | if key == "recent_clips": 108 | self.assertEqual(attr[key], []) 109 | continue 110 | self.assertEqual(attr[key], None) 111 | 112 | def test_doorbell_missing_attributes(self, mock_resp): 113 | """Test that attributes return None if missing.""" 114 | camera = BlinkDoorbell(self.blink.sync) 115 | self.blink.sync.network_id = None 116 | self.blink.sync.name = None 117 | attr = camera.attributes 118 | for key in attr: 119 | if key == "recent_clips": 120 | self.assertEqual(attr[key], []) 121 | continue 122 | self.assertEqual(attr[key], None) 123 | 124 | async def test_camera_stream(self, mock_resp): 125 | """Test that camera stream returns correct url.""" 126 | mock_resp.return_value = {"server": "rtsps://foo.bar"} 127 | mini_camera = BlinkCameraMini(self.blink.sync["test"]) 128 | doorbell_camera = BlinkDoorbell(self.blink.sync["test"]) 129 | self.assertEqual(await self.camera.get_liveview(), "rtsps://foo.bar") 130 | self.assertEqual(await mini_camera.get_liveview(), "rtsps://foo.bar") 131 | self.assertEqual(await doorbell_camera.get_liveview(), "rtsps://foo.bar") 132 | 133 | async def test_different_thumb_api(self, mock_resp): 134 | """Test that the correct url is created with new api.""" 135 | thumb_endpoint = "https://rest-test.immedia-semi.com/api/v3/media/accounts/9999/networks/5678/test/1234/thumbnail/thumbnail.jpg?ts=1357924680&ext=" 136 | mock_resp.side_effect = [ 137 | {"temp": 71}, 138 | mresp.MockResponse({"test": 200}, 200, raw_data="test"), 139 | ] 140 | self.camera.sync.blink.account_id = 9999 141 | await self.camera.update(CONFIG, expire_clips=False) 142 | self.assertEqual(self.camera.thumbnail, thumb_endpoint) 143 | 144 | async def test_thumb_return_none(self, mock_resp): 145 | """Test that a 'None" thumbnail is doesn't break system.""" 146 | config = { 147 | **CONFIG, 148 | **{ 149 | "thumbnail": None, 150 | }, 151 | } 152 | mock_resp.side_effect = [ 153 | {"temp": 71}, 154 | "test", 155 | ] 156 | await self.camera.update(config, expire_clips=False) 157 | self.assertEqual(self.camera.thumbnail, None) 158 | 159 | async def test_new_thumb_url_returned(self, mock_resp): 160 | """Test that thumb handled properly if new url returned.""" 161 | thumb_return = ( 162 | "/api/v3/media/accounts/9999/networks/5678/" 163 | "test/1234/thumbnail/thumbnail.jpg?ts=1357924680&ext=" 164 | ) 165 | config = { 166 | **CONFIG, 167 | **{ 168 | "thumbnail": thumb_return, 169 | }, 170 | } 171 | mock_resp.side_effect = [ 172 | {"temp": 71}, 173 | mresp.MockResponse({"test": 200}, 200, raw_data="test"), 174 | ] 175 | self.camera.sync.blink.account_id = 9999 176 | await self.camera.update(config, expire_clips=False) 177 | self.assertEqual( 178 | self.camera.thumbnail, f"https://rest-test.immedia-semi.com{thumb_return}" 179 | ) 180 | -------------------------------------------------------------------------------- /tests/test_doorbell_as_sync.py: -------------------------------------------------------------------------------- 1 | """Tests camera and system functions.""" 2 | 3 | from unittest import mock 4 | from unittest import IsolatedAsyncioTestCase 5 | import pytest 6 | from blinkpy.blinkpy import Blink 7 | from blinkpy.helpers.util import BlinkURLHandler 8 | from blinkpy.sync_module import BlinkLotus 9 | from blinkpy.camera import BlinkDoorbell 10 | 11 | 12 | @mock.patch("blinkpy.auth.Auth.query") 13 | class TestBlinkDoorbell(IsolatedAsyncioTestCase): 14 | """Test BlinkDoorbell functions in blinkpy.""" 15 | 16 | def setUp(self): 17 | """Set up Blink module.""" 18 | self.blink = Blink(motion_interval=0, session=mock.AsyncMock()) 19 | self.blink.last_refresh = 0 20 | self.blink.urls = BlinkURLHandler("test") 21 | response = { 22 | "name": "test", 23 | "id": 3, 24 | "serial": "test123", 25 | "enabled": True, 26 | "network_id": 1, 27 | "thumbnail": "/foo/bar", 28 | } 29 | self.blink.homescreen = {"doorbells": [response]} 30 | self.blink.sync["test"] = BlinkLotus(self.blink, "test", "1234", response) 31 | self.blink.sync["test"].network_info = {"network": {"armed": True}} 32 | 33 | def tearDown(self): 34 | """Clean up after test.""" 35 | self.blink = None 36 | 37 | def test_sync_attributes(self, mock_resp): 38 | """Test sync attributes.""" 39 | self.assertEqual(self.blink.sync["test"].attributes["name"], "test") 40 | self.assertEqual(self.blink.sync["test"].attributes["network_id"], "1234") 41 | 42 | @pytest.mark.asyncio 43 | async def test_lotus_start(self, mock_resp): 44 | """Test doorbell instantiation.""" 45 | self.blink.last_refresh = None 46 | lotus = self.blink.sync["test"] 47 | self.assertTrue(await lotus.start()) 48 | self.assertTrue("test" in lotus.cameras) 49 | self.assertEqual(lotus.cameras["test"].__class__, BlinkDoorbell) 50 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | """Test blink Utils errors.""" 2 | 3 | import unittest 4 | from blinkpy.helpers.errors import ( 5 | USERNAME, 6 | PASSWORD, 7 | AUTH_TOKEN, 8 | AUTHENTICATE, 9 | REQUEST, 10 | BLINK_ERRORS, 11 | ) 12 | 13 | 14 | class TestBlinkUtilsErrors(unittest.TestCase): 15 | """Test BlinkUtilErros functions in blinkpy.""" 16 | 17 | def test_helpers_errors(self) -> None: 18 | """Test the helper errors.""" 19 | assert USERNAME 20 | assert PASSWORD 21 | assert AUTH_TOKEN 22 | assert AUTHENTICATE 23 | assert REQUEST 24 | assert BLINK_ERRORS 25 | -------------------------------------------------------------------------------- /tests/test_mini_as_sync.py: -------------------------------------------------------------------------------- 1 | """Tests camera and system functions.""" 2 | 3 | from unittest import mock 4 | from unittest import IsolatedAsyncioTestCase 5 | import pytest 6 | from blinkpy.blinkpy import Blink 7 | from blinkpy.helpers.util import BlinkURLHandler 8 | from blinkpy.sync_module import BlinkOwl 9 | from blinkpy.camera import BlinkCameraMini 10 | 11 | 12 | @mock.patch("blinkpy.auth.Auth.query") 13 | class TestBlinkSyncModule(IsolatedAsyncioTestCase): 14 | """Test BlinkSyncModule functions in blinkpy.""" 15 | 16 | def setUp(self): 17 | """Set up Blink module.""" 18 | self.blink = Blink(motion_interval=0, session=mock.AsyncMock()) 19 | self.blink.last_refresh = 0 20 | self.blink.urls = BlinkURLHandler("test") 21 | response = { 22 | "name": "test", 23 | "id": 2, 24 | "serial": "foobar123", 25 | "enabled": True, 26 | "network_id": 1, 27 | "thumbnail": "/foo/bar", 28 | } 29 | self.blink.homescreen = {"owls": [response]} 30 | self.blink.sync["test"] = BlinkOwl(self.blink, "test", "1234", response) 31 | self.blink.sync["test"].network_info = {"network": {"armed": True}} 32 | 33 | def tearDown(self): 34 | """Clean up after test.""" 35 | self.blink = None 36 | 37 | def test_sync_attributes(self, mock_resp): 38 | """Test sync attributes.""" 39 | self.assertEqual(self.blink.sync["test"].attributes["name"], "test") 40 | self.assertEqual(self.blink.sync["test"].attributes["network_id"], "1234") 41 | 42 | @pytest.mark.asyncio 43 | async def test_owl_start(self, mock_resp): 44 | """Test owl camera instantiation.""" 45 | self.blink.last_refresh = None 46 | owl = self.blink.sync["test"] 47 | self.assertTrue(await owl.start()) 48 | self.assertTrue("test" in owl.cameras) 49 | self.assertEqual(owl.cameras["test"].__class__, BlinkCameraMini) 50 | -------------------------------------------------------------------------------- /tests/test_sync_functions.py: -------------------------------------------------------------------------------- 1 | """Tests camera and system functions.""" 2 | 3 | import json 4 | from unittest import mock 5 | from unittest import IsolatedAsyncioTestCase 6 | from random import shuffle 7 | import pytest 8 | from blinkpy.blinkpy import Blink 9 | from blinkpy.helpers.util import BlinkURLHandler 10 | from blinkpy.sync_module import BlinkSyncModule 11 | from blinkpy.camera import BlinkCamera, BlinkCameraMini, BlinkDoorbell 12 | 13 | 14 | @mock.patch("blinkpy.auth.Auth.query") 15 | class TestBlinkSyncModule(IsolatedAsyncioTestCase): 16 | """Test BlinkSyncModule functions in blinkpy.""" 17 | 18 | def setUp(self): 19 | """Set up Blink module.""" 20 | self.blink = Blink(motion_interval=0, session=mock.AsyncMock()) 21 | self.blink.last_refresh = 0 22 | self.blink.urls = BlinkURLHandler("test") 23 | self.blink.sync["test"] = BlinkSyncModule(self.blink, "test", "1234", []) 24 | self.camera = BlinkCamera(self.blink.sync) 25 | self.mock_start = [ 26 | { 27 | "syncmodule": { 28 | "id": 1234, 29 | "network_id": 5678, 30 | "serial": "12345678", 31 | "status": "foobar", 32 | } 33 | }, 34 | {"event": True}, 35 | {}, 36 | {}, 37 | None, 38 | {"devicestatus": {}}, 39 | ] 40 | self.blink.sync["test"].network_info = {"network": {"armed": True}} 41 | 42 | def tearDown(self): 43 | """Clean up after test.""" 44 | self.blink = None 45 | self.camera = None 46 | self.mock_start = None 47 | 48 | @pytest.mark.asyncio 49 | async def test_check_new_videos(self, mock_resp): 50 | """Test recent video response.""" 51 | mock_resp.return_value = { 52 | "media": [ 53 | { 54 | "device_name": "foo", 55 | "media": "/foo/bar.mp4", 56 | "created_at": "1990-01-01T00:00:00+00:00", 57 | } 58 | ] 59 | } 60 | 61 | sync_module = self.blink.sync["test"] 62 | sync_module.cameras = {"foo": None} 63 | sync_module.blink.last_refresh = 0 64 | self.assertEqual(sync_module.motion, {}) 65 | self.assertTrue(await sync_module.check_new_videos()) 66 | self.assertEqual( 67 | sync_module.last_records["foo"], 68 | [{"clip": "/foo/bar.mp4", "time": "1990-01-01T00:00:00+00:00"}], 69 | ) 70 | self.assertEqual(sync_module.motion, {"foo": True}) 71 | mock_resp.return_value = {"media": []} 72 | self.assertTrue(await sync_module.check_new_videos()) 73 | self.assertEqual(sync_module.motion, {"foo": False}) 74 | self.assertEqual( 75 | sync_module.last_records["foo"], 76 | [{"clip": "/foo/bar.mp4", "time": "1990-01-01T00:00:00+00:00"}], 77 | ) 78 | 79 | @pytest.mark.asyncio 80 | async def test_check_new_videos_old_date(self, mock_resp): 81 | """Test videos return response with old date.""" 82 | mock_resp.return_value = { 83 | "media": [ 84 | { 85 | "device_name": "foo", 86 | "media": "/foo/bar.mp4", 87 | "created_at": "1970-01-01T00:00:00+00:00", 88 | } 89 | ] 90 | } 91 | 92 | sync_module = self.blink.sync["test"] 93 | sync_module.cameras = {"foo": None} 94 | sync_module.blink.last_refresh = 1000 95 | self.assertTrue(await sync_module.check_new_videos()) 96 | self.assertEqual(sync_module.motion, {"foo": False}) 97 | 98 | @pytest.mark.asyncio 99 | async def test_check_no_motion_if_not_armed(self, mock_resp): 100 | """Test that motion detection is not set if module unarmed.""" 101 | mock_resp.return_value = { 102 | "media": [ 103 | { 104 | "device_name": "foo", 105 | "media": "/foo/bar.mp4", 106 | "created_at": "1990-01-01T00:00:00+00:00", 107 | } 108 | ] 109 | } 110 | sync_module = self.blink.sync["test"] 111 | sync_module.cameras = {"foo": None} 112 | sync_module.blink.last_refresh = 1000 113 | self.assertTrue(await sync_module.check_new_videos()) 114 | self.assertEqual(sync_module.motion, {"foo": True}) 115 | sync_module.network_info = {"network": {"armed": False}} 116 | self.assertTrue(await sync_module.check_new_videos()) 117 | self.assertEqual(sync_module.motion, {"foo": False}) 118 | 119 | @pytest.mark.asyncio 120 | async def test_check_multiple_videos(self, mock_resp): 121 | """Test motion found even with multiple videos.""" 122 | mock_resp.return_value = { 123 | "media": [ 124 | { 125 | "device_name": "foo", 126 | "media": "/foo/bar.mp4", 127 | "created_at": "1970-01-01T00:00:00+00:00", 128 | }, 129 | { 130 | "device_name": "foo", 131 | "media": "/bar/foo.mp4", 132 | "created_at": "1990-01-01T00:00:00+00:00", 133 | }, 134 | { 135 | "device_name": "foo", 136 | "media": "/foobar.mp4", 137 | "created_at": "1970-01-01T00:00:01+00:00", 138 | }, 139 | ] 140 | } 141 | sync_module = self.blink.sync["test"] 142 | sync_module.cameras = {"foo": None} 143 | sync_module.blink.last_refresh = 1000 144 | self.assertTrue(await sync_module.check_new_videos()) 145 | self.assertEqual(sync_module.motion, {"foo": True}) 146 | expected_result = { 147 | "foo": [{"clip": "/bar/foo.mp4", "time": "1990-01-01T00:00:00+00:00"}] 148 | } 149 | self.assertEqual(sync_module.last_records, expected_result) 150 | 151 | @pytest.mark.asyncio 152 | async def test_sync_start(self, mock_resp): 153 | """Test sync start function.""" 154 | mock_resp.side_effect = self.mock_start 155 | await self.blink.sync["test"].start() 156 | self.assertEqual(self.blink.sync["test"].name, "test") 157 | self.assertEqual(self.blink.sync["test"].sync_id, 1234) 158 | self.assertEqual(self.blink.sync["test"].network_id, 5678) 159 | self.assertEqual(self.blink.sync["test"].serial, "12345678") 160 | self.assertEqual(self.blink.sync["test"].status, "foobar") 161 | 162 | @pytest.mark.asyncio 163 | async def test_sync_with_mixed_cameras(self, mock_resp): 164 | """Test sync module with mixed cameras attached.""" 165 | resp_sync = { 166 | "syncmodule": { 167 | "network_id": 1234, 168 | "id": 1, 169 | "serial": 456, 170 | "status": "onboarded", 171 | } 172 | } 173 | resp_network_info = {"network": {"sync_module_error": False}} 174 | resp_videos = {"media": []} 175 | resp_empty = {} 176 | 177 | self.blink.sync["test"].camera_list = [ 178 | {"name": "foo", "id": 10, "type": "default"}, 179 | {"name": "bar", "id": 11, "type": "mini"}, 180 | {"name": "fake", "id": 12, "type": "doorbell"}, 181 | ] 182 | 183 | self.blink.homescreen = { 184 | "owls": [{"name": "bar", "id": 3}], 185 | "doorbells": [{"name": "fake", "id": 12}], 186 | } 187 | 188 | side_effect = [ 189 | resp_sync, 190 | resp_network_info, 191 | resp_videos, 192 | resp_empty, 193 | resp_empty, 194 | resp_empty, 195 | resp_empty, 196 | resp_empty, 197 | resp_empty, 198 | ] 199 | 200 | mock_resp.side_effect = side_effect 201 | 202 | test_sync = self.blink.sync["test"] 203 | 204 | self.assertTrue(await test_sync.start()) 205 | self.assertEqual(test_sync.cameras["foo"].__class__, BlinkCamera) 206 | self.assertEqual(test_sync.cameras["bar"].__class__, BlinkCameraMini) 207 | self.assertEqual(test_sync.cameras["fake"].__class__, BlinkDoorbell) 208 | 209 | # Now shuffle the cameras and see if it still works 210 | for i in range(0, 10): 211 | shuffle(test_sync.camera_list) 212 | mock_resp.side_effect = side_effect 213 | self.assertTrue(await test_sync.start()) 214 | debug_msg = f"Iteration: {i}, {test_sync.camera_list}" 215 | self.assertEqual( 216 | test_sync.cameras["foo"].__class__, BlinkCamera, msg=debug_msg 217 | ) 218 | self.assertEqual( 219 | test_sync.cameras["bar"].__class__, BlinkCameraMini, msg=debug_msg 220 | ) 221 | self.assertEqual( 222 | test_sync.cameras["fake"].__class__, BlinkDoorbell, msg=debug_msg 223 | ) 224 | 225 | @pytest.mark.asyncio 226 | async def test_init_local_storage(self, mock_resp): 227 | """Test initialization of local storage object.""" 228 | json_fragment = """{ 229 | "sync_modules": [ 230 | { 231 | "id": 123456, 232 | "name": "test", 233 | "local_storage_enabled": true, 234 | "local_storage_compatible": true, 235 | "local_storage_status": "active" 236 | } 237 | ] 238 | }""" 239 | self.blink.homescreen = json.loads(json_fragment) 240 | await self.blink.sync["test"]._init_local_storage(123456) 241 | self.assertTrue(self.blink.sync["test"].local_storage) 242 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | """Test various api functions.""" 2 | 3 | from unittest import mock, IsolatedAsyncioTestCase 4 | import time 5 | import aiofiles 6 | from io import BufferedIOBase 7 | from blinkpy.helpers.util import ( 8 | json_load, 9 | json_save, 10 | Throttle, 11 | time_to_seconds, 12 | gen_uid, 13 | get_time, 14 | merge_dicts, 15 | backoff_seconds, 16 | BlinkException, 17 | ) 18 | from blinkpy.helpers import constants as const 19 | 20 | 21 | class TestUtil(IsolatedAsyncioTestCase): 22 | """Test the helpers/util module.""" 23 | 24 | def setUp(self): 25 | """Initialize the blink module.""" 26 | 27 | def tearDown(self): 28 | """Tear down blink module.""" 29 | 30 | async def test_throttle(self): 31 | """Test the throttle decorator.""" 32 | calls = [] 33 | 34 | @Throttle(seconds=5) 35 | async def test_throttle(force=False): 36 | calls.append(1) 37 | 38 | now = int(time.time()) 39 | 40 | # First call should fire 41 | await test_throttle() 42 | self.assertEqual(1, len(calls)) 43 | 44 | # Call again, still should fire with delay 45 | await test_throttle() 46 | self.assertEqual(2, len(calls)) 47 | assert int(time.time()) - now >= 5 48 | 49 | # Call with force 50 | await test_throttle(force=True) 51 | self.assertEqual(3, len(calls)) 52 | 53 | # Call without throttle, fire with delay 54 | now = int(time.time()) 55 | 56 | await test_throttle() 57 | self.assertEqual(4, len(calls)) 58 | assert int(time.time()) - now >= 5 59 | 60 | async def test_throttle_per_instance(self): 61 | """Test that throttle is done once per instance of class.""" 62 | 63 | class Tester: 64 | """A tester class for throttling.""" 65 | 66 | async def test(self): 67 | """Test the throttle.""" 68 | return True 69 | 70 | tester = Tester() 71 | throttled = Throttle(seconds=1)(tester.test) 72 | now = int(time.time()) 73 | self.assertEqual(await throttled(), True) 74 | self.assertEqual(await throttled(), True) 75 | assert int(time.time()) - now >= 1 76 | 77 | async def test_throttle_multiple_objects(self): 78 | """Test that function is throttled even if called by multiple objects.""" 79 | 80 | @Throttle(seconds=5) 81 | async def test_throttle_method(): 82 | return True 83 | 84 | class Tester: 85 | """A tester class for throttling.""" 86 | 87 | def test(self): 88 | """Test function for throttle.""" 89 | return test_throttle_method() 90 | 91 | tester1 = Tester() 92 | tester2 = Tester() 93 | now = int(time.time()) 94 | self.assertEqual(await tester1.test(), True) 95 | self.assertEqual(await tester2.test(), True) 96 | assert int(time.time()) - now >= 5 97 | 98 | async def test_throttle_on_two_methods(self): 99 | """Test that throttle works for multiple methods.""" 100 | 101 | class Tester: 102 | """A tester class for throttling.""" 103 | 104 | @Throttle(seconds=3) 105 | async def test1(self): 106 | """Test function for throttle.""" 107 | return True 108 | 109 | @Throttle(seconds=5) 110 | async def test2(self): 111 | """Test function for throttle.""" 112 | return True 113 | 114 | tester = Tester() 115 | now = int(time.time()) 116 | 117 | self.assertEqual(await tester.test1(), True) 118 | self.assertEqual(await tester.test2(), True) 119 | self.assertEqual(await tester.test1(), True) 120 | assert int(time.time()) - now >= 3 121 | self.assertEqual(await tester.test2(), True) 122 | assert int(time.time()) - now >= 5 123 | 124 | def test_time_to_seconds(self): 125 | """Test time to seconds conversion.""" 126 | correct_time = "1970-01-01T00:00:05+00:00" 127 | wrong_time = "1/1/1970 00:00:03" 128 | self.assertEqual(time_to_seconds(correct_time), 5) 129 | self.assertFalse(time_to_seconds(wrong_time)) 130 | 131 | async def test_json_save(self): 132 | """Check that the file is saved.""" 133 | mock_file = mock.MagicMock() 134 | aiofiles.threadpool.wrap.register(mock.MagicMock)( 135 | lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase( 136 | *args, **kwargs 137 | ) 138 | ) 139 | with mock.patch( 140 | "aiofiles.threadpool.sync_open", return_value=mock_file 141 | ) as mock_open: 142 | await json_save('{"test":1,"test2":2}', "face.file") 143 | mock_open.assert_called_once() 144 | 145 | async def test_json_load_data(self): 146 | """Check that bad file is handled.""" 147 | filename = "fake.file" 148 | aiofiles.threadpool.wrap.register(mock.MagicMock)( 149 | lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase( 150 | *args, **kwargs 151 | ) 152 | ) 153 | self.assertEqual(await json_load(filename), None) 154 | 155 | mock_file = mock.MagicMock(spec=BufferedIOBase) 156 | mock_file.name = filename 157 | mock_file.read.return_value = '{"some data":"more"}' 158 | with mock.patch("aiofiles.threadpool.sync_open", return_value=mock_file): 159 | self.assertNotEqual(await json_load(filename), None) 160 | 161 | async def test_json_load_bad_data(self): 162 | """Check that bad file is handled.""" 163 | self.assertEqual(await json_load("fake.file"), None) 164 | filename = "fake.file" 165 | aiofiles.threadpool.wrap.register(mock.MagicMock)( 166 | lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase( 167 | *args, **kwargs 168 | ) 169 | ) 170 | self.assertEqual(await json_load(filename), None) 171 | 172 | mock_file = mock.MagicMock(spec=BufferedIOBase) 173 | mock_file.name = filename 174 | mock_file.read.return_value = "" 175 | with mock.patch("aiofiles.threadpool.sync_open", return_value=mock_file): 176 | self.assertEqual(await json_load("fake.file"), None) 177 | 178 | def test_gen_uid(self): 179 | """Test gen_uid formatting.""" 180 | val1 = gen_uid(8) 181 | val2 = gen_uid(8, uid_format=True) 182 | 183 | self.assertEqual(len(val1), 16) 184 | 185 | self.assertTrue(val2.startswith("BlinkCamera_")) 186 | val2_cut = val2.split("_") 187 | val2_split = val2_cut[1].split("-") 188 | self.assertEqual(len(val2_split[0]), 8) 189 | self.assertEqual(len(val2_split[1]), 4) 190 | self.assertEqual(len(val2_split[2]), 4) 191 | self.assertEqual(len(val2_split[3]), 4) 192 | self.assertEqual(len(val2_split[4]), 12) 193 | 194 | def test_get_time(self): 195 | """Test the get time util.""" 196 | self.assertEqual( 197 | get_time(), time.strftime(const.TIMESTAMP_FORMAT, time.gmtime(time.time())) 198 | ) 199 | 200 | def test_merge_dicts(self): 201 | """Test for duplicates message in merge dicts.""" 202 | dict_A = {"key1": "value1", "key2": "value2"} 203 | dict_B = {"key1": "value1"} 204 | 205 | expected_log = [ 206 | "WARNING:blinkpy.helpers.util:Duplicates found during merge: ['key1']. " 207 | "Renaming is recommended." 208 | ] 209 | 210 | with self.assertLogs(level="DEBUG") as merge_log: 211 | merge_dicts(dict_A, dict_B) 212 | self.assertListEqual(merge_log.output, expected_log) 213 | 214 | def test_backoff_seconds(self): 215 | """Test the backoff seconds function.""" 216 | self.assertNotEqual(backoff_seconds(), None) 217 | 218 | def test_blink_exception(self): 219 | """Test the Blink Exception class.""" 220 | test_exception = BlinkException([1, "No good"]) 221 | self.assertEqual(test_exception.errid, 1) 222 | self.assertEqual(test_exception.message, "No good") 223 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = build, py39, py310, py311, py312, lint 3 | skip_missing_interpreters = True 4 | skipsdist = True 5 | 6 | [testenv] 7 | setenv = 8 | LANG=en_US.UTF-8 9 | PYTHONPATH = {toxinidir} 10 | commands = 11 | pytest --timeout=30 --durations=10 --cov=blinkpy --cov-report term-missing {posargs} 12 | deps = 13 | -r{toxinidir}/requirements.txt 14 | -r{toxinidir}/requirements_test.txt 15 | 16 | [testenv:cov] 17 | setenv = 18 | LANG=en_US.UTF-8 19 | PYTHONPATH = {toxinidir} 20 | commands = 21 | pip install -e . 22 | pytest --timeout=30 --durations=10 --cov=blinkpy --cov-report=xml {posargs} 23 | deps = 24 | -r{toxinidir}/requirements.txt 25 | -r{toxinidir}/requirements_test.txt 26 | 27 | [testenv:lint] 28 | deps = 29 | -r{toxinidir}/requirements.txt 30 | -r{toxinidir}/requirements_test.txt 31 | basepython = python3 32 | commands = 33 | ruff check blinkpy tests blinkapp 34 | black --check --color --diff blinkpy tests blinkapp 35 | rst-lint README.rst CHANGES.rst CONTRIBUTING.rst 36 | 37 | [testenv:build] 38 | recreate = True 39 | skip_install = True 40 | allowlist_externals = 41 | /bin/sh 42 | /bin/rm 43 | deps = 44 | -r{toxinidir}/requirements_test.txt 45 | commands = 46 | /bin/rm -rf build dist 47 | python -m build 48 | /bin/sh -c "pip install --upgrade dist/*.whl" 49 | py.test tests 50 | --------------------------------------------------------------------------------