pax_global_header00006660000000000000000000000064150012047510014505gustar00rootroot0000000000000052 comment=b2b4c91a94ab2677d894a9307510b5600eec3050 pyschlage-2025.4.0/000077500000000000000000000000001500120475100137165ustar00rootroot00000000000000pyschlage-2025.4.0/.devcontainer.json000066400000000000000000000021551500120475100173530ustar00rootroot00000000000000{ "name": "Python 3", "image": "mcr.microsoft.com/devcontainers/python:1-3.12", "postCreateCommand": "scripts/setup", "customizations": { "vscode": { "extensions": [ "charliermarsh.ruff", "streetsidesoftware.code-spell-checker", "github.vscode-pull-request-github", "ms-python.black-formatter", "ms-python.mypy-type-checker", "ms-python.python", "ms-python.vscode-pylance" ], "settings": { "files.eol": "\n", "editor.tabSize": 4, "python.pythonPath": "/usr/bin/python3", "python.analysis.autoSearchPaths": false, "python.formatting.provider": "black", "python.formatting.blackPath": "/usr/local/py-utils/bin/black", "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, "files.trimTrailingWhitespace": true } } }, "remoteUser": "vscode" }pyschlage-2025.4.0/.github/000077500000000000000000000000001500120475100152565ustar00rootroot00000000000000pyschlage-2025.4.0/.github/dependabot.yml000066400000000000000000000003061500120475100201050ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily - package-ecosystem: "github-actions" directory: "/" schedule: interval: daily pyschlage-2025.4.0/.github/release-drafter.yml000066400000000000000000000006271500120475100210530ustar00rootroot00000000000000name-template: $RESOLVED_VERSION tag-template: $RESOLVED_VERSION categories: - title: '⚠️ Breaking changes' labels: - breaking - title: 'πŸš€ New features' labels: - enhancement - title: '🐞 Bug fixes' labels: - bug - title: 'πŸ“‹ Other changes' - title: '🧩 Dependency updates' labels: - dependencies template: | ## What’s Changed $CHANGES pyschlage-2025.4.0/.github/workflows/000077500000000000000000000000001500120475100173135ustar00rootroot00000000000000pyschlage-2025.4.0/.github/workflows/build-and-test.yml000066400000000000000000000036351500120475100226610ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python name: Build and Test on: push: branches: [ "main" ] pull_request: branches: [ "main" ] env: PYTHON_VERSION: "3.12" jobs: build_and_test: runs-on: ubuntu-latest strategy: fail-fast: false steps: - uses: actions/checkout@v4 - name: Set up Python ${{ env.PYTHON_VERSION }} uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install -r requirements-test.txt - name: Test with pytest run: | pytest ruff: runs-on: ubuntu-latest strategy: fail-fast: false steps: - uses: actions/checkout@v4 - name: Set up Python ${{ env.PYTHON_VERSION }} uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install -r requirements-test.txt - name: Run ruff run: | ruff check --output-format=github . mypy: runs-on: ubuntu-latest strategy: fail-fast: false steps: - uses: actions/checkout@v4 - name: "Set up Python ${{ env.PYTHON_VERSION }}" uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements-test.txt mypy --non-interactive --install-types . - name: Register mypy problem matcher run: | echo "::add-matcher::.github/workflows/matchers/mypy.json" - name: Run mypy run: | mypy . pyschlage-2025.4.0/.github/workflows/matchers/000077500000000000000000000000001500120475100211215ustar00rootroot00000000000000pyschlage-2025.4.0/.github/workflows/matchers/mypy.json000066400000000000000000000004461500120475100230160ustar00rootroot00000000000000{ "problemMatcher": [ { "owner": "mypy", "pattern": [ { "regexp": "^(.+):(\\d+):\\s(error|warning):\\s(.+)$", "file": 1, "line": 2, "severity": 3, "message": 4 } ] } ] } pyschlage-2025.4.0/.github/workflows/publish-python.yml000066400000000000000000000012301500120475100230170ustar00rootroot00000000000000name: Publish to PyPI on: release: types: [published] env: PYTHON_VERSION: "3.12" jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine build - name: Build and publish env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | python -m build twine upload dist/* pyschlage-2025.4.0/.github/workflows/release-drafter.yml000066400000000000000000000003731500120475100231060ustar00rootroot00000000000000name: Release Drafter "on": push: branches: - main jobs: update_release_draft: runs-on: ubuntu-latest steps: - uses: release-drafter/release-drafter@v6.1.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} pyschlage-2025.4.0/.gitignore000066400000000000000000000035441500120475100157140ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # Version file generated by packaging pyschlage/_version.py # Schlage credentials .schlage pyschlage-2025.4.0/.pre-commit-config.yaml000066400000000000000000000025211500120475100201770ustar00rootroot00000000000000repos: - repo: https://github.com/asottile/pyupgrade rev: v3.3.1 hooks: - id: pyupgrade args: [--py310-plus] stages: [manual] - repo: https://github.com/psf/black rev: 23.1.0 hooks: - id: black args: - --quiet files: ^.*\.py$ - repo: https://github.com/PyCQA/isort rev: 5.12.0 hooks: - id: isort args: ["--profile", "black", "--filter-files"] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: check-executables-have-shebangs stages: [manual] - id: check-json exclude: (.vscode|.devcontainer) - id: no-commit-to-branch args: - --branch=main - repo: https://github.com/cdce8p/python-typing-update rev: v0.5.0 hooks: # Run `python-typing-update` hook manually from time to time # to update python typing syntax. # Will require manual work, before submitting changes! # pre-commit run --hook-stage manual python-typing-update --all-files - id: python-typing-update stages: [manual] args: - --py310-plus - --force - --keep-updates files: ^.*\.py$ - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.8.0 hooks: - id: ruff args: - --fix pyschlage-2025.4.0/.readthedocs.yaml000066400000000000000000000003071500120475100171450ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: "3.12" python: install: - method: pip path: . - requirements: docs/requirements.txt sphinx: configuration: docs/conf.py pyschlage-2025.4.0/.vscode/000077500000000000000000000000001500120475100152575ustar00rootroot00000000000000pyschlage-2025.4.0/.vscode/settings.json000066400000000000000000000002701500120475100200110ustar00rootroot00000000000000{ "python.formatting.provider": "black", "python.pythonPath": "/usr/local/bin/python", "python.testing.pytestEnabled": true, "cSpell.words": [ "Schlage" ] }pyschlage-2025.4.0/LICENSE000066400000000000000000000261351500120475100147320ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. pyschlage-2025.4.0/README.md000066400000000000000000000024541500120475100152020ustar00rootroot00000000000000# pyschlage Python 3 library for interacting with Schlage Encode WiFi locks. *Note that this project has no official relationship with Schlage or Allegion. Use at your own risk.* ## Usage ```python from pyschlage import Auth, Schlage # Create a Schlage object and authenticate with your credentials. s = Schlage(Auth("username", "password")) # List the locks attached to your account. locks = s.locks() # Print the name of the first lock print(locks[0].name) "My lock" # Lock the first lock. lock[0].lock() ``` ## Installation ### Pip To install pyschlage, run this command in your terminal: ```sh $ pip install pyschlage ``` ### Source code Pyschlage is actively developed on Github, where the code is [always available](https://github.com/dknowles2/pyschlage). You can either clone the public repository: ```sh $ git clone https://github.com/dknowles2/pyschlage ``` Or download the latest [tarball](https://github.com/dknowles2/pyschlage/tarball/main): ```sh $ curl -OL https://github.com/dknowles2/pyschlage/tarball/main ``` Once you have a copy of the source, you can embed it in your own Python package, or install it into your site-packages easily: ```sh $ cd pyschlage $ python -m pip install . ``` ## Documentation API reference can be found on [Read the Docs](https://pyschlage.readthedocs.io) pyschlage-2025.4.0/docs/000077500000000000000000000000001500120475100146465ustar00rootroot00000000000000pyschlage-2025.4.0/docs/Makefile000066400000000000000000000011721500120475100163070ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) pyschlage-2025.4.0/docs/api.rst000066400000000000000000000030261500120475100161520ustar00rootroot00000000000000API Reference ============= Main API -------- The main entry-point into pyschlage is through the :class:`pyschlage.Schlage ` object. From there you can access the locks associated with a Schlage account, and interact with them directly. .. autoclass:: pyschlage.Schlage :members: :special-members: __init__ Authentication -------------- Creating a :class:`Schlage ` object first requires creating an authentication and transport object, which is encapsulated in the :class:`pyschlage.Auth ` object. .. autoclass:: pyschlage.Auth :members: :special-members: __init__ Locks ----- The :class:`Schlage ` object provides access to :class:`Lock ` objects. Each instance of a :class:`Lock ` itself can fetch additional data such as :class:`access codes ` and :class:`log entries `. .. autoclass:: pyschlage.lock.Lock :members: :undoc-members: .. autoclass:: pyschlage.code.AccessCode :members: :undoc-members: .. autoclass:: pyschlage.code.TemporarySchedule :members: :undoc-members: .. autoclass:: pyschlage.code.RecurringSchedule :members: :undoc-members: .. autoclass:: pyschlage.code.DaysOfWeek :members: :undoc-members: .. autoclass:: pyschlage.log.LockLog :members: :undoc-members: Exceptions ---------- .. automodule:: pyschlage.exceptions :members: :undoc-members: .. toctree:: :maxdepth: 2 :caption: Contents: pyschlage-2025.4.0/docs/conf.py000066400000000000000000000007331500120475100161500ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. import os import sys sys.path.insert(0, os.path.abspath("..")) project = "pyschlage" copyright = "2023, David Knowles" author = "David Knowles" release = "2023.3.2" extensions = ["sphinx.ext.autodoc", "sphinx.ext.autosummary"] templates_path = ["_templates"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] html_theme = "sphinx_rtd_theme" html_static_path = ["_static"] autodoc_member_order = "groupwise" pyschlage-2025.4.0/docs/index.rst000066400000000000000000000037741500120475100165220ustar00rootroot00000000000000Pyschlage ========= .. image:: https://github.com/dknowles2/pyschlage/workflows/Build%20and%20Test/badge.svg :target: https://github.com/dknowles2/pyschlage/actions/workflows/build-and-test.yml :alt: Build and Test .. image:: https://img.shields.io/pypi/v/pyschlage.svg :target: https://pypi.python.org/pypi/pyschlage .. image:: https://readthedocs.org/projects/pyschlage/badge/?version=latest :target: https://pyschlage.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status .. image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/psf/black :alt: Black Pyschlage is a Python 3 library for interacting with Schlage Encode WiFi locks. ------------------- Basic usage =========== .. code-block:: python >>> from pyschlage import Auth, Schlage >>> # Create a Schlage object and authenticate with your credentials. >>> s = Schlage(Auth("username", "password")) >>> # List the locks attached to your account. >>> locks = s.locks() >>> # Print the name of the first lock >>> print(locks[0].name) 'My lock' >>> # Lock the first lock. >>> lock[0].lock() Installation ============ Pip --- To install pyschlage, run this command in your terminal: .. code-block:: bash $ pip install pyschlage Source code ----------- Pyschlage is actively developed on Github, where the code is `always available `_. You can either clone the public repository: .. code-block:: bash $ git clone https://github.com/dknowles2/pyschlage Or download the latest `tarball `_: .. code-block:: bash $ curl -OL https://github.com/dknowles2/pyschlage/tarball/main Once you have a copy of the source, you can embed it in your own Python package, or install it into your site-packages easily: .. code-block:: bash $ cd pyschlage $ python -m pip install . API Reference ============= .. toctree:: :maxdepth: 2 api pyschlage-2025.4.0/docs/make.bat000066400000000000000000000014401500120475100162520ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.https://www.sphinx-doc.org/ exit /b 1 ) if "%1" == "" goto help %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd pyschlage-2025.4.0/docs/requirements.txt000066400000000000000000000000461500120475100201320ustar00rootroot00000000000000Sphinx==8.2.3 sphinx_rtd_theme==3.0.2 pyschlage-2025.4.0/mypy.ini000066400000000000000000000001521500120475100154130ustar00rootroot00000000000000[mypy] [mypy-botocore.*] ignore_missing_imports = True [mypy-pycognito.*] ignore_missing_imports = True pyschlage-2025.4.0/pyproject.toml000066400000000000000000000025501500120475100166340ustar00rootroot00000000000000[build-system] requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"] build-backend = "setuptools.build_meta" [project] name = "pyschlage" description = "Python API for interacting with Schlage WiFi locks." authors = [ {name = "David Knowles", email = "dknowles2@gmail.com"}, ] dependencies = ["pycognito", "requests"] requires-python = ">=3.12" dynamic = ["readme", "version"] license = {text = "Apache-2.0"} keywords = ["schlage", "api", "iot"] classifiers = [ "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Python Modules", ] [project.urls] "Homepage" = "https://github.com/dknowles2/pyschlage" "Source Code" = "https://github.com/dknowles2/pyschlage" "Bug Reports" = "https://github.com/dknowles2/pyschlage/issues" "Documentation" = "https://pyschlage.readthedocs.io" [tool.setuptools] platforms = ["any"] zip-safe = true include-package-data = true [tool.setuptools.dynamic] readme = { file = ["README.md"], content-type = "text/markdown" } [tool.setuptools_scm] write_to = "pyschlage/_version.py" [tool.coverage.report] omit = ["pyschlage/_version.py"] [tool.isort] profile = "black" combine_as_imports = true force_sort_within_sections = true forced_separate = ["tests"] pyschlage-2025.4.0/pyschlage/000077500000000000000000000000001500120475100156755ustar00rootroot00000000000000pyschlage-2025.4.0/pyschlage/__init__.py000066400000000000000000000002551500120475100200100ustar00rootroot00000000000000"""Client library for interacting with Schlage WiFi locks.""" from .api import Schlage from .auth import Auth from .lock import Lock __all__ = ("Auth", "Schlage", "Lock") pyschlage-2025.4.0/pyschlage/api.py000066400000000000000000000027771500120475100170350ustar00rootroot00000000000000"""API for interacting with the Schlage WiFi cloud service.""" from __future__ import annotations from .auth import Auth from .lock import Lock from .user import User class Schlage: """API for interacting with the Schlage WiFi cloud service.""" def __init__(self, auth: Auth) -> None: """Instantiates a Schlage API object. :param auth: Authentication and transport for the API. :type auth: pyschlage.Auth """ self._auth = auth def locks(self) -> list[Lock]: """Retrieves all locks associated with this account. :rtype: list[Lock] :raise pyschlage.exceptions.NotAuthorizedError: When authentication fails. :raise pyschlage.exceptions.UnknownError: On other errors. """ path = Lock.request_path() response = self._auth.request("get", path, params={"archetype": "lock"}) locks = [] for lock_json in response.json(): lock = Lock.from_json(self._auth, lock_json) lock.refresh_access_codes() locks.append(lock) return locks def users(self) -> list[User]: """Retrieves all users associated with this account's locks. :rtype: list[User] :raise pyschlage.exceptions.NotAuthorizedError: When authentication fails. :raise pyschlage.exceptions.UnknownError: On other errors. """ path = User.request_path() response = self._auth.request("get", path) return [User.from_json(u) for u in response.json()] pyschlage-2025.4.0/pyschlage/auth.py000066400000000000000000000101311500120475100172040ustar00rootroot00000000000000"""Authentication support for the Schlage WiFi cloud service.""" from __future__ import annotations from functools import wraps from typing import Callable from botocore.exceptions import ClientError import pycognito from pycognito import utils import requests from .exceptions import NotAuthorizedError, UnknownError _DEFAULT_TIMEOUT = 60 _NOT_AUTHORIZED_ERRORS = ( "NotAuthorizedException", "InvalidPasswordException", "PasswordResetRequiredException", "UserNotFoundException", "UserNotConfirmedException", ) API_KEY = "hnuu9jbbJr7MssFDWm5nU2Z7nG5Q5rxsaqWsE7e9" BASE_URL = "https://api.allegion.yonomi.cloud/v1" CLIENT_ID = "t5836cptp2s1il0u9lki03j5" CLIENT_SECRET = "1kfmt18bgaig51in4j4v1j3jbe7ioqtjhle5o6knqc5dat0tpuvo" USER_POOL_REGION = "us-west-2" USER_POOL_ID = USER_POOL_REGION + "_2zhrVs9d4" def _translate_auth_errors( # pylint: disable=invalid-name fn: Callable[..., requests.Response], # pylint: enable=invalid-name ) -> Callable[..., requests.Response]: @wraps(fn) def wrapper(*args, **kwargs) -> requests.Response: try: return fn(*args, **kwargs) except ClientError as ex: resp_err = ex.response.get("Error", {}) if resp_err.get("Code") in _NOT_AUTHORIZED_ERRORS: raise NotAuthorizedError( resp_err.get("Message", "Not authorized") ) from ex raise UnknownError(str(ex)) from ex # pragma: no cover return wrapper def _translate_http_errors( # pylint: disable=invalid-name fn: Callable[..., requests.Response], # pylint: enable=invalid-name ) -> Callable[..., requests.Response]: @wraps(fn) def wrapper(*args, **kwargs) -> requests.Response: resp = fn(*args, **kwargs) try: resp.raise_for_status() return resp except requests.HTTPError as ex: try: message = resp.json().get("message", resp.reason) except requests.JSONDecodeError: message = resp.reason raise UnknownError(message) from ex return wrapper class Auth: """Handles authentication for the Schlage WiFi cloud service.""" def __init__(self, username: str, password: str) -> None: """Initializes an Auth object. :param username: The username associated with the Schlage account. :type username: str :param password: The password for the account. :type password: str """ self.cognito = pycognito.Cognito( username=username, user_pool_region=USER_POOL_REGION, user_pool_id=USER_POOL_ID, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, ) self.auth = utils.RequestsSrpAuth( password=password, cognito=self.cognito, ) self._user_id: str | None = None @_translate_auth_errors def authenticate(self): """Performs authentication with AWS. :raise pyschlage.exceptions.NotAuthorizedError: When authentication fails. :raise pyschlage.exceptions.UnknownError: On other errors. """ self.auth(requests.Request()) @property def user_id(self) -> str: """Returns the unique user id for the authenticated user.""" if self._user_id is None: self._user_id = self._get_user_id() return self._user_id def _get_user_id(self) -> str: resp = self.request("get", "users/@me") return resp.json()["identityId"] @_translate_http_errors @_translate_auth_errors def request( self, method: str, path: str, base_url: str = BASE_URL, **kwargs ) -> requests.Response: """Performs a request against the Schlage WiFi cloud service. :meta private: """ kwargs["auth"] = self.auth if "headers" not in kwargs: kwargs["headers"] = {} kwargs["headers"]["X-Api-Key"] = API_KEY kwargs.setdefault("timeout", _DEFAULT_TIMEOUT) # pylint: disable=missing-timeout return requests.request(method, f"{base_url}/{path.lstrip('/')}", **kwargs) pyschlage-2025.4.0/pyschlage/code.py000066400000000000000000000217131500120475100171650ustar00rootroot00000000000000"""Objects and routines related to Schlage WiFI access codes.""" from __future__ import annotations from dataclasses import astuple, dataclass, field from datetime import datetime from typing import Any from .auth import Auth from .common import Mutable from .device import Device from .exceptions import NotAuthenticatedError from .notification import ON_UNLOCK_ACTION, Notification _MIN_TIME = 0 _MAX_TIME = 0xFFFFFFFF _MIN_HOUR = 0 _MIN_MINUTE = 0 _MAX_HOUR = 23 _MAX_MINUTE = 59 _ALL_DAYS = "7F" @dataclass class TemporarySchedule: """A temporary schedule for when an AccessCode is enabled.""" start: datetime """The time at which the schedule should start.""" end: datetime """The time at which the schedule should end.""" @classmethod def from_json(cls, json) -> TemporarySchedule: """Creates a TemporarySchedule from a JSON dict. :meta private: """ return TemporarySchedule( start=datetime.fromtimestamp(json["activationSecs"]), end=datetime.fromtimestamp(json["expirationSecs"]), ) def to_json(self) -> dict: """Returns a JSON dict of this TemporarySchedule. :meta private: """ return { "activationSecs": int(self.start.timestamp()), "expirationSecs": int(self.end.timestamp()), } @dataclass class DaysOfWeek: """Enabled status for each day of the week.""" sun: bool = True mon: bool = True tue: bool = True wed: bool = True thu: bool = True fri: bool = True sat: bool = True @classmethod def from_str(cls, s) -> DaysOfWeek: """Creates a DaysOfWeek from a hex string. :meta private: """ n = int(s, 16) return cls(*[(n & (1 << i)) != 0 for i in reversed(range(7))]) def to_str(self) -> str: """Returns the string representation. :meta private: """ n = 0 for d in astuple(self): n = (n << 1) | d return hex(n).lstrip("0x").upper() @dataclass class RecurringSchedule: """A recurring schedule for when an AccessCode is enabled.""" days_of_week: DaysOfWeek = field(default_factory=DaysOfWeek) """Days of the week on which the access code is enabled.""" start_hour: int = _MIN_HOUR """Hour at which the access code is enabled.""" start_minute: int = _MIN_MINUTE """Minute at which the access code is enabled.""" end_hour: int = _MAX_HOUR """Hour at which the access code is disabled.""" end_minute: int = _MAX_MINUTE """Minute at which the access code is disabled.""" @classmethod def from_json(cls, json: dict[str, Any] | None) -> RecurringSchedule | None: """Creates a RecurringSchedule from a JSON dict. :meta private: """ if not json: return None if ( json["daysOfWeek"] == _ALL_DAYS and json["startHour"] == _MIN_HOUR and json["startMinute"] == _MIN_MINUTE and json["endHour"] == _MAX_HOUR and json["endMinute"] == _MAX_MINUTE ): return None return cls( DaysOfWeek.from_str(json["daysOfWeek"]), json["startHour"], json["startMinute"], json["endHour"], json["endMinute"], ) def to_json(self) -> dict: """Returns a JSON dict of this RecurringSchedule. :meta private: """ return { "daysOfWeek": self.days_of_week.to_str(), "startHour": self.start_hour, "startMinute": self.start_minute, "endHour": self.end_hour, "endMinute": self.end_minute, } @dataclass class AccessCode(Mutable): """An access code for a lock.""" name: str = "" """User-specified name for the access code.""" code: str = "" """The access code.""" schedule: TemporarySchedule | RecurringSchedule | None = None """Optional schedule at which the code is enabled.""" notify_on_use: bool = False """Whether to notify the user's phone app when the code is used.""" disabled: bool = False """Whether the code is disabled.""" device_id: str | None = field(default=None, repr=False) """Unique identifier for the device the access code is associated with.""" access_code_id: str | None = field(default=None, repr=False) """Unique identifier for the access code.""" _device: Device | None = field(default=None, repr=False) _notification: Notification | None = field(default=None, repr=False) _json: dict[Any, Any] = field(default_factory=dict, repr=False) @staticmethod def request_path(device_id: str, access_code_id: str | None = None) -> str: """Returns the request path for an AccessCode. :meta private: """ path = f"devices/{device_id}/storage/accesscode" if access_code_id: return f"{path}/{access_code_id}" # pragma: no cover return path @classmethod def from_json(cls, auth: Auth, device: Device, json: dict[str, Any]) -> AccessCode: """Creates an AccessCode from a JSON dict. :meta private: """ schedule: TemporarySchedule | RecurringSchedule | None = None if json["activationSecs"] == _MIN_TIME and json["expirationSecs"] == _MAX_TIME: schedule = RecurringSchedule.from_json(json["schedule1"]) else: schedule = TemporarySchedule.from_json(json) access_code_length = json.get("accessCodeLength", 4) return AccessCode( _auth=auth, _json=json, _device=device, access_code_id=json["accesscodeId"], name=json["friendlyName"], code=f"{json['accessCode']:0{access_code_length}}", notify_on_use=bool(json["notification"]), disabled=bool(json.get("disabled", None)), schedule=schedule, device_id=device.device_id, ) def to_json(self) -> dict: """Returns a JSON dict with this AccessCode's mutable properties. :meta private: """ json = { "friendlyName": self.name, "accessCode": int(self.code), "accessCodeLength": len(self.code), "notification": int(self.notify_on_use), "notificationEnabled": self.notify_on_use, "disabled": int(self.disabled), "activationSecs": _MIN_TIME, "expirationSecs": _MAX_TIME, "schedule1": RecurringSchedule().to_json(), } if self.access_code_id: json["accesscodeId"] = self.access_code_id if isinstance(self.schedule, RecurringSchedule): json["schedule1"] = self.schedule.to_json() elif self.schedule is not None: json.update(self.schedule.to_json()) return json def save(self): """Commits changes to the access code. :raise pyschlage.exceptions.NotAuthenticatedError: When the user is not authenticated. :raise pyschlage.exceptions.NotAuthorizedError: When authentication fails. :raise pyschlage.exceptions.UnknownError: On other errors. """ if not self._auth: raise NotAuthenticatedError assert self._device is not None command = "updateaccesscode" if self.access_code_id else "addaccesscode" resp = self._device.send_command(command, self.to_json()) # NOTE: We don't call self._update_with() here because the API only returns # the accesscodeId field. resp_json = resp.json() if "accesscodeId" in resp_json: self.access_code_id = resp_json["accesscodeId"] self.device_id = self._device.device_id if self._notification is None: self._notification = Notification( _auth=self._auth, notification_id=f"{self._auth.user_id}_{self.access_code_id}", user_id=self._auth.user_id, device_id=self.device_id, device_type=self._device.device_type, notification_type=ON_UNLOCK_ACTION, ) self._notification.filter_value = self.name self._notification.active = self.notify_on_use self._notification.save() def delete(self): """Deletes the access code. :raise pyschlage.exceptions.NotAuthenticatedError: When the user is not authenticated. :raise pyschlage.exceptions.NotAuthorizedError: When authentication fails. :raise pyschlage.exceptions.UnknownError: On other errors. """ if self._auth is None: raise NotAuthenticatedError assert self._device is not None self._device.send_command("deleteaccesscode", self.to_json()) if self._notification is not None: self._notification.delete() self._auth = None self._json = {} self._device = None self._notification = None self.access_code_id = None self.disabled = True pyschlage-2025.4.0/pyschlage/common.py000066400000000000000000000045551500120475100175500ustar00rootroot00000000000000"""Common utilities and classes.""" from __future__ import annotations from copy import deepcopy from dataclasses import dataclass, field, fields from datetime import UTC, datetime from threading import Lock as Mutex from time import mktime from typing import Any from .auth import Auth @dataclass class Mutable: """Base class for models which have mutable state.""" _mu: Mutex = field(init=False, repr=False, compare=False, default_factory=Mutex) _auth: Auth | None = field(default=None, repr=False) def __getstate__(self): state = self.__dict__.copy() del state["_mu"] return state def __setstate__(self, state): self.__dict__.update(state) self._mu = Mutex() def _update_with(self, json, *args, **kwargs): new_obj = self.__class__.from_json(self._auth, json, *args, **kwargs) with self._mu: for f in fields(new_obj): setattr(self, f.name, getattr(new_obj, f.name)) def utc2local(utc: datetime) -> datetime: """Converts a UTC datetime to localtime.""" epoch = mktime(utc.timetuple()) offset = datetime.fromtimestamp(epoch) - datetime.fromtimestamp(epoch, UTC).replace( tzinfo=None ) return (utc + offset).replace(tzinfo=None) def fromisoformat(dt: str) -> datetime: """Converts an ISO formatted datetime into a datetime object.""" # datetime.fromisoformat() doesn't like fractional seconds with a "Z" suffix. return datetime.fromisoformat(dt.rstrip("Z") + "+00:00") def redact(json: dict[Any, Any], *, allowed: list[str]) -> dict[str, Any]: """Returns a copy of the given JSON dict with non-allowed keys redacted.""" if len(allowed) == 1 and allowed[0] == "*": return deepcopy(json) allowed_here: dict[str, list[str]] = {} for allow in allowed: k, _, children = allow.partition(".") if k not in allowed_here: allowed_here[k] = [] if not children: children = "*" allowed_here[k].append(children) ret: dict[str, Any] = {} for k, v in json.items(): if isinstance(v, dict): ret[k] = redact(v, allowed=allowed_here.get(k, [])) elif k in allowed_here: ret[k] = v else: if isinstance(v, list): ret[k] = [""] else: ret[k] = "" return ret pyschlage-2025.4.0/pyschlage/device.py000066400000000000000000000034171500120475100175130ustar00rootroot00000000000000"""Schlage devices.""" from dataclasses import dataclass from enum import Enum from requests import Response from .common import Mutable from .exceptions import NotAuthenticatedError class DeviceType(str, Enum): """Known device types.""" BRIDGE = "br400" DENALI = "be489" DENALI_BLE = "be489ble" DENALI_MCKINLEY = "be489wb2" DENALI_MCKINLEY_BLE = "be489ble2" DENALI_MCKINLEY_WIFI = "be489wifi2" DENALI_WIFI = "be489wifi" ENCODE_LEVER = "fe789" ENCODE_LEVER_BLE = "fe789ble" ENCODE_LEVER_MCKINLEY = "fe789wb2" ENCODE_LEVER_MCKINLEY_BLE = "fe789ble2" ENCODE_LEVER_MCKINLEY_WIFI = "fe789wifi2" ENCODE_LEVER_WIFI = "fe789wifi" JACKALOPE = "be499" JACKALOPE_BLE = "be499ble" JACKALOPE_MCKINLEY = "be499wb2" JACKALOPE_MCKINLEY_BLE = "be499ble2" JACKALOPE_MCKINLEY_WIFI = "be499wifi2" JACKALOPE_WIFI = "be499wifi" SENSE = "be479" @dataclass class Device(Mutable): """Base class for Schlage devices.""" device_id: str = "" """Schlage-generated unique device identifier.""" device_type: str = "" """The device type of the lock. See |DeviceType| for currently known types. """ @staticmethod def request_path(device_id: str | None = None) -> str: """Returns the request path for a Lock. :meta private: """ path = "devices" if device_id: path = f"{path}/{device_id}" return path def send_command(self, command: str, data=dict) -> Response: """Sends a command to the device.""" if not self._auth: raise NotAuthenticatedError path = f"{self.request_path(self.device_id)}/commands" json = {"data": data, "name": command} return self._auth.request("post", path, json=json) pyschlage-2025.4.0/pyschlage/exceptions.py000066400000000000000000000005531500120475100204330ustar00rootroot00000000000000"""Exceptions used in pyschlage.""" class Error(Exception): """Base error class.""" class NotAuthenticatedError(Error): """Raised when a request is made to an unauthenticated object.""" class NotAuthorizedError(Error): """Raised when invalid credentials are used.""" class UnknownError(Error): """Raised when an unknown problem occurs.""" pyschlage-2025.4.0/pyschlage/lock.py000066400000000000000000000340151500120475100172020ustar00rootroot00000000000000"""Lock object used for Schlage WiFi devices.""" from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Iterable from .auth import Auth from .code import AccessCode from .common import redact from .device import Device from .exceptions import NotAuthenticatedError from .log import LockLog from .notification import ON_UNLOCK_ACTION, Notification from .user import User AUTO_LOCK_TIMES = (0, 5, 15, 30, 60, 120, 240, 300) @dataclass class LockStateMetadata: """Metadata about the current lock state.""" action_type: str """The action type that last changed the lock state.""" uuid: str | None = None """The UUID of the actor that changed the lock state.""" name: str | None = None """Human readable name of the access code that changed the lock state. If the lock state was not changed by an access code, this will be None. """ @classmethod def from_json(cls, json: dict) -> LockStateMetadata: """Creates a LockStateMetadata from a JSON object. :meta private: """ return cls(action_type=json["actionType"], uuid=json["UUID"], name=json["name"]) @dataclass class Lock(Device): """A Schlage WiFi lock.""" name: str = "" """User-specified name of the lock.""" model_name: str = "" """The model name of the lock.""" connected: bool = False """Whether the lock is connected to WiFi.""" battery_level: int | None = None """The remaining battery level of the lock. This is an integer between 0 and 100 or None if lock is unavailable. """ is_locked: bool | None = False """Whether the device is currently locked or None if lock is unavailable.""" is_jammed: bool | None = False """Whether the lock has identified itself as jammed. Returns None if lock is unavailable. """ lock_state_metadata: LockStateMetadata | None = None """Metadata about the current lock state.""" beeper_enabled: bool = False """Whether the keypress beep is enabled.""" lock_and_leave_enabled: bool = False """Whether lock-and-leave (a.k.a. "1-Touch Locking) feature is enabled.""" auto_lock_time: int = 0 """Time (in seconds) after which the lock will automatically lock itself.""" firmware_version: str | None = None """The firmware version installed on the lock or None if lock is unavailable.""" mac_address: str | None = None """The MAC address for the lock or None if lock is unavailable.""" users: dict[str, User] = field(default_factory=dict) """Users with access to this lock, keyed by their ID.""" access_codes: dict[str, AccessCode] | None = None """Access codes for this lock, keyed by their ID.""" _cat: str = field(default="", repr=False) _json: dict[Any, Any] = field(default_factory=dict, repr=False) @classmethod def from_json(cls, auth: Auth, json: dict) -> Lock: """Creates a Lock from a JSON object. :meta private: """ is_locked = is_jammed = None attributes = json["attributes"] if "lockState" in attributes: is_locked = attributes["lockState"] == 1 is_jammed = attributes["lockState"] == 2 lock_state_metadata = None if "lockStateMetadata" in attributes: lock_state_metadata = LockStateMetadata.from_json( attributes["lockStateMetadata"] ) users: dict[str, User] = {} for user_json in json.get("users", []): user = User.from_json(user_json) users[user.user_id] = user return cls( _auth=auth, device_id=json["deviceId"], name=json["name"], model_name=json.get("modelName", ""), device_type=json["devicetypeId"], connected=json.get("connected", False), battery_level=attributes.get("batteryLevel"), is_locked=is_locked, is_jammed=is_jammed, lock_state_metadata=lock_state_metadata, beeper_enabled=attributes.get("beeperEnabled") == 1, lock_and_leave_enabled=attributes.get("lockAndLeaveEnabled") == 1, auto_lock_time=attributes.get("autoLockTime", 0), firmware_version=attributes.get("mainFirmwareVersion"), mac_address=attributes.get("macAddress"), users=users, _cat=json.get("CAT", ""), _json=json, ) def get_diagnostics(self) -> dict[Any, Any]: """Returns a redacted dict of the raw JSON for diagnostics purposes.""" return redact( self._json, allowed=[ "attributes.accessCodeLength", "attributes.actAlarmBuzzerEnabled", "attributes.actAlarmState", "attributes.actuationCurrentMax", "attributes.alarmSelection", "attributes.alarmSensitivity", "attributes.alarmState", "attributes.autoLockTime", "attributes.batteryChangeDate", "attributes.batteryLevel", "attributes.batteryLowState", "attributes.batterySaverConfig", "attributes.batterySaverState", "attributes.beeperEnabled", "attributes.bleFirmwareVersion", "attributes.firmwareUpdate", "attributes.homePosCurrentMax", "attributes.keypadFirmwareVersion", "attributes.lockAndLeaveEnabled", "attributes.lockState", "attributes.lockStateMetadata", "attributes.mainFirmwareVersion", "attributes.mode", "attributes.modelName", "attributes.periodicDeepQueryTimeSetting", "attributes.psPollEnabled", "attributes.timezone", "attributes.wifiFirmwareVersion", "attributes.wifiRssi", "connected", "connectivityUpdated", "created", "devicetypeId", "lastUpdated", "modelName", "name", "role", "timezone", ], ) def _is_wifi_lock(self) -> bool: for prefix in ("be489", "be499", "fe789"): if self.device_type.startswith(prefix): return True return False def refresh(self) -> None: """Refreshes the Lock state. :raise pyschlage.exceptions.NotAuthorizedError: When authentication fails. :raise pyschlage.exceptions.UnknownError: On other errors. """ if not self._auth: raise NotAuthenticatedError path = self.request_path(self.device_id) self._update_with(self._auth.request("get", path).json()) self.refresh_access_codes() def _put_attributes(self, attributes): path = self.request_path(self.device_id) json = {"attributes": attributes} resp = self._auth.request("put", path, json=json) self._update_with(resp.json()) def _toggle(self, lock_state: int): if not self._auth: raise NotAuthenticatedError if self._is_wifi_lock(): self._put_attributes({"lockState": lock_state}) else: data = { "CAT": self._cat, "deviceId": self.device_id, "state": lock_state, "userId": self._auth.user_id, } self.send_command("changelockstate", data) self.is_locked = lock_state == 1 self.is_jammed = False def lock(self): """Locks the device. :raise pyschlage.exceptions.NotAuthorizedError: When authentication fails. :raise pyschlage.exceptions.UnknownError: On other errors. """ self._toggle(1) def unlock(self): """Unlocks the device. :raise pyschlage.exceptions.NotAuthorizedError: When authentication fails. :raise pyschlage.exceptions.UnknownError: On other errors. """ self._toggle(0) def last_changed_by( self, logs: list[LockLog] | None = None, ) -> str | None: """Determines the last entity or user that changed the lock state. :param logs: Unused. Kept for legacy reasons. :rtype: str """ _ = logs # For pylint if self.lock_state_metadata is None: return None user_suffix = "" uuid = self.lock_state_metadata.uuid if uuid is not None and (user := self.users.get(uuid)): user_suffix = f" - {user.name}" match self.lock_state_metadata.action_type: case "thumbTurn": return "thumbturn" case "1touchLocking": return "1-touch locking" case "accesscode": return f"keypad - {self.lock_state_metadata.name}" case "AppleHomeNFC": return f"apple nfc device{user_suffix}" case "virtualKey": return f"mobile device{user_suffix}" return "unknown" def keypad_disabled(self, logs: list[LockLog] | None = None) -> bool: """Returns True if the keypad is currently disabled. :param logs: Recent logs. If None, new logs will be fetched. :type logs: list[LockLog] or None :rtype: bool """ if logs is None: logs = self.logs() if not logs: return False newest_log = sorted(logs, reverse=True, key=lambda log: log.created_at)[0] return newest_log.message == "Keypad disabled invalid code" def logs(self, limit: int | None = None, sort_desc: bool = False) -> list[LockLog]: """Fetches activity logs for the lock. :param limit: The number of log entries to return. :type limit: int | None :param sort_desc: Whether to sort entries in descending order. :type sort_desc: bool (defaults to `False`) :rtype: list[pyschlage.log.LockLog] :raise pyschlage.exceptions.NotAuthorizedError: When authentication fails. :raise pyschlage.exceptions.UnknownError: On other errors. """ if not self._auth: raise NotAuthenticatedError path = LockLog.request_path(self.device_id) params: dict[str, Any] = {} if limit: params["limit"] = limit if sort_desc: params["sort"] = "desc" resp = self._auth.request("get", path, params=params) return [LockLog.from_json(lock_log) for lock_log in resp.json()] def refresh_access_codes(self) -> None: """Fetches access codes for this lock. :raise pyschlage.exceptions.NotAuthorizedError: When authentication fails. :raise pyschlage.exceptions.UnknownError: On other errors. """ self.access_codes = {} for code in self._get_access_codes(): assert code.access_code_id is not None self.access_codes[code.access_code_id] = code def _get_access_codes(self) -> Iterable[AccessCode]: if not self._auth: raise NotAuthenticatedError # Access Codes can be configured to notify the user on use via the app. # To make this work, a Notification must also be added, with its ID set # to "{user_id}_{access_code_id}". If notifications are disabled for # the access code, the Notification's |active| attribute is set to # False. In some cases, there may also just not be a Notification # added if notifications are disabled. notifications: dict[str, Notification] = {} user_id_len = len(self._auth.user_id) for notification in self._get_notifications(): if notification.notification_type == ON_UNLOCK_ACTION: if not notification.notification_id.startswith(self._auth.user_id): # This shouldn't happen, but ignore it just in case. continue # pragma: no cover access_code_id = notification.notification_id[user_id_len + 1 :] notifications[access_code_id] = notification path = AccessCode.request_path(self.device_id) resp = self._auth.request("get", path) for code_json in resp.json(): access_code = AccessCode.from_json(self._auth, self, code_json) access_code.device_id = self.device_id if access_code.access_code_id in notifications: access_code._notification = notification yield access_code def _get_notifications(self) -> Iterable[Notification]: if not self._auth: raise NotAuthenticatedError # pragma: no cover path = Notification.request_path() params = {"deviceId": self.device_id} resp = self._auth.request("get", path, params=params) for notification_json in resp.json(): notification = Notification.from_json(self._auth, notification_json) notification.device_type = self.device_type yield notification def add_access_code(self, code: AccessCode): """Adds an access code to the lock. :param code: The access code to add. :type code: pyschlage.code.AccessCode :raise pyschlage.exceptions.NotAuthorizedError: When authentication fails. :raise pyschlage.exceptions.UnknownError: On other errors. """ code._auth = self._auth code._device = self code.save() def set_beeper(self, enabled: bool): """Sets the beeper_enabled setting.""" self._put_attributes({"beeperEnabled": 1 if enabled else 0}) def set_lock_and_leave(self, enabled: bool): """Sets the lock_and_leave setting.""" self._put_attributes({"lockAndLeaveEnabled": 1 if enabled else 0}) def set_auto_lock_time(self, auto_lock_time: int): """Sets the auto_lock_time setting. Setting it to `0` turns off the auto-lock feature.""" if auto_lock_time not in AUTO_LOCK_TIMES: raise ValueError(f"auto_lock_time must be one of: {AUTO_LOCK_TIMES}") self._put_attributes({"autoLockTime": auto_lock_time}) pyschlage-2025.4.0/pyschlage/log.py000066400000000000000000000055121500120475100170330ustar00rootroot00000000000000"""Log entries for Schlage WiFi devices.""" from __future__ import annotations from dataclasses import dataclass from datetime import datetime from .common import fromisoformat, utc2local _DEFAULT_UUID = "ffffffff-ffff-ffff-ffff-ffffffffffff" LOG_EVENT_TYPES = { -1: "Unknown", 0: "Unknown", 1: "Locked by keypad", 2: "Unlocked by keypad", 3: "Locked by thumbturn", 4: "Unlocked by thumbturn", 5: "Locked by Schlage button", 6: "Locked by mobile device", 7: "Unlocked by mobile device", 8: "Locked by time", 9: "Unlocked by time", 10: "Lock jammed", 11: "Keypad disabled invalid code", 12: "Alarm triggered", 14: "Access code user added", 15: "Access code user deleted", 16: "Mobile user added", 17: "Mobile user deleted", 18: "Admin privilege added", 19: "Admin privilege deleted", 20: "Firmware updated", 21: "Low battery indicated", 22: "Batteries replaced", 23: "Forced entry alarm silenced", 27: "Hall sensor comm error", 28: "FDR failed", 29: "Critical battery state", 30: "All access code deleted", 32: "Firmware update failed", 33: "Bluetooth firmware download failed", 34: "WiFi firmware download failed", 35: "Keypad disconnected", 36: "WiFi AP disconnect", 37: "WiFi host disconnect", 38: "WiFi AP connect", 39: "WiFi host connect", 40: "User DB failure", 48: "Passage mode activated", 49: "Passage mode deactivated", 52: "Unlocked by Apple key", 53: "Locked by Apple key", 54: "Motor jammed on fail", 55: "Motor jammed off fail", 56: "Motor jammed retries exceeded", 255: "History cleared", } @dataclass class LockLog: """A lock log entry.""" created_at: datetime """The time at which the log entry was created.""" message: str """The human-readable message associated with the log entry.""" accessor_id: str | None = None """Unique identifier for the user that triggered the log entry.""" access_code_id: str | None = None """Unique identifier for the access code that triggered the log entry.""" @staticmethod def request_path(device_id: str) -> str: """Returns the request path for the LockLog. :meta private: """ return f"devices/{device_id}/logs" @classmethod def from_json(cls, json): """Creates a LockLog from a JSON object. :meta private: """ def none_if_default(attr): return None if attr == _DEFAULT_UUID else attr return cls( created_at=utc2local(fromisoformat(json["createdAt"])), accessor_id=none_if_default(json["message"]["accessorUuid"]), access_code_id=none_if_default(json["message"]["keypadUuid"]), message=LOG_EVENT_TYPES.get(json["message"]["eventCode"], "Unknown"), ) pyschlage-2025.4.0/pyschlage/notification.py000066400000000000000000000057701500120475100207460ustar00rootroot00000000000000"""Notifications for Schlage WiFi devices.""" from dataclasses import dataclass, field from datetime import datetime from typing import Any from .auth import Auth from .common import Mutable, fromisoformat from .exceptions import NotAuthenticatedError ON_ALARM = "onalarmstate" ON_BATTERY_LOW = "onbatterylowstate" ON_LOCKED = "onstatelocked" OFFLINE_24_HOURS = "offline24hours" ON_UNLOCK_ACTION = "onunlockstateaction" ON_UNLOCKED = "onstateunlocked" UNKNOWN = "__unknown__" @dataclass class Notification(Mutable): """A Schlage WiFi lock notification.""" notification_id: str = "" user_id: str | None = None device_id: str | None = None device_type: str | None = None notification_type: str = UNKNOWN active: bool = False filter_value: str | None = None created_at: datetime | None = None updated_at: datetime | None = None _json: dict[str, Any] = field(default_factory=dict, repr=False) @staticmethod def request_path(notification_id: str | None = None) -> str: """Returns the request path for the Notification. :meta private: """ path = "notifications" if notification_id is not None: path = f"{path}/{notification_id}" return path @classmethod def from_json(cls, auth: Auth, json: dict[str, Any]) -> "Notification": return Notification( _auth=auth, _json=json, notification_id=json["notificationId"], user_id=json["userId"], device_id=json["deviceId"], notification_type=json["notificationDefinitionId"], active=json["active"], filter_value=json.get("filterValue", None), created_at=fromisoformat(json["createdAt"]), updated_at=fromisoformat(json["updatedAt"]), ) def to_json(self) -> dict[str, Any]: """Returns a JSON dict with this Notification's mutable properties.""" json: dict[str, Any] = { "notificationId": self.notification_id, "userId": self.user_id, "deviceId": self.device_id, "devicetypeId": self.device_type, "notificationDefinitionId": self.notification_type, "active": self.active, } if self.filter_value is not None: json["filterValue"] = self.filter_value return json def save(self): """Saves the Notification.""" if not self._auth: raise NotAuthenticatedError method = "put" if self.created_at else "post" path = self.request_path(self.notification_id) resp = self._auth.request(method, path, self.to_json()) self._update_with(resp.json()) def delete(self): """Deletes the notification.""" if not self._auth: raise NotAuthenticatedError path = self.request_path(self.notification_id) self._auth.request("delete", path) self._auth = None self._json = {} self.notification_id = None self.active = False pyschlage-2025.4.0/pyschlage/py.typed000066400000000000000000000000001500120475100173620ustar00rootroot00000000000000pyschlage-2025.4.0/pyschlage/user.py000066400000000000000000000017411500120475100172300ustar00rootroot00000000000000"""Objects related to Schlage API users.""" from __future__ import annotations from dataclasses import dataclass, field @dataclass class User: """A Schlage API user account.""" name: str | None = None """The username associated with the account.""" email: str = "" """The email associated with the account.""" user_id: str = field(default="", repr=False) """Unique identifier for the user.""" @staticmethod def request_path(user_id: str | None = None) -> str: """Returns the request path for a User. :meta private: """ path = "users" if user_id: return f"{path}/{user_id}" # pragma: no cover return path @classmethod def from_json(cls, json) -> User: """Creates a User from a JSON dict. :meta private: """ return User( name=json.get("friendlyName"), email=json["email"], user_id=json["identityId"], ) pyschlage-2025.4.0/requirements-dev.txt000066400000000000000000000000221500120475100177500ustar00rootroot00000000000000pre-commit==4.2.0 pyschlage-2025.4.0/requirements-test.txt000066400000000000000000000001221500120475100201520ustar00rootroot00000000000000-r requirements.txt mypy==1.15.0 pytest==8.3.5 pytest-timeout==2.3.1 ruff==0.11.6 pyschlage-2025.4.0/requirements.txt000066400000000000000000000000451500120475100172010ustar00rootroot00000000000000pycognito==2024.5.1 requests==2.32.3 pyschlage-2025.4.0/scripts/000077500000000000000000000000001500120475100154055ustar00rootroot00000000000000pyschlage-2025.4.0/scripts/setup000077500000000000000000000005041500120475100164720ustar00rootroot00000000000000#!/bin/sh set -e cd "$(dirname "$0")/.." echo "Installing development dependencies..." python3 -m pip install --upgrade pip python3 -m pip install --upgrade wheel python3 -m pip install -r requirements.txt python3 -m pip install -r requirements-dev.txt python3 -m pip install -r requirements-test.txt pre-commit install pyschlage-2025.4.0/setup.cfg000066400000000000000000000000001500120475100155250ustar00rootroot00000000000000pyschlage-2025.4.0/tests/000077500000000000000000000000001500120475100150605ustar00rootroot00000000000000pyschlage-2025.4.0/tests/__init__.py000066400000000000000000000000001500120475100171570ustar00rootroot00000000000000pyschlage-2025.4.0/tests/conftest.py000066400000000000000000000174071500120475100172700ustar00rootroot00000000000000from __future__ import annotations from typing import Any from unittest.mock import Mock, create_autospec from pytest import fixture from pyschlage.auth import Auth from pyschlage.code import AccessCode from pyschlage.device import Device from pyschlage.lock import Lock from pyschlage.log import LockLog from pyschlage.notification import ON_UNLOCK_ACTION, Notification @fixture def mock_auth(): yield create_autospec(Auth, spec_set=True, user_id="") @fixture def lock_users_json() -> list[dict]: return [ { "consentRecords": [], "created": "2022-12-24T20:00:00.000Z", "email": "asdf@asdf.com", "friendlyName": "asdf", "identityId": "user-uuid", "role": "owner", "lastUpdated": "2022-12-24T20:00:00.000Z", }, { "consentRecords": [], "created": "2022-12-24T20:00:00.000Z", "email": "foo@bar.xyz", "friendlyName": "Foo Bar", "identityId": "foo-bar-uuid", "role": "guest", "lastUpdated": "2022-12-24T20:00:00.000Z", }, ] @fixture def wifi_lock_json(lock_users_json): return { "CAT": "01234", "SAT": "98765", "attributes": { "CAT": "01234", "SAT": "98765", "accessCodeLength": 4, "actAlarmBuzzerEnabled": 0, "actAlarmState": 0, "actuationCurrentMax": 226, "alarmSelection": 0, "alarmSensitivity": 0, "alarmState": 0, "autoLockTime": 0, "batteryChangeDate": 1669017530, "batteryLevel": 95, "batteryLowState": 0, "batterySaverConfig": { "activePeriod": [], "enabled": 0, }, "batterySaverState": 0, "beeperEnabled": 1, "bleFirmwareVersion": "0118.000103.015", "diagnostics": {}, "firmwareUpdate": { "status": {"additionalInfo": None, "updateStatus": None} }, "homePosCurrentMax": 153, "keypadFirmwareVersion": "03.00.00250052", "lockAndLeaveEnabled": 1, "lockState": 1, "lockStateMetadata": { "UUID": None, "actionType": "periodicDeepQuery", "clientId": None, "name": None, }, "macAddress": "AA:BB:CC:00:11:22", "mainFirmwareVersion": "10.00.00264232", "mode": 2, "modelName": "__model_name__", "periodicDeepQueryTimeSetting": 60, "psPollEnabled": 1, "serialNumber": "d34db33f", "timezone": -20, "wifiFirmwareVersion": "03.15.00.01", "wifiRssi": -42, }, "connected": True, "connectivityUpdated": "2022-12-04T20:58:22.000Z", "created": "2020-04-05T21:53:11.000Z", "deviceId": "__wifi_uuid__", "devicetypeId": "be489wifi", "lastUpdated": "2022-12-04T20:58:22.000Z", "macAddress": "AA:BB:CC:00:11:22", "modelName": "__model_name__", "name": "Door Lock", "physicalId": "serial-number", "relatedDevices": [], "role": "owner", "serialNumber": "serial-number", "timezone": -20, "users": lock_users_json, } @fixture def wifi_lock(mock_auth: Mock, wifi_lock_json: dict, access_code: AccessCode) -> Lock: lock = Lock.from_json(mock_auth, wifi_lock_json) assert access_code.access_code_id is not None lock.access_codes = {access_code.access_code_id: access_code} return lock @fixture def wifi_device(mock_auth: Mock, wifi_lock_json: dict) -> Device: return Device( _auth=mock_auth, device_id=wifi_lock_json["deviceId"], device_type=wifi_lock_json["devicetypeId"], ) @fixture def wifi_lock_unavailable_json(wifi_lock_json): keep = ("modelName", "serialNumber", "macAddress", "SAT", "CAT") for k in list(wifi_lock_json["attributes"].keys()): if k not in keep: del wifi_lock_json["attributes"][k] return wifi_lock_json @fixture def lock_json(wifi_lock_json): return wifi_lock_json @fixture def ble_lock_json(lock_users_json): return { "CAT": "abcdef", "SAT": "ghijkl", "attributes": { "CAT": "abcdef", "SAT": "ghijkl", "accessCodeLength": 4, "adminOnlyEnabled": 0, "alarmSelection": 0, "alarmSensitivity": 0, "alarmState": 0, "autoLockTime": 240, "batteryLevel": 66, "batteryLowState": 0, "beeperEnabled": 1, "hardwareVersion": "1.3.0", "lastTalkedTime": "2022-12-20T22:46:11Z", "lockAndLeaveEnabled": 1, "lockState": 1, "macAddress": "EA:10:CA:87:19:F6", "mainFirmwareVersion": "004.031.000", "manufacturerName": "Schlage ", "modelName": "BE479CEN619", "name": "BLE Lock", "profileVersion": "1.1", "serialNumber": "", "timezone": -60, }, "connected": False, "connectivityUpdated": "2022-12-20T23:02:35.000Z", "created": "2021-03-03T20:19:18.000Z", "deviceId": "__ble_uuid__", "devicetypeId": "be479", "lastUpdated": "2022-12-20T23:02:35.000Z", "macAddress": "EA:10:CA:87:19:F6", "modelName": "BE479CEN619", "name": "BLE Lock", "physicalId": "ea:10:ca:87:19:f6", "relatedDevices": [{"deviceId": "__bridge_uuid__"}], "role": "owner", "serialNumber": "", "timezone": -60, "users": lock_users_json, } @fixture def access_code_json(): return { "accessCode": 123, "accesscodeId": "__access_code_uuid__", "accessCodeLength": 4, "activationSecs": 0, "disabled": 0, "expirationSecs": 4294967295, "friendlyName": "Friendly name", "notification": 0, "notificationEnabled": False, "schedule1": { "daysOfWeek": "7F", "endHour": 23, "endMinute": 59, "startHour": 0, "startMinute": 0, }, } @fixture def access_code( mock_auth: Mock, wifi_device: Device, access_code_json: dict ) -> AccessCode: return AccessCode.from_json(mock_auth, wifi_device, access_code_json) @fixture def notification_json() -> dict[str, Any]: return { "notificationId": "___access_code_uuid__", "userId": "", "deviceId": "__wifi_uuid__", "devicetypeId": "be489wifi", "notificationDefinitionId": ON_UNLOCK_ACTION, "filterValue": "Friendly name", "active": True, "createdAt": "2023-03-01T17:26:47.366Z", "updatedAt": "2023-03-01T17:26:47.366Z", } @fixture def notification(mock_auth: Auth, notification_json) -> Notification: return Notification.from_json(mock_auth, notification_json) @fixture def log_json() -> dict[str, Any]: return { "createdAt": "2023-03-01T17:26:47.366Z", "deviceId": "__device_uuid__", "logId": "__log_uuid__", "message": { "accessorUuid": "ffffffff-ffff-ffff-ffff-ffffffffffff", "action": 2, "clientId": None, "eventCode": 9999, "keypadUuid": "ffffffff-ffff-ffff-ffff-ffffffffffff", "secondsSinceEpoch": 1677691601, }, "timestamp": "2023-03-01T17:26:41.001Z", "ttl": "2023-03-31T17:26:47.000Z", "type": "DEVICE_LOG", "updatedAt": "2023-03-01T17:26:47.366Z", } @fixture def lock_log(log_json: dict[str, Any]) -> LockLog: return LockLog.from_json(log_json) pyschlage-2025.4.0/tests/test_api.py000066400000000000000000000024001500120475100172360ustar00rootroot00000000000000from __future__ import annotations from typing import Any from unittest import mock from pyschlage import api def test_locks( mock_auth: mock.Mock, lock_json: dict[str, Any], access_code_json: dict[str, Any], notification_json: dict[str, Any], ) -> None: schlage = api.Schlage(mock_auth) mock_auth.request.side_effect = [ mock.Mock(json=mock.Mock(return_value=[lock_json])), mock.Mock(json=mock.Mock(return_value=[notification_json])), mock.Mock(json=mock.Mock(return_value=[access_code_json])), ] locks = schlage.locks() assert len(locks) == 1 mock_auth.request.assert_has_calls( [ mock.call("get", "devices", params={"archetype": "lock"}), mock.call( "get", "notifications", params={"deviceId": lock_json["deviceId"]} ), mock.call("get", "devices/__wifi_uuid__/storage/accesscode"), ] ) def test_users(mock_auth: mock.Mock, lock_users_json: list[dict]) -> None: schlage = api.Schlage(mock_auth) mock_auth.request.return_value = mock.Mock( json=mock.Mock(return_value=lock_users_json) ) users = schlage.users() assert len(users) == 2 mock_auth.request.assert_called_once_with("get", "users") pyschlage-2025.4.0/tests/test_auth.py000066400000000000000000000117431500120475100174400ustar00rootroot00000000000000from unittest import mock from botocore.exceptions import ClientError import pytest import requests import pyschlage from pyschlage import auth as _auth @mock.patch("requests.Request") @mock.patch("pycognito.utils.RequestsSrpAuth") @mock.patch("pycognito.Cognito") def test_authenticate(mock_cognito, mock_srp_auth, mock_request): auth = _auth.Auth("__username__", "__password__") mock_cognito.assert_called_once() assert mock_cognito.call_args.kwargs["username"] == "__username__" mock_srp_auth.assert_called_once_with( password="__password__", cognito=mock_cognito.return_value ) auth.authenticate() mock_srp_auth.return_value.assert_called_once_with(mock_request.return_value) @mock.patch("requests.request") @mock.patch("pycognito.utils.RequestsSrpAuth") @mock.patch("pycognito.Cognito") def test_request(mock_cognito, mock_srp_auth, mock_request): auth = _auth.Auth("__username__", "__password__") auth.request("get", "/foo/bar", baz="bam") mock_request.assert_called_once_with( "get", "https://api.allegion.yonomi.cloud/v1/foo/bar", timeout=60, auth=mock_srp_auth.return_value, headers={"X-Api-Key": _auth.API_KEY}, baz="bam", ) @mock.patch("requests.request", spec=True) @mock.patch("pycognito.utils.RequestsSrpAuth", spec=True) @mock.patch("pycognito.Cognito") def test_request_not_authorized(mock_cognito, mock_srp_auth, mock_request): url = "https://api.allegion.yonomi.cloud/v1/foo/bar" auth = _auth.Auth("__username__", "__password__") mock_request.side_effect = ClientError( { "Error": { "Code": "NotAuthorizedException", "Message": f"Unauthorized for url: {url}", } }, "foo-op", ) with pytest.raises( pyschlage.exceptions.NotAuthorizedError, match=f"Unauthorized for url: {url}" ): auth.request("get", "/foo/bar", baz="bam") mock_request.assert_called_once_with( "get", url, timeout=60, auth=mock_srp_auth.return_value, headers={"X-Api-Key": _auth.API_KEY}, baz="bam", ) @mock.patch("requests.request", spec=True) @mock.patch("pycognito.utils.RequestsSrpAuth", spec=True) @mock.patch("pycognito.Cognito") def test_request_unknown_error(mock_cognito, mock_srp_auth, mock_request): url = "https://api.allegion.yonomi.cloud/v1/foo/bar" auth = _auth.Auth("__username__", "__password__") mock_resp = mock.create_autospec(requests.Response) mock_resp.raise_for_status.side_effect = requests.HTTPError( f"500 Server Error: Internal for url: {url}" ) mock_resp.status_code = 500 mock_resp.reason = "Internal" mock_resp.json.side_effect = requests.JSONDecodeError("msg", "doc", 1) mock_request.return_value = mock_resp with pytest.raises(pyschlage.exceptions.UnknownError): auth.request("get", "/foo/bar", baz="bam") mock_request.assert_called_once_with( "get", url, timeout=60, auth=mock_srp_auth.return_value, headers={"X-Api-Key": _auth.API_KEY}, baz="bam", ) @mock.patch("requests.request") @mock.patch("pycognito.utils.RequestsSrpAuth") @mock.patch("pycognito.Cognito") def test_user_id(mock_cognito, mock_srp_auth, mock_request): auth = _auth.Auth("__username__", "__password__") mock_request.return_value = mock.Mock( json=mock.Mock( return_value={ "consentRecords": [], "created": "2022-12-24T20:00:00.000Z", "email": "asdf@asdf.com", "friendlyName": "username", "identityId": "", "lastUpdated": "2022-12-24T20:00:00.000Z", } ) ) assert auth.user_id == "" mock_request.assert_called_once_with( "get", "https://api.allegion.yonomi.cloud/v1/users/@me", timeout=60, auth=mock_srp_auth.return_value, headers={"X-Api-Key": _auth.API_KEY}, ) @mock.patch("requests.request") @mock.patch("pycognito.utils.RequestsSrpAuth") @mock.patch("pycognito.Cognito") def test_user_id_is_cached(mock_cognito, mock_srp_auth, mock_request): auth = _auth.Auth("__username__", "__password__") mock_request.return_value = mock.Mock( json=mock.Mock( return_value={ "consentRecords": [], "created": "2022-12-24T20:00:00.000Z", "email": "asdf@asdf.com", "friendlyName": "username", "identityId": "", "lastUpdated": "2022-12-24T20:00:00.000Z", } ) ) assert auth.user_id == "" mock_request.assert_called_once_with( "get", "https://api.allegion.yonomi.cloud/v1/users/@me", timeout=60, auth=mock_srp_auth.return_value, headers={"X-Api-Key": _auth.API_KEY}, ) mock_request.reset_mock() assert auth.user_id == "" mock_request.assert_not_called() pyschlage-2025.4.0/tests/test_code.py000066400000000000000000000111451500120475100174050ustar00rootroot00000000000000from copy import deepcopy from datetime import datetime from typing import Any from unittest.mock import Mock, create_autospec, patch import pytest from pyschlage.code import AccessCode, DaysOfWeek, RecurringSchedule, TemporarySchedule from pyschlage.device import Device from pyschlage.exceptions import NotAuthenticatedError from pyschlage.notification import Notification class TestAccessCode: def test_to_from_json( self, mock_auth: Mock, access_code_json: dict[str, Any], wifi_device: Device ): access_code_id = "__access_code_uuid__" code = AccessCode( _auth=mock_auth, _device=wifi_device, _json=access_code_json, name="Friendly name", code="0123", schedule=None, device_id=wifi_device.device_id, access_code_id=access_code_id, ) assert AccessCode.from_json(mock_auth, wifi_device, access_code_json) == code assert code.to_json() == access_code_json def test_to_from_json_recurring_schedule( self, mock_auth: Mock, access_code_json: dict[str, Any], wifi_device: Device ): assert RecurringSchedule.from_json({}) is None assert RecurringSchedule.from_json(None) is None access_code_id = "__access_code_uuid__" sched = RecurringSchedule(days_of_week=DaysOfWeek(mon=False)) json = deepcopy(access_code_json) json["schedule1"] = sched.to_json() code = AccessCode( _auth=mock_auth, _json=json, _device=wifi_device, name="Friendly name", code="0123", schedule=sched, device_id=wifi_device.device_id, access_code_id=access_code_id, ) assert AccessCode.from_json(mock_auth, wifi_device, json) == code assert code.to_json() == json def test_to_from_json_temporary_schedule( self, mock_auth: Mock, access_code_json: dict[str, Any], wifi_device: Device ): access_code_id = "__access_code_uuid__" sched = TemporarySchedule( start=datetime(2022, 12, 25, 8, 30, 0), end=datetime(2022, 12, 25, 9, 0, 0), ) json = deepcopy(access_code_json) json["activationSecs"] = 1671957000 json["expirationSecs"] = 1671958800 code = AccessCode( _auth=mock_auth, _json=json, _device=wifi_device, name="Friendly name", code="0123", schedule=sched, device_id=wifi_device.device_id, access_code_id=access_code_id, ) assert AccessCode.from_json(mock_auth, wifi_device, json) == code assert code.to_json() == json def test_save( self, mock_auth: Mock, access_code_json: dict[str, Any], ): with pytest.raises(NotAuthenticatedError): AccessCode().save() mock_device = create_autospec(Device, spec_set=True, device_id="__wifi_uuid__") code = AccessCode.from_json(mock_auth, mock_device, access_code_json) code.code = "1122" old_json = code.to_json() new_json = {"accesscodeId": "2211"} with patch( "pyschlage.code.Notification", autospec=True ) as mock_notification_cls: mock_notification = create_autospec(Notification, spec_set=True) mock_notification_cls.return_value = mock_notification mock_device.send_command.return_value = Mock( json=Mock(return_value=new_json) ) code.save() mock_notification.save.assert_called_once_with() mock_device.send_command.assert_called_once_with( "updateaccesscode", old_json ) assert code.code == "1122" assert code.access_code_id == "2211" def test_delete(self, mock_auth: Mock, access_code_json: dict[str, Any]): with pytest.raises(NotAuthenticatedError): AccessCode().delete() mock_device = create_autospec(Device, spec_set=True, device_id="__wifi_uuid__") code = AccessCode.from_json(mock_auth, mock_device, access_code_json) mock_notification = create_autospec(Notification, spec_set=True) code._notification = mock_notification mock_auth.request.return_value = Mock() json = code.to_json() code.delete() mock_device.send_command.assert_called_once_with("deleteaccesscode", json) mock_notification.delete.assert_called_once_with() assert code._auth is None assert code._json == {} assert code.access_code_id is None assert code.disabled pyschlage-2025.4.0/tests/test_common.py000066400000000000000000000035761500120475100177740ustar00rootroot00000000000000from __future__ import annotations from pickle import dumps, loads from typing import Any import pytest from pyschlage import common def test_pickle_unpickle() -> None: mut = common.Mutable() mut2 = loads(dumps(mut)) assert mut2._mu is not None assert mut2._mu != mut._mu assert mut2._auth == mut._auth @pytest.fixture def json_dict() -> dict[Any, Any]: return { "a": "foo", "b": 1, "c": { "c0": "foo", "c1": 1, "c2": { "c20": "foo", }, "c3": ["foo"], }, "d": ["foo"], } def test_redact_allow_asterisk(json_dict: dict[Any, Any]): assert common.redact(json_dict, allowed=["*"]) == json_dict def test_redact_allow_all(json_dict: dict[Any, Any]): assert common.redact(json_dict, allowed=["a", "b", "c.*", "d"]) == json_dict assert ( common.redact( json_dict, allowed=["a", "b", "c.c0", "c.c1", "c.c2", "c.c3", "d"] ) == json_dict ) assert common.redact(json_dict, allowed=["a", "b", "c", "d"]) == json_dict def test_redact_all(json_dict: dict[Any, Any]): want = { "a": "", "b": "", "c": { "c0": "", "c1": "", "c2": { "c20": "", }, "c3": [""], }, "d": [""], } assert common.redact(json_dict, allowed=[]) == want def test_redact_partial(json_dict: dict[Any, Any]): want = { "a": "foo", "b": 1, "c": { "c0": "foo", "c1": "", "c2": { "c20": "", }, "c3": [""], }, "d": [""], } assert common.redact(json_dict, allowed=["a", "b", "c.c0"]) == want pyschlage-2025.4.0/tests/test_lock.py000066400000000000000000000440701500120475100174260ustar00rootroot00000000000000from __future__ import annotations from copy import deepcopy from datetime import datetime from typing import Any from unittest.mock import Mock, call, patch import pytest from pyschlage.code import AccessCode from pyschlage.exceptions import NotAuthenticatedError from pyschlage.lock import Lock from pyschlage.log import LockLog from pyschlage.notification import Notification from pyschlage.user import User class TestLock: def test_from_json(self, mock_auth, lock_json): lock = Lock.from_json(mock_auth, lock_json) assert lock._auth == mock_auth assert lock.device_id == "__wifi_uuid__" assert lock.name == "Door Lock" assert lock.model_name == "__model_name__" assert lock.device_type == "be489wifi" assert lock.connected is True assert lock.battery_level == 95 assert lock.is_locked assert lock._cat == "01234" assert lock.is_jammed is False assert lock.beeper_enabled is True assert lock.lock_and_leave_enabled is True assert lock.auto_lock_time == 0 assert lock.firmware_version == "10.00.00264232" assert lock.mac_address == "AA:BB:CC:00:11:22" assert lock.users == { "user-uuid": User("asdf", "asdf@asdf.com", "user-uuid"), "foo-bar-uuid": User("Foo Bar", "foo@bar.xyz", "foo-bar-uuid"), } def test_from_json_cat_optional( self, mock_auth: Mock, lock_json: dict[Any, Any] ) -> None: lock_json.pop("CAT", None) lock = Lock.from_json(mock_auth, lock_json) assert lock._cat == "" def test_from_json_no_connected( self, mock_auth: Mock, lock_json: dict[Any, Any] ) -> None: lock_json.pop("connected") lock = Lock.from_json(mock_auth, lock_json) assert not lock.connected def test_from_json_is_jammed(self, mock_auth, lock_json): lock_json["attributes"]["lockState"] = 2 lock = Lock.from_json(mock_auth, lock_json) assert lock.is_locked is False assert lock.is_jammed def test_from_json_wifi_lock_unavailable( self, mock_auth, wifi_lock_unavailable_json ): lock = Lock.from_json(mock_auth, wifi_lock_unavailable_json) assert lock.battery_level is None assert lock.firmware_version is None assert lock.is_locked is None assert lock.is_jammed is None def test_from_json_no_model_name( self, mock_auth: Mock, lock_json: dict[Any, Any] ) -> None: lock_json.pop("modelName", None) lock = Lock.from_json(mock_auth, lock_json) assert lock.model_name == "" def test_diagnostics(self, mock_auth: Mock, lock_json: dict) -> None: lock = Lock.from_json(mock_auth, lock_json) want = { "CAT": "", "SAT": "", "attributes": { "CAT": "", "SAT": "", "accessCodeLength": 4, "actAlarmBuzzerEnabled": 0, "actAlarmState": 0, "actuationCurrentMax": 226, "alarmSelection": 0, "alarmSensitivity": 0, "alarmState": 0, "autoLockTime": 0, "batteryChangeDate": 1669017530, "batteryLevel": 95, "batteryLowState": 0, "batterySaverConfig": {"activePeriod": [], "enabled": 0}, "batterySaverState": 0, "beeperEnabled": 1, "bleFirmwareVersion": "0118.000103.015", "diagnostics": {}, "firmwareUpdate": { "status": {"additionalInfo": None, "updateStatus": None} }, "homePosCurrentMax": 153, "keypadFirmwareVersion": "03.00.00250052", "lockAndLeaveEnabled": 1, "lockState": 1, "lockStateMetadata": { "UUID": None, "actionType": "periodicDeepQuery", "clientId": None, "name": None, }, "macAddress": "", "mainFirmwareVersion": "10.00.00264232", "mode": 2, "modelName": "__model_name__", "periodicDeepQueryTimeSetting": 60, "psPollEnabled": 1, "serialNumber": "", "timezone": -20, "wifiFirmwareVersion": "03.15.00.01", "wifiRssi": -42, }, "connected": True, "connectivityUpdated": "2022-12-04T20:58:22.000Z", "created": "2020-04-05T21:53:11.000Z", "deviceId": "", "devicetypeId": "be489wifi", "lastUpdated": "2022-12-04T20:58:22.000Z", "macAddress": "", "modelName": "__model_name__", "name": "Door Lock", "physicalId": "", "relatedDevices": [""], "role": "owner", "serialNumber": "", "timezone": -20, "users": [""], } assert lock.get_diagnostics() == want def test_refresh( self, mock_auth: Mock, lock_json: dict[str, Any], access_code_json: dict[str, Any], notification_json: dict[str, Any], ) -> None: with pytest.raises(NotAuthenticatedError): Lock().refresh() lock = Lock.from_json(mock_auth, lock_json) lock_json["name"] = "" mock_auth.request.side_effect = [ Mock(json=Mock(return_value=lock_json)), Mock(json=Mock(return_value=[notification_json])), Mock(json=Mock(return_value=[access_code_json])), ] lock.refresh() mock_auth.request.assert_has_calls( [ call("get", "devices/__wifi_uuid__"), call( "get", "notifications", params={"deviceId": lock_json["deviceId"]} ), call("get", "devices/__wifi_uuid__/storage/accesscode"), ] ) assert lock.name == "" def test_send_command_unauthenticated(self): with pytest.raises(NotAuthenticatedError): Lock().send_command("foo", data={"bar": "baz"}) def test_lock_wifi(self, mock_auth, wifi_lock_json): initial_json = deepcopy(wifi_lock_json) initial_json["attributes"]["lockState"] = 0 lock = Lock.from_json(mock_auth, initial_json) new_json = deepcopy(wifi_lock_json) new_json["attributes"]["lockState"] = 1 mock_auth.request.return_value = Mock(json=Mock(return_value=new_json)) lock.lock() mock_auth.request.assert_called_once_with( "put", "devices/__wifi_uuid__", json={"attributes": {"lockState": 1}} ) assert lock.is_locked def test_unlock_wifi(self, mock_auth, wifi_lock_json): initial_json = deepcopy(wifi_lock_json) initial_json["attributes"]["lockState"] = 1 lock = Lock.from_json(mock_auth, initial_json) new_json = deepcopy(wifi_lock_json) new_json["attributes"]["lockState"] = 0 mock_auth.request.return_value = Mock(json=Mock(return_value=new_json)) lock.unlock() mock_auth.request.assert_called_once_with( "put", "devices/__wifi_uuid__", json={"attributes": {"lockState": 0}} ) assert not lock.is_locked def test_lock_ble(self, mock_auth, ble_lock_json): with pytest.raises(NotAuthenticatedError): Lock().lock() lock = Lock.from_json(mock_auth, ble_lock_json) lock.lock() command_json = { "data": { "CAT": "abcdef", "deviceId": "__ble_uuid__", "state": 1, "userId": "", }, "name": "changelockstate", } mock_auth.request.assert_called_once_with( "post", "devices/__ble_uuid__/commands", json=command_json ) assert lock.is_locked def test_unlock_ble(self, mock_auth, ble_lock_json): with pytest.raises(NotAuthenticatedError): Lock().unlock() lock = Lock.from_json(mock_auth, ble_lock_json) lock.unlock() command_json = { "data": { "CAT": "abcdef", "deviceId": "__ble_uuid__", "state": 0, "userId": "", }, "name": "changelockstate", } mock_auth.request.assert_called_once_with( "post", "devices/__ble_uuid__/commands", json=command_json ) assert not lock.is_locked def test_logs( self, mock_auth: Mock, wifi_lock: Lock, log_json: dict[str, Any], lock_log: LockLog, ): with pytest.raises(NotAuthenticatedError): Lock().logs() mock_auth.request.return_value = Mock(json=Mock(return_value=[log_json])) assert wifi_lock.logs(limit=10, sort_desc=True) == [lock_log] mock_auth.request.assert_called_once_with( "get", "devices/__wifi_uuid__/logs", params={"limit": 10, "sort": "desc"} ) mock_auth.reset_mock() mock_auth.request.return_value = Mock(json=Mock(return_value=[log_json])) assert wifi_lock.logs() == [lock_log] mock_auth.request.assert_called_once_with( "get", "devices/__wifi_uuid__/logs", params={} ) def test_refresh_access_codes( self, mock_auth: Mock, lock_json: dict[str, Any], access_code_json: dict[str, Any], notification_json: dict[str, Any], notification: Notification, ) -> None: with pytest.raises(NotAuthenticatedError): Lock().refresh_access_codes() lock = Lock.from_json(mock_auth, lock_json) mock_auth.request.side_effect = [ Mock(json=Mock(return_value=[notification_json])), Mock(json=Mock(return_value=[access_code_json])), ] lock.refresh_access_codes() mock_auth.request.assert_has_calls( [ call("get", "notifications", params={"deviceId": lock.device_id}), call("get", "devices/__wifi_uuid__/storage/accesscode"), ] ) notification.device_type = lock.device_type want_code = AccessCode.from_json(mock_auth, lock, access_code_json) want_code.device_id = lock.device_id want_code._notification = notification assert lock.access_codes == { access_code_json["accesscodeId"]: want_code, } def test_add_access_code( self, mock_auth: Mock, lock_json: dict[str, Any], access_code_json: dict[str, Any], notification_json: dict[str, Any], ): lock = Lock.from_json(mock_auth, lock_json) code = AccessCode.from_json(mock_auth, lock, access_code_json) # Users should not set these. code._auth = None code._device = None code.access_code_id = None code.device_id = None json = code.to_json() notification_json["active"] = False mock_auth.request.side_effect = [ Mock(json=Mock(return_value=access_code_json)), Mock(json=Mock(return_value=notification_json)), ] lock.add_access_code(code) del notification_json["createdAt"] del notification_json["updatedAt"] mock_auth.request.assert_has_calls( [ call( "post", "devices/__wifi_uuid__/commands", json={"data": json, "name": "addaccesscode"}, ), call( "post", "notifications/___access_code_uuid__", notification_json, ), ] ) assert code._auth == mock_auth assert code.device_id == lock.device_id assert code.access_code_id == "__access_code_uuid__" def test_set_beeper( self, mock_auth: Mock, wifi_lock_json: dict[str, Any], wifi_lock: Lock ) -> None: assert wifi_lock.beeper_enabled wifi_lock_json["attributes"]["beeperEnabled"] = 0 mock_auth.request.return_value = Mock(json=Mock(return_value=wifi_lock_json)) wifi_lock.set_beeper(False) mock_auth.request.assert_called_once_with( "put", "devices/__wifi_uuid__", json={"attributes": {"beeperEnabled": 0}} ) assert not wifi_lock.beeper_enabled def test_set_lock_and_leave( self, mock_auth: Mock, wifi_lock_json: dict[str, Any], wifi_lock: Lock ) -> None: assert wifi_lock.lock_and_leave_enabled wifi_lock_json["attributes"]["lockAndLeaveEnabled"] = 0 mock_auth.request.return_value = Mock(json=Mock(return_value=wifi_lock_json)) wifi_lock.set_lock_and_leave(False) mock_auth.request.assert_called_once_with( "put", "devices/__wifi_uuid__", json={"attributes": {"lockAndLeaveEnabled": 0}}, ) assert not wifi_lock.lock_and_leave_enabled def test_set_auto_lock_time( self, mock_auth: Mock, wifi_lock_json: dict[str, Any], wifi_lock: Lock ) -> None: with pytest.raises(ValueError): wifi_lock.set_auto_lock_time(1) assert wifi_lock.auto_lock_time == 0 wifi_lock_json["attributes"]["autoLockTime"] = 15 mock_auth.request.return_value = Mock(json=Mock(return_value=wifi_lock_json)) wifi_lock.set_auto_lock_time(15) mock_auth.request.assert_called_once_with( "put", "devices/__wifi_uuid__", json={"attributes": {"autoLockTime": 15}}, ) assert wifi_lock.auto_lock_time == 15 class TestKeypadDisabled: def test_true(self, wifi_lock: Lock) -> None: logs = [ LockLog( created_at=datetime(2023, 1, 1, 0, 0, 0), message="Unlocked by keypad", ), LockLog( created_at=datetime(2023, 1, 1, 1, 0, 0), message="Keypad disabled invalid code", ), ] assert wifi_lock.keypad_disabled(logs) is True def test_true_unsorted(self, wifi_lock: Lock) -> None: logs = [ LockLog( created_at=datetime(2023, 1, 1, 1, 0, 0), message="Keypad disabled invalid code", ), LockLog( created_at=datetime(2023, 1, 1, 0, 0, 0), message="Unlocked by keypad", ), ] assert wifi_lock.keypad_disabled(logs) is True def test_false(self, wifi_lock: Lock) -> None: logs = [ LockLog( created_at=datetime(2023, 1, 1, 0, 0, 0), message="Keypad disabled invalid code", ), LockLog( created_at=datetime(2023, 1, 1, 1, 0, 0), message="Unlocked by keypad", ), ] assert wifi_lock.keypad_disabled(logs) is False def test_fetches_logs(self, wifi_lock: Mock) -> None: with patch.object(wifi_lock, "logs") as logs_mock: logs_mock.return_value = [ LockLog( created_at=datetime(2023, 1, 1, 0, 0, 0), message="Unlocked by keypad", ), LockLog( created_at=datetime(2023, 1, 1, 1, 0, 0), message="Keypad disabled invalid code", ), ] assert wifi_lock.keypad_disabled() is True wifi_lock.logs.assert_called_once_with() def test_fetches_logs_no_logs(self, wifi_lock: Lock) -> None: with patch.object(wifi_lock, "logs") as logs_mock: logs_mock.return_value = [] assert wifi_lock.keypad_disabled() is False logs_mock.assert_called_once_with() class TestChangedBy: def test_thumbturn(self, wifi_lock: Lock) -> None: assert wifi_lock.lock_state_metadata is not None wifi_lock.lock_state_metadata.action_type = "thumbTurn" assert wifi_lock.last_changed_by() == "thumbturn" def test_one_touch_locking(self, wifi_lock: Lock) -> None: assert wifi_lock.lock_state_metadata is not None wifi_lock.lock_state_metadata.action_type = "1touchLocking" assert wifi_lock.last_changed_by() == "1-touch locking" def test_nfc_device(self, wifi_lock: Lock) -> None: assert wifi_lock.lock_state_metadata is not None wifi_lock.lock_state_metadata.action_type = "AppleHomeNFC" wifi_lock.lock_state_metadata.uuid = "user-uuid" assert wifi_lock.last_changed_by() == "apple nfc device - asdf" def test_nfc_device_no_uuid(self, wifi_lock: Lock) -> None: assert wifi_lock.lock_state_metadata is not None wifi_lock.lock_state_metadata.action_type = "AppleHomeNFC" wifi_lock.lock_state_metadata.uuid = None assert wifi_lock.last_changed_by() == "apple nfc device" def test_keypad(self, wifi_lock: Lock) -> None: assert wifi_lock.lock_state_metadata is not None wifi_lock.lock_state_metadata.action_type = "accesscode" wifi_lock.lock_state_metadata.name = "secret code" assert wifi_lock.last_changed_by() == "keypad - secret code" def test_mobile_device(self, wifi_lock: Lock) -> None: assert wifi_lock.lock_state_metadata is not None wifi_lock.lock_state_metadata.action_type = "virtualKey" wifi_lock.lock_state_metadata.uuid = "user-uuid" assert wifi_lock.last_changed_by() == "mobile device - asdf" wifi_lock.lock_state_metadata.uuid = "unknown" assert wifi_lock.last_changed_by() == "mobile device" wifi_lock.lock_state_metadata.uuid = None assert wifi_lock.last_changed_by() == "mobile device" def test_unknown(self, wifi_lock: Lock) -> None: assert wifi_lock.last_changed_by() == "unknown" def test_no_metadata(self, wifi_lock: Lock) -> None: wifi_lock.lock_state_metadata = None assert wifi_lock.last_changed_by() is None pyschlage-2025.4.0/tests/test_log.py000066400000000000000000000027121500120475100172540ustar00rootroot00000000000000from datetime import datetime from pyschlage.log import LockLog _DEFAULT_UUID = "ffffffff-ffff-ffff-ffff-ffffffffffff" class TestFromJson: def test_unlocked_by_thumbturn(self, log_json): log_json["message"]["eventCode"] = 4 lock_log = LockLog( created_at=datetime(2023, 3, 1, 17, 26, 47, 366000), accessor_id=None, access_code_id=None, message="Unlocked by thumbturn", ) assert LockLog.from_json(log_json) == lock_log def test_unlocked_by_keypad(self, log_json): log_json["message"].update( { "eventCode": 2, "keypadUuid": "__access-code-id__", } ) lock_log = LockLog( created_at=datetime(2023, 3, 1, 17, 26, 47, 366000), accessor_id=None, access_code_id="__access-code-id__", message="Unlocked by keypad", ) assert LockLog.from_json(log_json) == lock_log def test_unlocked_by_mobile_device(self, log_json): log_json["message"].update( { "eventCode": 7, "accessorUuid": "__user-id__", } ) lock_log = LockLog( created_at=datetime(2023, 3, 1, 17, 26, 47, 366000), accessor_id="__user-id__", access_code_id=None, message="Unlocked by mobile device", ) assert LockLog.from_json(log_json) == lock_log pyschlage-2025.4.0/tests/test_notification.py000066400000000000000000000020271500120475100211600ustar00rootroot00000000000000from datetime import UTC, datetime from typing import Any from unittest.mock import Mock import pytest from pyschlage.exceptions import NotAuthenticatedError from pyschlage.notification import Notification def test_from_json(mock_auth: Mock, notification_json: dict[str, Any]): notification = Notification.from_json(mock_auth, notification_json) assert notification.active assert notification.created_at == datetime( 2023, 3, 1, 17, 26, 47, 366000, tzinfo=UTC ) def test_save() -> None: with pytest.raises(NotAuthenticatedError): Notification().save() def test_delete(mock_auth: Mock, notification: Notification) -> None: with pytest.raises(NotAuthenticatedError): Notification().delete() notification.delete() mock_auth.request.assert_called_once_with( "delete", "notifications/___access_code_uuid__" ) assert notification._auth is None assert notification._json == {} assert notification.notification_id is None assert not notification.active pyschlage-2025.4.0/tests/test_user.py000066400000000000000000000007221500120475100174500ustar00rootroot00000000000000from __future__ import annotations from pyschlage.user import User def test_from_json(lock_users_json: list[dict]): user = User( name="asdf", email="asdf@asdf.com", user_id="user-uuid", ) assert User.from_json(lock_users_json[0]) == user def test_from_json_no_name(lock_users_json: list[dict]): for user_json in lock_users_json: user_json.pop("friendlyName") assert User.from_json(user_json).name is None