pax_global_header00006660000000000000000000000064145105626340014520gustar00rootroot0000000000000052 comment=1fba54b87e4defcc4bc0c43422a77414b89220b8 albertogeniola-elmax-api-1fba54b/000077500000000000000000000000001451056263400170505ustar00rootroot00000000000000albertogeniola-elmax-api-1fba54b/.github/000077500000000000000000000000001451056263400204105ustar00rootroot00000000000000albertogeniola-elmax-api-1fba54b/.github/workflows/000077500000000000000000000000001451056263400224455ustar00rootroot00000000000000albertogeniola-elmax-api-1fba54b/.github/workflows/documentation.yml000066400000000000000000000024171451056263400260450ustar00rootroot00000000000000name: Publish Documentation on: push: branches: [ main ] paths: [docs/**] jobs: docs: runs-on: ubuntu-latest steps: - name: Setup Python 3.9 uses: actions/setup-python@v2 with: python-version: 3.9 - uses: actions/checkout@v1 - name: Install deps run: | cd docs pip install -r requirements.txt - name: api-docs run: | sphinx-apidoc -o docs/api elmax_api - uses: ammaraskar/sphinx-action@master with: docs-folder: "docs/" - name: Commit documentation changes run: | git clone https://github.com/albertogeniola/elmax-api.git --branch gh-pages --single-branch gh-pages cp -r docs/_build/html/* gh-pages/ cd gh-pages git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add . git commit -m "Update documentation" -a || true # The above command will fail if no changes were present, so we ignore # the return code. - name: Push changes uses: ad-m/github-push-action@master with: branch: gh-pages directory: gh-pages github_token: ${{ secrets.GITHUB_TOKEN }}albertogeniola-elmax-api-1fba54b/.github/workflows/linting.yml000066400000000000000000000006611451056263400246370ustar00rootroot00000000000000name: Lint on: push: branches: - '*' paths: - '!docs/**' pull_request: branches: - '*' paths: - '!docs/**' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: "3.9" - name: Black Code Formatter uses: psf/black@stablealbertogeniola-elmax-api-1fba54b/.github/workflows/release.yml000066400000000000000000000023111451056263400246050ustar00rootroot00000000000000name: Release on: push: tags: - 'v*' jobs: # ----------------------------- # Release on github and Twine # ----------------------------- release: name: Release runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Setup Python 3.9 uses: actions/setup-python@v2 with: python-version: 3.9 - name: Install python dependencies run: | python -m pip install --upgrade pip pip install -U setuptools wheel pip install twine if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Build artifact run: python setup.py sdist bdist_wheel - name: Release on GitHub uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: Pre-Release ${{env.tag}} prerelease: false draft: false - name: Release on Pypi env: TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} run: | twine upload -u "$TWINE_USERNAME" -p "$TWINE_PASSWORD" dist/*albertogeniola-elmax-api-1fba54b/.github/workflows/testing.yml000066400000000000000000000026141451056263400246500ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Testing on: push: branches: - '*' paths: - '!docs/**' pull_request: branches: - '*' paths: - '!docs/**' jobs: # --------------------------------- # Testing # --------------------------------- test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python 3.9 uses: actions/setup-python@v2 with: python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r dev-requirements.txt - name: Lint with flake8 run: | # Stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --statistics - name: Test with pytest env: ELMAX_USERNAME: ${{ secrets.ELMAX_USERNAME }} ELMAX_PASSWORD: ${{ secrets.ELMAX_PASSWORD }} ELMAX_PANEL_PIN: ${{ secrets.ELMAX_PANEL_PIN }} run: | pytestalbertogeniola-elmax-api-1fba54b/.gitignore000066400000000000000000000042701451056263400210430ustar00rootroot00000000000000# Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # 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/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # IPython Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # dotenv .env # virtualenv venv/ ENV/ # Spyder project settings .spyderproject # Rope project settings .ropeproject ### VirtualEnv template # Virtualenv # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ .Python [Bb]in [Ii]nclude [Ll]ib [Ll]ib64 [Ll]ocal [Ss]cripts pyvenv.cfg .venv pip-selfcheck.json ### JetBrains template # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff: .idea/workspace.xml .idea/tasks.xml .idea/dictionaries .idea/vcs.xml .idea/jsLibraryMappings.xml # Sensitive or high-churn files: .idea/dataSources.ids .idea/dataSources.xml .idea/dataSources.local.xml .idea/sqlDataSources.xml .idea/dynamic.xml .idea/uiDesigner.xml # Gradle: .idea/gradle.xml .idea/libraries # Mongo Explorer plugin: .idea/mongoSettings.xml .idea/ ## File-based project format: *.iws ## Plugin-specific files: # IntelliJ /out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.propertiesalbertogeniola-elmax-api-1fba54b/CHANGELOG.md000066400000000000000000000005241451056263400206620ustar00rootroot00000000000000# CHANGELOG.md ## v0.0.5 Features: - Added CHANGELOG - Releasing stable version ## v0.0.5rc3 Bugfixes: - Fix push_feature ## v0.0.5rc2 Features: - Expose BASE_URL and SSL_CONTEXT to via properties ## v0.0.4 Features: - Handle 422 status code ## v0.0.3rc4 Features: - Improve error handling for local API calls albertogeniola-elmax-api-1fba54b/README.md000066400000000000000000000052031451056263400203270ustar00rootroot00000000000000# Python Elmax API client ![Elmax Logo](docs/elmax-logo.png?raw=true "Elmax Logo") Asynchronous Python API client for interacting with the Elmax Cloud services, via HTTP apis. ![Release Build Status](https://github.com/albertogeniola/elmax-api/workflows/Release/badge.svg?branch=main) ![Testing Status](https://github.com/albertogeniola/elmax-api/workflows/Testing/badge.svg?branch=main) ![Documentation](https://github.com/albertogeniola/elmax-api/workflows/Publish%20Documentation/badge.svg?branch=main) ## Installation Use the package manager pip to install Python Elmax API client: ```bash $ pip3 install elmax-api --user ``` or, to install it globally, use the following command ```bash $ pip3 install elmax-api ``` ## Usage ```python import asyncio from elmax_api.http import Elmax from elmax_api.model.command import SwitchCommand MY_USERNAME = 'TYPE_HERE_YOUR_ELMAX_EMAIL' MY_PASSWORD = 'TYPE_HERE_YOUR_ELMAX_PASSWORD' async def main(): # Instantiate the Elmax API client client = Elmax(username=MY_USERNAME, password=MY_PASSWORD) # List panels for your user panels = await client.list_control_panels() print(f"Found {len(panels)} panels for user {client.get_authenticated_username()}") # Get online panels only online_panels = [] for p in panels: status = 'ONLINE' if p.online else 'OFFLINE' print(f"+ {p.hash}: {status}") if p.online: online_panels.append(p) if len(online_panels) == 0: print("Sorry, no panel to work with. Exiting.") exit(0) # Fetch status of first panel p = online_panels[0] panel_status = await client.get_panel_status(control_panel_id=p.hash) # Print some zone status for z in panel_status.zones: print(f"Zone '{z.name}' open: {z.opened}") # Toggle some actuator actuator = panel_status.actuators[0] old_status = actuator.opened print(f"Actuator {actuator.name} was {'ON' if old_status else 'OFF'}") print(f"Switching {'OFF' if old_status else 'ON'} actuator {actuator.name}") await client.execute_command(endpoint_id=actuator.endpoint_id, command=SwitchCommand.TURN_ON if not old_status else SwitchCommand.TURN_OFF) print("Waiting a bit...") await asyncio.sleep(5) print("Reverting back original actuator status") await client.execute_command(endpoint_id=actuator.endpoint_id, command=SwitchCommand.TURN_ON if old_status else SwitchCommand.TURN_OFF) print("Done!") if __name__ == '__main__': asyncio.run(main()) ``` ## Documentation Full API documentation is available on GitHub pages, [here](https://albertogeniola.github.io/elmax-api/).albertogeniola-elmax-api-1fba54b/docs/000077500000000000000000000000001451056263400200005ustar00rootroot00000000000000albertogeniola-elmax-api-1fba54b/docs/Makefile000066400000000000000000000011721451056263400214410ustar00rootroot00000000000000# 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) albertogeniola-elmax-api-1fba54b/docs/api/000077500000000000000000000000001451056263400205515ustar00rootroot00000000000000albertogeniola-elmax-api-1fba54b/docs/api/elmax_api.model.rst000066400000000000000000000036341451056263400243470ustar00rootroot00000000000000elmax\_api.model package ======================== Submodules ---------- elmax\_api.model.actuator module -------------------------------- .. automodule:: elmax_api.model.actuator :members: :undoc-members: :show-inheritance: elmax\_api.model.alarm\_status module ------------------------------------- .. automodule:: elmax_api.model.alarm_status :members: :undoc-members: :show-inheritance: elmax\_api.model.area module ---------------------------- .. automodule:: elmax_api.model.area :members: :undoc-members: :show-inheritance: elmax\_api.model.command module ------------------------------- .. automodule:: elmax_api.model.command :members: :undoc-members: :show-inheritance: elmax\_api.model.cover module ----------------------------- .. automodule:: elmax_api.model.cover :members: :undoc-members: :show-inheritance: elmax\_api.model.cover\_status module ------------------------------------- .. automodule:: elmax_api.model.cover_status :members: :undoc-members: :show-inheritance: elmax\_api.model.endpoint module -------------------------------- .. automodule:: elmax_api.model.endpoint :members: :undoc-members: :show-inheritance: elmax\_api.model.goup module ---------------------------- .. automodule:: elmax_api.model.goup :members: :undoc-members: :show-inheritance: elmax\_api.model.panel module ----------------------------- .. automodule:: elmax_api.model.panel :members: :undoc-members: :show-inheritance: elmax\_api.model.scene module ----------------------------- .. automodule:: elmax_api.model.scene :members: :undoc-members: :show-inheritance: elmax\_api.model.zone module ---------------------------- .. automodule:: elmax_api.model.zone :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: elmax_api.model :members: :undoc-members: :show-inheritance: albertogeniola-elmax-api-1fba54b/docs/api/elmax_api.rst000066400000000000000000000012521451056263400232420ustar00rootroot00000000000000elmax\_api package ================== Subpackages ----------- .. toctree:: :maxdepth: 4 elmax_api.model Submodules ---------- elmax\_api.constants module --------------------------- .. automodule:: elmax_api.constants :members: :undoc-members: :show-inheritance: elmax\_api.exceptions module ---------------------------- .. automodule:: elmax_api.exceptions :members: :undoc-members: :show-inheritance: elmax\_api.http module ---------------------- .. automodule:: elmax_api.http :members: :undoc-members: :show-inheritance: Module contents --------------- .. automodule:: elmax_api :members: :undoc-members: :show-inheritance: albertogeniola-elmax-api-1fba54b/docs/api/modules.rst000066400000000000000000000001001451056263400227420ustar00rootroot00000000000000elmax_api ========= .. toctree:: :maxdepth: 4 elmax_api albertogeniola-elmax-api-1fba54b/docs/basic.rst000066400000000000000000000116671451056263400216260ustar00rootroot00000000000000Basic Concepts ============== This library implements an asynchronous HTTP api client against hte Elmax HTTP web endpoint. The core functionality of the library is implemented within the :class:`elmax_api.http.Elmax` class. .. warning:: You need to be familiar with asyncio and python asynchronous programming pattern. This library is not designed to work as blocking threaded model. The HTTP Client --------------- To get started, simply instantiate an instance of Elmax client: .. code-block:: python # Instantiate the Elmax API client client = Elmax(username=MY_USERNAME, password=MY_PASSWORD) .. note:: This operation won't attempt any login/connection to the remote cloud, but simply construct the client object. Once created, you can start fetching data from the Elmax Cloud or issuing commands to it. Have a look at the :ref:`quickstart` section to get your hands dirty right away. Authentication and login ------------------------ The :py:class:`Elmax` client handles login and authentication autonomously. Whenever needed, the client will call the login endpoint and authenticates with username/password provided to the constructor. If the login succeeds, the obtained JWT token is stored into memory for later use. Thus, everytime the client needs to invoke authenticated APIs, it reuses the cached JTW token. If, for any reason, the JWT gets refused (as per expiration or invalidation), the library will try again to renews it using the stored user credentials. In case the developers wants to check the validity of user-provided credentials, he can explicitly invoke the :py:func:`login` method, which raises a :py:class:`ElmaxBadLoginError` in case of bad credentials. Elmax Model ----------- This library deals with the following entities. * Panel Represents an Elmax Control Panel. A Panel is the computational entity which activates actuators and reads zones status. A given user, may have multiple panels associated, although this usually means he owns multiple houses/facilities. * Actuator (switch) Is a device that can be activated. It usually has two possible states: on/off. The current version of this library handles two main actuator classes: switches (on/off) and covers (up/down). * Zone (sensor) A zone is nothing more than a sensor (volumetric, magnetic, etc). Usually a zone represents a specific door/window of the house perimeter. As such, a zone can be open or closed. * Area An area is a logical grouping of zones. For instance we can have the kitchen area or the living area. * Scene Scenes are automations that accomplish some objective. Common scenes might be "I'm home" (disable alarms open windows) or "I'm leaving" (closes the doors/windows) and activates away mode alarm. Listing Panels -------------- In order to list the panels associated to the current authenticated user, simply invoke the :py:func:`list_control_panels` method. This method returns a list of :py:class:`PanelEntry`, which the developer can use to retrieve the name/id/online-status of associated control panels. .. code-block:: python # List panels for your user panels = await client.list_control_panels() print(f"Found {len(panels)} panels for user {client.get_authenticated_username()}") Fetch panel status ------------------ Given a :py:class:`PanelEntry`, the developer can retrieve its full status by invoking the :py:func:`get_panel_status` function. This function takes one mandatory argument `control_panel_id` and an optional `pin` code. .. code-block:: python # ... # p is a panel entry retrieved via list_control_panels() panel_status = await client.get_panel_status(control_panel_id=p.hash) .. warning:: The library can only talk to panels that a re currently online. Trying to fetch information from an offline panel will likely result an exception being thrown The panel_status object returned by the client contains information about zones, areas and much more. Refer to the :py:class:`elmax_api.model.PanelStatus` object for more information. Send commands to actuators -------------------------- In order to control actuators connected to the Elmax control panel, the developer must first retrieve the endpoint_id associated to the device he wants to send the command to. The list of actuators available to a given panel is available within the :py:attr:`elmax_api.model.PanelStatus.actuators` property. To send a command to a specicic actuator, the developer relies on :py:func:`execute_command` function. This function accepts, at minimum, following parameters endpoint_id and command. The former, is the id of the actuator within the current panel. The latter is the command that the develoepr wants to issue to the device. The list of accepted commands is described within the :py:mod:`elmax_api.command` module. The current version of the library supports the following actions: #. Turn a device on/off #. Send the UP/DOWN command to a cover #. Trigger a scene #. Arm an area #. Disarm an area albertogeniola-elmax-api-1fba54b/docs/conf.py000066400000000000000000000043301451056263400212770ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html import sphinx_rtd_theme # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys sys.path.insert(0, os.path.abspath('.')) sys.path.insert(0, os.path.abspath('../')) # -- Project information ----------------------------------------------------- project = 'Elmax API' copyright = '2021, Alberto Geniola' author = 'Alberto Geniola' # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.napoleon', 'sphinx.ext.autosectionlabel',"sphinx_rtd_theme"] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'sphinx_rtd_theme' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] html_logo = "elmax-logo.png" html_theme_options = { 'logo_only': False, 'display_version': True, 'sticky_navigation': False, 'analytics_id': 'G-JEC9YKMV5X', 'vcs_pageview_mode': 'display_github' }albertogeniola-elmax-api-1fba54b/docs/elmax-logo.png000066400000000000000000000336571451056263400225700ustar00rootroot00000000000000PNG  IHDRMgAMA a :iCCPPhotoshop ICC profileHwTTϽwz0)C 7Da`(34!EDA"""` `QQy3Vt彗g}k=g}ֺtX 4Jc `23B=ÀH>nL"7w+7tI؂dPĩق }F1(1E";cX| v[="ޚ%qQ-["LqEVaf"+IĦ"&BD)+Rn|nbң2ޜT@`d0l[zZ ?KF\[fFf_nM{H? }_z=YQmv|c34 )[W%I Ȱ316rX7(ݝ ⺱SӅ|zfšyq_0sxpєqyv\7GSa؟8"Q>j1>s@7|8ՉŹ,߳e%9-$H*P*@#`l=p0VHiA>@ vjP @h'@8 .:n``a!2D UH 2!y@PAB&*: :]B=h~L2 p"΃ p\ u6<?g! DCJiA^&2L#PEGQި(j5jU:jGnFQ3Oh2Z mC#щlt݈nC_BF`0FcDa1k0Vy f 3bXl `{ǰCq[3yq<\ww7Zx;| ŗ]8~ M!8Ʉ*B !HT'\b8 q$C'bHBvay=+2Mv&G&Ec[ [bDDĐ I* Zc0&8(&iYH~Ho(%46h0װu wKDŽ7EGGDDōFG7FϮX{xULQ̝:+sV^]*uՙXXf8t\DѸ@f=s6'~_ ˍ̮`Oq8圉D]SINII\7n5ewrm\J`ᔅԈ4\Z\) /ד>aQ1n3|?~c&2S@L uYY5YoóOHrrsNy};_-cZuuk/\?kÑ)*0-(/x)bSWr±^$E[nEmnfmOk%%%JY׾1ꛅ ˬir]+wZiYYGgʿs{?T'U߮qiݧo۾C*זԾ?=xΫ^P֡ 2mjTl,ixwxHȑ&JG˚faԱc7sŨZr}wN>8(mP{nLGRHgT)S]]m?x3g]8wn| ƺc\x'ߥ+=/_u=wvWO]c\n}Ϫ'l:o\:xviMoܺ~{;˾;y/Ylx~XHQc?:b=rf}Icda)iDӤ)ϩV<|~W_}oοDΌ\«ï-_w>~f~#zGPQc'O%wu cHRMz&u0`:pQ<bKGD pHYs.#.#x?vtIME ++ IDATxwTSv"45PQA4vOIDM^bD,}&&,{ R,be{Fu33μ癇2sϽs{%Ԅ((%x`BaIWk@v% xyXR)EpԑУ;7DS)gM$ `#pc%`*Pݣ(RvAha9*` _#hW)V1.w3B6EQx.(( P|<1+(*yL7 u5dv( `;B?x+ ](XqgK xP_hdEQEph>pnN( <>Bd}A9j{7_F\QEW^Nv1Zp'l{= 6@_x'0@( `'7ZFfP`|<4  ]REQ܂^_!91ƍ<70xK( `lPog>kap&Ñ3u+..lcN=lC( x Ѽ"f}0Zh8h髠(Rx<6d;1>Dkv0/|(-OGj87 p()AC\)f}><וWCQ8pǬ?/?:(o_{>ALاcGXnFkQEQ Gl1qP;qY_EQx S!!=.f׻/s{'}UEQ! VGlgRt" CTuQEN!YN FS'/F6GA•EQ 䢸B?}1} l41fqWF"e OjQE|)O!|ӌp!0R(!E0^nFQ%Zu!PcG$3$gE2f|#(Jj#uw^o9BrQKyLv-$+Rt%1=I>F^xQA4kb7kPWZ(E'k_ o8VBo鄄uV(J ntp^쫁.Hbd_d>R+RtwĹo7z#f |^>>]cl$f;z45ҥ+RTx;n$_wp/$ A^4~Ux\ 끾+W[(J$ t#Fn @H @&K`un'RWH'(NHA@*F"sbY!H N½P `7!I"2a.H\M*V5(H fKGE-A7X&TYx:0êwRu _Jh_Cpxo]!GSsߖD>#y.7b6Zq|ݑ3"q5kזMɥ+ BQX8|{ 4k_Eԝ:$ZS0`цOE,KdINieH}O=X; ao>FC-9yy~w'+d?·=C!da7bXSSCxA&MHIOlRiFtnXv-lSN'^>ۮCRLŁ\򅋀c<'<|f0>Dr(C)^j:8`gO|zA+JI`X*z$2 Ek[4_[XׄG^n]H]^'@{eV2.p13G3 8"cG;b{l,kEQK"![S>Hє3,^Mp׮*+sMnn%) ⯶8$׍.l?~= DbDɫ~VVhkʻYzd6r!*Hˀ.+;^XzI+/ ާܖj4] wE][+)JʼۛMO(9v(#<7\qo2k?}٧V{ҤkIشV 3g^pGER-oM3~|I/}jH'F=VѢ^Yd͊* ^!{q<./=9r̆dkC/QH~mg9ykjġCÏ%Bx1T熆\ ,@J` 6nka35 pbw^pak= GD/xXއKol=d֭*Jóω lݢ3?#'d¢`lXeلTpR`g+.1Q;Q$8g_zY̮;sK: cQQual(/oiݚ@oQ >?ll4GWKCE屝[]VW:$ef L_\Jg:|W8o ƹ IO^1/LcD6HV{7:fùim&b\d!Y~7 A"6LGν/R8j™[rp(sl !|}/pO3drGdT#e<2F}Q.">fEx?cOxlˍ.}FjTV GU>쵍Xwn~0LŌgC,m}Cs&2[!t s?^E{E͌[l8OaϡW&S$ n|tZd{țnuB·Jk1g e{ک'z)uw70+j$1Ewm'/z4֔)-7`ϼ$ &<[ M<,Yy|L8 8l#A"/=s%v$xs8 1Ia2Up--H$4Ê"|n-1~*\ϞI 7{Hn6 zdĀYl!:4u^.L>N8zw͞H,y*bMk%=q< `s_6<ˌf~ͳNeL_gG4!1ô>E/\qNx C3>nxѴ؋D̓;  udUFs~2||&#U$~oG)؋X|6*`w}} #|WG=K(3gx|vC?|`&=bU:LA&D[$/&5f}6./6I {DqΡF9 v =6,Rs;jmAm5 ͵H Tma\o-u8eB+]x:"aYH/)zYf/HlkD'fRP 4MA; 5(?9 ksfa8()pOMAy%y0ڢW4 `~;o"(Zx F u!Wyp&N h5Ic7q/HqŔd~=BprRPj>NyIqqN^n}b"hF{Hy7 Lyz3mU!-^˒ՃmMl1I9gc5ًQ%)'d6l dO5s<f p{c&NuWڟp^wX|e9htA\lM&:&zט9 ׸H|/&E'vO!).جSt%y;"5Ae酿$JCt|EЂ#B / 34QwױIB[i@ז%ļ$2bp2!{^Dp;(oO-%XƫFe<FpKlQA c 7awA.x\.ȽX^٫^ B3:\V^-x'X:V@+d~-Z7s{{m& IW) :A. x\vZ2 a]$>d2\%. Mڗ6+mPӐ0%P#@uذ ذ8cWmw/b˼u/ 7[P7 IԤU O ߕ;h\~ʈ 蝉^J GlW׀8zAn`G3qaC ²!4`ĹrkcH.BD?Cr?Z]iYGEp8`@}UGO(xKfQ, Xo4ReRef%G&dw}gvXب pL#ш?O r XKm-ArXOGjk5fz,R/7JAp;D)a--r%zYK5AI%[Fh}[ۘ!T\ARV'ͲKF[YCd/w^r㉎M_d?T[ ^$Vċ6=aw8v4a{d?QN,n,3 9<ҟp+ԉ~'Gm Z/ZY8[{DW@ y 9a~ϕFj*JOP:xAX:a'H/-]ۍȾ9sf po\+Jʅ 8T焜Y5`B3Ɲ[NaiftmKlt ^mpe灰~ /P_v xOβS^8?,bEژ,㽸nfB>2ְ̓?bΖ|Η $6Zk$y<&ăcS@sqK&x^Z˶_!l#*M`J`VN{)3w6<ì<ܒ !/N(C&PS#ϫHܣ nO}9\Q.@<2=Y >vwn~Lf|>t''%ڍq8?@vJIMKMPY +~ptf= W"t}ljԜ-Db>GR/)H?롍AO4tM Y:$.@ IDATYTͼxATOG[;fqSJdOƉijbR1 ۺ"Yxɰeyw3l52LCq'p~ 3('ŋZ lw%x҂ڮwxmX.Mߊ1Y(?9oY:o$ɩq\;=3d$ӪS\ՂKHc~ΰE\ uzv4v߁:K!=,8yG`P#bz^Yht+';L$VRp3m:V{uGk-r[[ _ c~nfd_#X\gr$;ε[DJ~xq*!x86XE,2r'>;!f;sFnt W"[,\,׉bƮTSN~n4t%@2iCͰ ExB[-x#_Շ_r|d1!Xh.0 Er޸Gof1KTLFEtO'{x]Km \DKv7gI9#'AL(!y Zk|.bNk؏lgz#924\wx/R@!|trͧå7)FoUV-.!{EGL67zOx c~ oG"hE)D`[kH-EiΝhֶ-4uvH/,[ѽ=$ñEW#^pp 9r-c4H),UH$jV h>% -W >uh3v 2bYq$IT8{봢XV9C9k,agŶ7!))/C \]G/m}>h>]cB;,=,H$km\K9G ٞ:>%| -^ X$MHv57qU63`n{JktF*|dɟxKtdᱍ3L$^oz3^z8G^ oI#d?>yBg6ߠ=ϱ7>3yxɓ\뻈?g;E-n#'6:һBrO22du0fƄM{z6G*{~ViV/u`d(/%';ɯ'QFD2-A<5gz#[v4v3xQg0Uu®uZjo bηޒN &Hv [yS9@|8c 3S(VF(LX1zAovyXYOhBEQ /[)g>ל*g)_Q:p &=d/) Fu#< {/ME/=((E#g e:=qqj#bjW+nBb)Zە^o^C/ywI 00;1K_XLJFhR=E(DJim8-~:N$H]LxPfY->C?GoFb<7vd k#|w<07'H")x:MMб#54@*(!8S+*5[2LZ1^>(Ggw!xuG& Ks:dopD2X^Cr2( XW0ӆb8#dNFL}J#x~HRu[{hJE̛BXQ/RQlĂ aEQJW?٫~1Hs( 8H6l%܂[BXQqew4IEktBEgBXQq U U^E!iŠ(*àE#1c+rBx@VEpC3i Z_4tZ3f)8 zvyHՓP[ ®B]>%EQTG#bp,A䮬AEQq x5WgEQTGL@(*(*'Qm6"5EQnPEQ40Zg*(*C+R^qF `v( D- (*mq>vG+hW(R+D u8 (la[8Q( \0NyL׮PEQ{>q7p0GBQEp. 즏+V?|(J)%'@g}\|](p9H^19UEQ 8/-g= 흥^QE5|1M?){ _EQՀ́@{?kt+.cK6~!(P.EJ>Lա( E*^`iEQQa ~x |CYQEp8e2`(8rt=""U_UEQuBwu/Gw_AV)H,@O`08h}#rˌKux*.V0ɒ -=Pk7%90|WQEQ\6i;̿*p.3O #X3vT+6|*@?EQ)Pn'IENDB`albertogeniola-elmax-api-1fba54b/docs/examples/000077500000000000000000000000001451056263400216165ustar00rootroot00000000000000albertogeniola-elmax-api-1fba54b/docs/examples/quickstart.rst000066400000000000000000000013621451056263400245440ustar00rootroot00000000000000Installation ============ This library can be easily installed via the Pypi packet manager. .. code-block:: bash pip3 install elmax-api --user or, to install it globally: .. code-block:: bash pip3 install elmax-api .. _quickstart: Quick Start =========== The following code snippet illustrates how to login to the Elmax Cloud API, fetch the current zone status and toggle some actuator .. literalinclude:: ../../examples/basic.py Listen for push notifications ============================= In order to listen for push notification events, it is just necessary to register a callback coroutine using a `PushNotificationHandler` helper object. The following example shows how to to so. .. literalinclude:: ../../examples/push.pyalbertogeniola-elmax-api-1fba54b/docs/index.rst000066400000000000000000000006501451056263400216420ustar00rootroot00000000000000Welcome to Elmax API's documentation! ===================================== **Elmax API** is a Python library for controlling `Elmax devices `_. .. toctree:: :maxdepth: 2 :caption: Api reference examples/quickstart basic api/modules .. note:: This project is under active development. Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search`albertogeniola-elmax-api-1fba54b/docs/make.bat000066400000000000000000000013701451056263400214060ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build if "%1" == "" goto help %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.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd albertogeniola-elmax-api-1fba54b/docs/requirements.txt000066400000000000000000000000671451056263400232670ustar00rootroot00000000000000sphinx==4.1.2 sphinx-rtd-theme==0.5.2 MarkupSafe==2.0.0albertogeniola-elmax-api-1fba54b/elmax_api/000077500000000000000000000000001451056263400210075ustar00rootroot00000000000000albertogeniola-elmax-api-1fba54b/elmax_api/__init__.py000066400000000000000000000005221451056263400231170ustar00rootroot00000000000000"""Client for the Elmax Cloud services.""" __name__ = "elmax_api" __version__ = "0.0.5" __license__ = "MIT" __url__ = "https://github.com/albertogeniola/elmax-api" __author__ = "Alberto Geniola" __author_email__ = "albertogeniola@gmail.com" __description__ = """ Asynchronous API Library to work with Elmax devices """ __keywords__ = [] albertogeniola-elmax-api-1fba54b/elmax_api/constants.py000066400000000000000000000007331451056263400234000ustar00rootroot00000000000000"""Constants for the Elmax Cloud service client.""" from . import __version__ # URL Constants BASE_URL = "https://cloud.elmaxsrl.it/api/ext/" ENDPOINT_DEVICES = "devices" ENDPOINT_LOGIN = "login" ENDPOINT_STATUS_ENTITY_ID = "status" ENDPOINT_DISCOVERY = "discovery" ENDPOINT_LOCAL_CMD = "cmd" # User agent USER_AGENT = f"elmax-api/{__version__}" # DEFAULT HTTP TIMEOUT DEFAULT_HTTP_TIMEOUT = 20.0 BUSY_WAIT_INTERVAL = 2.0 # OTHER DEFAULTS DEFAULT_PANEL_PIN = "000000" albertogeniola-elmax-api-1fba54b/elmax_api/exceptions.py000066400000000000000000000014711451056263400235450ustar00rootroot00000000000000"""Exceptions for the Elmax Cloud services client.""" class ElmaxError(Exception): """General ElmaxError exception occurred.""" pass class ElmaxApiError(Exception): """Occurs when an API returns an unexpected return code""" def __init__(self, status_code: int): self._status_code = status_code @property def status_code(self): return self._status_code class ElmaxNetworkError(ElmaxError): """When a network error is encountered.""" pass class ElmaxBadLoginError(ElmaxError): """Occurs when a login attempt fails""" pass class ElmaxBadPinError(ElmaxError): """Occurs when a BAD pin is used with the discovery api""" pass class ElmaxPanelBusyError(ElmaxError): """Occurs when a command cannot be executed due to panel busy status""" pass albertogeniola-elmax-api-1fba54b/elmax_api/http.py000066400000000000000000000555661451056263400223610ustar00rootroot00000000000000""" This module handles HTTP api calls to the Elmax WEB endpoint, implemented by the `Elmax` class """ import asyncio import functools import logging import ssl import time from enum import Enum from socket import socket from typing import Dict, List, Optional, Union from abc import ABC, abstractmethod import httpx import jwt from yarl import URL from elmax_api.constants import BASE_URL, ENDPOINT_LOGIN, USER_AGENT, ENDPOINT_DEVICES, ENDPOINT_DISCOVERY, \ ENDPOINT_STATUS_ENTITY_ID, DEFAULT_HTTP_TIMEOUT, BUSY_WAIT_INTERVAL, ENDPOINT_LOCAL_CMD, DEFAULT_PANEL_PIN from elmax_api.exceptions import ElmaxBadLoginError, ElmaxApiError, ElmaxNetworkError, ElmaxBadPinError, \ ElmaxPanelBusyError from elmax_api.model.command import Command from elmax_api.model.panel import PanelEntry, PanelStatus, EndpointStatus _LOGGER = logging.getLogger(__name__) _JWT_ALGS = ["HS256"] async def helper(f, *args, **kwargs): if asyncio.iscoroutinefunction(f): return await f(*args, **kwargs) else: return f(*args, **kwargs) def async_auth(func, *method_args, **method_kwargs): """ Asynchronous decorator used to check validity of JWT token. It takes care to verify the validity of a JWT token before issuing the method call. In case the JWT is expired, or close to expiration date, it tries to renew it. """ @functools.wraps(func, *method_args, **method_kwargs) async def wrapper(*args, **kwargs): # Check whether the client has a valid token to be used. We consider valid tokens with expiration time # > 10minutes. If not, try to login first and then proceed with the function call. now = time.time() _instance = args[0] assert isinstance(_instance, GenericElmax) exp_time = _instance.token_expiration_time if exp_time == 0: _LOGGER.warning("The API client was not authorized yet. Login will be attempted.") await _instance.login() elif exp_time < 0: _LOGGER.warning("The API client token is expired. Login will be attempted.") await _instance.login() elif (exp_time - now) < 600: _LOGGER.info( "The API client token is going to be expired soon. " "Login will be attempted right now to refresh it." ) await _instance.login() # At this point, we assume the client has a valid token to use for authorized APIs. So let's use it. result = await helper(func, *args, **kwargs) return result return wrapper class GenericElmax(ABC): """ Abstract Elmax HTTP client. This class takes care of handling API calls against the ELMAX API cloud endpoint. It handles data marshalling/unmarshalling, login and token renewal upon expiration. """ def __init__(self, base_url: str = BASE_URL, current_panel_id: str = None, current_panel_pin: str = DEFAULT_PANEL_PIN, timeout: float = DEFAULT_HTTP_TIMEOUT, ssl_context: Optional[ssl.SSLContext] = None): """Base constructor. Args: base_url: API server base-URL current_panel_id: Panel id of the preferred panel current_panel_pin: Panel PIN of the preferred panel timeout: The default timeout, in seconds, to set up for the inner HTTP client ssl_contex: an SSL context to override the default one """ self._raw_jwt = None self._jwt = None self._areas = self._outputs = self._zones = [] self._current_panel_id = current_panel_id self._current_panel_pin = current_panel_pin self._base_url = URL(base_url) # Build the SSL context we trust sslcontext = ssl_context if ssl_context is not None else True self._ssl_context = sslcontext self._http_client = httpx.AsyncClient(timeout=timeout, verify=sslcontext) @classmethod async def retrieve_server_certificate(cls, hostname: str, port: int): try: pem_server_certificate = ssl.get_server_certificate((hostname, port)) return pem_server_certificate except (socket.gaierror, ConnectionRefusedError) as ex: raise ElmaxNetworkError from ex def set_default_timeout(self, timeout: float): """Sets the default timeout (in seconds) for the HTTP client""" self._http_client.timeout = timeout async def _request( self, method: "Elmax.HttpMethod", url: str, data: Optional[Dict] = None, authorized: bool = False, timeout: float = DEFAULT_HTTP_TIMEOUT, retry_attempts: int = 3 ) -> Dict: """ Executes an HTTP API request against a given endpoint, parses the output and returns the json to the caller. It handles most basic IO exceptions. If the API returns a non 200 response, this method raises an `ElmaxApiError` Args: method: HTTP method to use for the HTTP request url: Target request URL data: Json data/Data to post in POST messages. Ignored when issuing GET requests authorized: When set, the request is performed passing the stored authorization token timeout: timeout in seconds for a single attempt retry_attempts: number of retry attempts in case of 422 (panel busy) Returns: Dict: The dictionary object containing authenticated JWT data Raises: ElmaxApiError: Whenever a non 200 return code is returned by the remote server ElmaxNetworkError: If the http request could not be completed due to a network error ElmaxPanelBusyError: If the number of retries have been exhausted while the panel returned a busy state (422) """ retry_attempt = 0 while retry_attempt < retry_attempts: try: response_data = await self._internal_request(method=method, url=url, data=data, authorized=authorized, timeout=timeout) _LOGGER.debug(response_data) return response_data except ElmaxApiError as e: if e.status_code == 422: retry_attempt += 1 _LOGGER.error("Panel is busy. Command will be retried in a moment.") await asyncio.sleep(BUSY_WAIT_INTERVAL) else: raise raise ElmaxPanelBusyError() async def _internal_request( self, method: "Elmax.HttpMethod", url: str, data: Optional[Dict] = None, authorized: bool = False, timeout: float = DEFAULT_HTTP_TIMEOUT ) -> Dict: headers = { "User-Agent": USER_AGENT, "Accept": "application/json", "Content-Type": "application/json", } if authorized: headers["Authorization"] = f"JWT {self._raw_jwt}" try: if method == Elmax.HttpMethod.GET: response = await self._http_client.get(str(url), headers=headers, params=data, timeout=timeout) elif method == Elmax.HttpMethod.POST: response = await self._http_client.post(str(url), headers=headers, json=data, timeout=timeout) else: raise ValueError("Invalid/Unhandled method. Expecting GET or POST") _LOGGER.debug( "HTTP Request %s %s -> Status code: %d", str(method), url, response.status_code, ) if response.status_code != 200: _LOGGER.error( "Api call failed. Method=%s, Url=%s, Data=%s. Response code=%d. Response content=%s", method, url, str(data), response.status_code, str(response.content), ) raise ElmaxApiError(status_code=response.status_code) # The current API version does not return an error description nor an error http # status code for invalid logins. Instead, an empty body is returned. In that case we # assume the login failed due to invalid user/pass combination response_content = response.text if response_content == '': raise ElmaxBadLoginError() return response.json() # Wrap any other HTTP/NETWORK error except (httpx.ConnectError, httpx.ReadTimeout) as e: _LOGGER.exception("An unhandled error occurred while executing API Call.") raise ElmaxNetworkError("A network error occurred") @property def ssl_context(self) -> ssl.SSLContext: return self._ssl_context @property def base_url(self) -> URL: return self._base_url @property def current_panel_id(self) -> str: return self._current_panel_id def set_current_panel(self, panel_id: str, panel_pin: str = DEFAULT_PANEL_PIN): self._current_panel_id = panel_id self._current_panel_pin = panel_pin @property def is_authenticated(self) -> bool: """ Specifies whether the client has been granted a JWT which is still valid (not expired) Returns: bool: True if there is a valid JWT token, False if there's no token or if it is expired """ if self._jwt is None: # The user did not log in yet return False if self._jwt.get("exp", 0) <= time.time(): self._jwt = None return False return True @property def token_expiration_time(self) -> int: """ Returns the expiration timestamp of the stored JWT token. Returns: int: The timestamp of expiration or -1 if no token was present. """ if self._jwt is None: return 0 return self._jwt.get("exp", -1) @async_auth async def logout(self) -> None: """ Invalidate the current token TODO: * Check if there is a HTTP API to invalidate the current token """ self._jwt = None @abstractmethod async def login(self, *args, **kwargs) -> Dict: """ Acquires a token and stores it internally. """ raise NotImplemented() @abstractmethod @async_auth async def get_current_panel_status(self, *args, **kwargs) -> PanelStatus: """ Fetches the status of the local control panel. Returns: The current status of the control panel Raises: ElmaxBadPinError: Whenever the provided PIN is incorrect or in any way refused by the server ElmaxApiError: in case of underlying api call failure """ raise NotImplemented() @async_auth async def get_endpoint_status(self, endpoint_id: str) -> EndpointStatus: """ Fetches the panel status only for the given endpoint_id Args: control_panel_id: Id of the control panel to fetch status from endpoint_id: Id of the device to fetch data for Returns: The current status of the given endpoint """ url = self._base_url / ENDPOINT_STATUS_ENTITY_ID / endpoint_id response_data = await self._request(Elmax.HttpMethod.GET, url=url, authorized=True) status = EndpointStatus.from_api_response(response_entry=response_data) return status @async_auth @abstractmethod async def execute_command(self, endpoint_id: str, command: Union[Command, str], extra_payload: Dict = None, retry_attempts: int = 3) -> Optional[Dict]: """ Executes a command against the given endpoint Args: endpoint_id: EndpointID against which the command should be issued command: Command to issue. Can either be a string or a `Command` enum value extra_payload: Dictionary of extra payload to be issued to the endpoint retry_attempts: Maximum retry attempts in case of 422 error (panel busy) Returns: Json response data, if any, returned from the API """ raise NotImplemented() @async_auth async def _execute_command(self, url: str, extra_payload: Dict = None, retry_attempts: int = 3) -> Optional[Dict]: if extra_payload is not None and not isinstance(extra_payload, dict): raise ValueError("The extra_payload parameter must be a dictionary") response_data = await self._request(Elmax.HttpMethod.POST, url=url, authorized=True, data=extra_payload, retry_attempts=retry_attempts) _LOGGER.debug(response_data) return response_data def get_authenticated_username(self) -> Optional[str]: """ Returns the username associated to the current JWT token, if any. In case the user is not authenticated, returns None """ if self._jwt is None: return None return self._jwt.get("email") class HttpMethod(Enum): """Enumerative helper for supported HTTP methods of the Elmax API""" GET = "get" POST = "post" class Elmax(GenericElmax): """ Class implementing the Cloud HTTP API. """ def __init__(self, username: str, password: str): """Client constructor. Args: username: username to use for logging in password: password to use for logging in """ super(Elmax, self).__init__(base_url=BASE_URL) self._username = username self._password = password @async_auth async def list_control_panels(self) -> List[PanelEntry]: """ Lists the control panels available for the given user Returns: List[PanelEntry]: The list of fetched `ControlPanel` devices discovered via the API """ res = [] url = self._base_url / ENDPOINT_DEVICES response_data = await self._request( method=Elmax.HttpMethod.GET, url=url, authorized=True ) for response_entry in response_data: res.append(PanelEntry.from_api_response(response_entry=response_entry)) return res async def login(self, *args, **kwargs) -> Dict: """ Connects to the API ENDPOINT and returns the access token to be used within the client Raises: ElmaxBadLoginError: if the login attempt fails due to bad username/password credentials ValueError: in case the json response is malformed """ url = self._base_url / ENDPOINT_LOGIN data = { "username": self._username, "password": self._password, } try: response_data = await self._request( method=Elmax.HttpMethod.POST, url=url, data=data, authorized=False ) except ElmaxApiError as e: if e.status_code == 401: raise ElmaxBadLoginError() raise if "token" not in response_data: raise ValueError("Missing token parameter in json response") jwt_token = response_data["token"] if not jwt_token.startswith("JWT "): raise ValueError("API did not return JWT token as expected") jt = jwt_token.split("JWT ")[1] # We do not need to verify the signature as this is usually something the server # needs to do. We will just decode it to get information about user/claims. # Moreover, since the JWT is obtained over a HTTPS channel, we do not need to verify # its integrity/confidentiality as the ssl does this for us self._jwt = jwt.decode( jt, algorithms=_JWT_ALGS, options={"verify_signature": False} ) self._raw_jwt = ( jt # keep an encoded version of the JWT for convenience and performance ) return self._jwt @async_auth async def get_panel_status(self, control_panel_id: str, pin: Optional[str] = DEFAULT_PANEL_PIN) -> PanelStatus: """ Fetches the control panel status. Args: control_panel_id: Id of the control panel to fetch status from pin: security pin (optional) Returns: The current status of the control panel Raises: ElmaxBadPinError: Whenever the provided PIN is incorrect or in any way refused by the server ElmaxApiError: in case of underlying api call failure """ url = self._base_url / ENDPOINT_DISCOVERY / control_panel_id / str(pin) try: response_data = await self._request(Elmax.HttpMethod.GET, url=url, authorized=True) except ElmaxApiError as e: if e.status_code == 403: raise ElmaxBadPinError() from e else: raise panel_status = PanelStatus.from_api_response(response_entry=response_data) return panel_status @async_auth async def execute_command(self, endpoint_id: str, command: Union[Command, str], extra_payload: Dict = None, retry_attempts: int = 3) -> Optional[Dict]: """ Executes a command against the given endpoint Args: endpoint_id: EndpointID against which the command should be issued command: Command to issue. Can either be a string or a `Command` enum value extra_payload: Dictionary of extra payload to be issued to the endpoint retry_attempts: Maximum retry attempts in case of 422 error (panel busy) Returns: Json response data, if any, returned from the API """ if isinstance(command, Command): cmd_str = str(command.value) elif isinstance(command, str): cmd_str = command else: raise ValueError("Invalid/unsupported command") url = self._base_url / endpoint_id / cmd_str return await self._execute_command(url=url, extra_payload=extra_payload, retry_attempts=retry_attempts) @async_auth async def get_current_panel_status(self, *args, **kwargs) -> PanelStatus: if self._current_panel_id is None: raise RuntimeError("Unset/Invalid current control panel ID.") return await self.get_panel_status(control_panel_id=self._current_panel_id, pin=self._current_panel_pin) class ElmaxLocal(GenericElmax): """ Class implementing the Local HTTP API client. """ def __init__(self, panel_api_url: str, panel_code: str, ssl_context: ssl.SSLContext = None): """Client constructor. Args: panel_api_url: API address of the Elmax Panel panel_code: authentication code to be used with the panel ssl_context: SSLContext object to use for SSL verification """ super(ElmaxLocal, self).__init__(base_url=panel_api_url, ssl_context=ssl_context) # The current version of the local API does not expose the panel ID attribute, # so we use the panel IP as ID self.set_current_panel(panel_id=panel_api_url, panel_pin=panel_code) async def login(self, *args, **kwargs) -> Dict: """ Connects to the Local ENDPOINT and returns the access token to be used within the client Raises: ElmaxBadLoginError: if the login attempt fails due to bad username/password credentials ValueError: in case the json response is malformed """ url = self._base_url / ENDPOINT_LOGIN data = { "pin": self._current_panel_pin } try: response_data = await self._request( method=Elmax.HttpMethod.POST, url=url, data=data, authorized=False ) except ElmaxApiError as e: if e.status_code in (401, 403): raise ElmaxBadLoginError() raise if "token" not in response_data: raise ValueError("Missing token parameter in json response") jwt_token = response_data["token"] if not jwt_token.startswith("JWT "): raise ValueError("API did not return JWT token as expected") jt = jwt_token.split("JWT ")[1] # We do not need to verify the signature as this is usually something the server # needs to do. We will just decode it to get information about user/claims. # Moreover, since the JWT is obtained over a HTTPS channel, we do not need to verify # its integrity/confidentiality as the ssl does this for us self._jwt = jwt.decode( jt, algorithms=_JWT_ALGS, options={"verify_signature": False} ) self._raw_jwt = ( jt # keep an encoded version of the JWT for convenience and performance ) return self._jwt @async_auth async def execute_command(self, endpoint_id: str, command: Union[Command, str], extra_payload: Dict = None, retry_attempts: int = 3) -> Optional[Dict]: """ Executes a command against the given endpoint Args: endpoint_id: EndpointID against which the command should be issued command: Command to issue. Can either be a string or a `Command` enum value extra_payload: Dictionary of extra payload to be issued to the endpoint retry_attempts: Maximum retry attempts in case of 422 error (panel busy) Returns: Json response data, if any, returned from the API """ if isinstance(command, Command): cmd_str = str(command.value) elif isinstance(command, str): cmd_str = command else: raise ValueError("Invalid/unsupported command") url = self._base_url / ENDPOINT_LOCAL_CMD / endpoint_id / cmd_str return await self._execute_command(url=url, extra_payload=extra_payload, retry_attempts=retry_attempts) @async_auth async def get_current_panel_status(self, *args, **kwargs) -> PanelStatus: """ Fetches the control panel status. Returns: The current status of the control panel Raises: ElmaxBadPinError: Whenever the provided PIN is incorrect or in any way refused by the server ElmaxApiError: in case of underlying api call failure """ url = self._base_url / ENDPOINT_DISCOVERY try: response_data = await self._request(Elmax.HttpMethod.GET, url=url, authorized=True) except ElmaxApiError as e: if e.status_code == 403: raise ElmaxBadPinError() from e else: raise panel_status = PanelStatus.from_api_response(response_entry=response_data) return panel_status albertogeniola-elmax-api-1fba54b/elmax_api/model/000077500000000000000000000000001451056263400221075ustar00rootroot00000000000000albertogeniola-elmax-api-1fba54b/elmax_api/model/__init__.py000066400000000000000000000000001451056263400242060ustar00rootroot00000000000000albertogeniola-elmax-api-1fba54b/elmax_api/model/actuator.py000066400000000000000000000022131451056263400243010ustar00rootroot00000000000000from typing import Dict from elmax_api.model.endpoint import DeviceEndpoint class Actuator(DeviceEndpoint): """Representation of an actuator""" def __init__(self, endpoint_id: str, visible: bool, index: int, name: str, opened: bool): super().__init__(endpoint_id=endpoint_id, visible=visible, index=index, name=name) self._opened = opened @property def opened(self) -> bool: return self._opened def __eq__(self, other): super_equals = super().__eq__(other) if not super_equals: return False return self.opened == other.opened @staticmethod def from_api_response(response_entry: Dict) -> 'Actuator': """Create a new actuator object from the API json response""" actuator = Actuator( endpoint_id=response_entry.get('endpointId'), visible=response_entry.get('visibile'), index=response_entry.get('indice'), name=response_entry.get('nome'), opened=response_entry.get('aperta') ) return actuator albertogeniola-elmax-api-1fba54b/elmax_api/model/alarm_status.py000066400000000000000000000004231451056263400251570ustar00rootroot00000000000000from enum import Enum class AlarmArmStatus(Enum): ARMED_TOTALLY = 4 ARMED_P1_P2 = 3 ARMED_P2 = 2 ARMED_P1 = 1 NOT_ARMED = 0 class AlarmStatus(Enum): TRIGGERED = 3 ARMED_STANDBY = 2 NOT_ARMED_NOT_ARMABLE = 1 NOT_ARMED_NOT_TRIGGERED = 0 albertogeniola-elmax-api-1fba54b/elmax_api/model/area.py000066400000000000000000000052411451056263400233730ustar00rootroot00000000000000from typing import Dict, List from elmax_api.model.alarm_status import AlarmStatus, AlarmArmStatus from elmax_api.model.endpoint import DeviceEndpoint class Area(DeviceEndpoint): """Representation of an Area configuration""" def __init__(self, endpoint_id: str, visible: bool, index: int, name: str, status: AlarmStatus, armed_status: AlarmArmStatus, available_statuses: List[AlarmStatus], available_arm_statuses: List[AlarmArmStatus]): super().__init__(endpoint_id=endpoint_id, visible=visible, index=index, name=name) self._status = status self._armed_status = armed_status self._available_arm_statuses = available_arm_statuses self._available_statuses = available_statuses @property def available_arm_statuses(self) -> List[AlarmArmStatus]: """ Supported list of available alarm arm-statutes Returns: """ return self._available_arm_statuses @property def available_statuses(self) -> List[AlarmStatus]: """ Supported list of available alarm statuses Returns: """ return self._available_statuses @property def status(self) -> AlarmStatus: """ Current alarm status. Returns: `AlarmStatus` """ return self._status @property def armed_status(self) -> AlarmArmStatus: """ Current alarm arm status Returns: `AlarmArmStatus` """ return self._armed_status def __eq__(self, other): super_equals = super().__eq__(other) if not super_equals: return False return self.status == other.status and self.armed_status == other.armed_status and self.available_arm_statuses == other.available_arm_statuses and self.available_statuses == other.available_statuses @staticmethod def from_api_response(response_entry: Dict) -> 'Area': """Create a new area configuration object from the API json response""" area = Area( endpoint_id=response_entry.get('endpointId'), visible=response_entry.get('visibile'), index=response_entry.get('indice'), name=response_entry.get('nome'), status=AlarmStatus(response_entry['statoSessione']), armed_status=AlarmArmStatus(response_entry['stato']), available_statuses=[ AlarmStatus(a) for a in response_entry['statiSessioneDisponibili'] ], available_arm_statuses=[AlarmArmStatus(a) for a in response_entry['statiDisponibili']] ) return area albertogeniola-elmax-api-1fba54b/elmax_api/model/command.py000066400000000000000000000005371451056263400241040ustar00rootroot00000000000000from enum import Enum class Command(Enum): pass class SwitchCommand(Command): TURN_ON = "on" TURN_OFF = "off" class CoverCommand(Command): UP = 1 DOWN = 2 class AreaCommand(Command): ARM_TOTALLY = 4 ARM_P1_P2 = 3 ARM_P2 = 2 ARM_P1 = 1 DISARM = 0 class SceneCommand(Command): TRIGGER_SCENE = "on" albertogeniola-elmax-api-1fba54b/elmax_api/model/cover.py000066400000000000000000000026501451056263400236020ustar00rootroot00000000000000from typing import Dict from elmax_api.model.cover_status import CoverStatus from elmax_api.model.endpoint import DeviceEndpoint class Cover(DeviceEndpoint): """Representation of a cover""" def __init__(self, endpoint_id: str, visible: bool, index: int, name: str, position: int, status: CoverStatus): super().__init__(endpoint_id=endpoint_id, visible=visible, index=index, name=name) self._position = position self._status = status @property def position(self) -> int: return self._position @property def status(self) -> CoverStatus: return self._status def __eq__(self, other): super_equals = super().__eq__(other) if not super_equals: return False return self.position == other.position and self.status == other.status @staticmethod def from_api_response(response_entry: Dict) -> 'Cover': """Create a new cover object from the API json response""" cover = Cover( endpoint_id=response_entry.get('endpointId'), visible=response_entry.get('visibile'), index=response_entry.get('indice'), name=response_entry.get('nome'), position=response_entry.get('posizione'), status=CoverStatus(response_entry['stato']) ) return cover albertogeniola-elmax-api-1fba54b/elmax_api/model/cover_status.py000066400000000000000000000001431451056263400252000ustar00rootroot00000000000000from enum import Enum class CoverStatus(Enum): DOWN = "down" UP = "up" IDLE = "stop" albertogeniola-elmax-api-1fba54b/elmax_api/model/endpoint.py000066400000000000000000000022731451056263400243050ustar00rootroot00000000000000from typing import Dict class DeviceEndpoint: def __init__(self, endpoint_id: str, visible: bool, index: int, name: str): self._endpoint_id = endpoint_id self._visible = visible self._index = index self._name = name @property def endpoint_id(self) -> str: return self._endpoint_id @property def visible(self) -> bool: return self._visible @property def index(self) -> int: return self._index @property def name(self) -> str: return self._name def __eq__(self, other): return self.endpoint_id == other.endpoint_id and \ self.visible==other.visible and \ self.index==other.index and \ self.name==other.name @staticmethod def from_api_response(response_entry: Dict) -> 'DeviceEndpoint': """Create a new area configuration object from the API json response""" endpoint = DeviceEndpoint( endpoint_id=response_entry.get('endpointId'), visible=response_entry.get('visibile'), index=response_entry.get('indice'), name=response_entry.get('nome'), ) return endpoint albertogeniola-elmax-api-1fba54b/elmax_api/model/goup.py000066400000000000000000000014571451056263400234420ustar00rootroot00000000000000from typing import Dict from elmax_api.model.endpoint import DeviceEndpoint class Group(DeviceEndpoint): """Representation of a Group configuration""" def __init__(self, endpoint_id: str, visible: bool, index: int, name: str): super().__init__(endpoint_id=endpoint_id, visible=visible, index=index, name=name) @staticmethod def from_api_response(response_entry: Dict) -> 'Group': """Create a new group configuration object from the API json response""" group = Group( endpoint_id=response_entry.get('endpointId'), visible=response_entry.get('visibile'), index=response_entry.get('indice'), name=response_entry.get('nome'), ) return group albertogeniola-elmax-api-1fba54b/elmax_api/model/panel.py000066400000000000000000000224601451056263400235640ustar00rootroot00000000000000import json from enum import Enum from typing import Dict, List, Any from elmax_api.model.actuator import Actuator from elmax_api.model.area import Area from elmax_api.model.cover import Cover from elmax_api.model.endpoint import DeviceEndpoint from elmax_api.model.goup import Group from elmax_api.model.scene import Scene from elmax_api.model.zone import Zone class PanelEntry: """Representation of an available control panel.""" def __init__(self, devicehash: str, online: bool, name_by_user: Dict[str, str]): """Initialize the new control panel.""" self._hash = devicehash self._online = online self._names = name_by_user @property def hash(self) -> str: return self._hash @property def online(self) -> bool: return self._online def get_name_by_user(self, username: str) -> str: if username not in self._names: ValueError( "Cannot find the name associated by user %s to device %s", username, self._hash, ) return self._names.get(username) @staticmethod def from_api_response(response_entry: Dict) -> 'PanelEntry': """Create a new control panel from the API json response""" # Convert the data structure so that we have a dictionary of names by user name_by_user = dict() for entry in response_entry.get("username", []): username = entry.get("name") name = entry.get("label") name_by_user[username] = name control_panel = PanelEntry( devicehash=response_entry["hash"], online=bool(response_entry["centrale_online"]), name_by_user=name_by_user, ) return control_panel class PanelStatus: """Representation of a panel status""" def __init__(self, panel_id: str, user_email: str, release: str, cover_feature: bool, scene_feature: bool, zones: List[Zone], actuators: List[Actuator], areas: List[Area], groups: List[Group], scenes: List[Scene], covers: List[Cover], push_feature: bool, accessory_type: str, accessory_release: str, *args, **kwargs ): self._panel_id = panel_id self._user_email = user_email self._release = release self._cover_feature = cover_feature self._scene_feature = scene_feature self._zones = zones self._actuators = actuators self._areas = areas self._groups = groups self._scenes = scenes self._covers = covers self._push_feature = push_feature self._accessory_type = accessory_type self._accessory_release = accessory_release @property def panel_id(self) -> str: return self._panel_id @property def user_email(self) -> str: return self._user_email @property def release(self) -> str: return self._release @property def cover_feature(self) -> bool: return self._cover_feature @property def scene_feature(self) -> bool: return self._scene_feature @property def zones(self) -> List[Zone]: return self._zones @property def actuators(self) -> List[Actuator]: return self._actuators @property def areas(self) -> List[Area]: return self._areas @property def groups(self) -> List[Group]: return self._groups @property def scenes(self) -> List[Scene]: return self._scenes @property def covers(self) -> List[Cover]: return self._covers @property def all_endpoints(self) -> List[DeviceEndpoint]: res = [] res.extend(self.actuators) res.extend(self.areas) res.extend(self.groups) res.extend(self.scenes) res.extend(self.zones) res.extend(self.covers) return res @property def push_feature(self) -> bool: return self._push_feature @property def accessory_type(self) -> str: return self._accessory_type @property def accessory_release(self) -> str: return self._accessory_release def __repr__(self): def inspectobj(obj): if isinstance(obj,Enum): return obj.name elif hasattr(obj, "__dict__"): return vars(obj) else: return str(obj) return json.dumps(self, default=inspectobj) @staticmethod def from_api_response(response_entry: Dict) -> 'PanelStatus': """Create a new panel status object from the API json response""" panel_status = PanelStatus( panel_id=response_entry.get('centrale'), user_email=response_entry.get('utente'), release=response_entry.get('release'), cover_feature=response_entry.get('tappFeature'), scene_feature=response_entry.get('sceneFeature'), push_feature=response_entry.get('pushFeature', False), accessory_type=response_entry.get('tipo_accessorio', 'Unknown'), accessory_release=response_entry.get('release_accessorio', 'Unknown'), zones=[Zone.from_api_response(x) for x in response_entry.get('zone', [])], actuators=[Actuator.from_api_response(x) for x in response_entry.get('uscite', [])], areas=[Area.from_api_response(x) for x in response_entry.get('aree', [])], covers=[Cover.from_api_response(x) for x in response_entry.get('tapparelle', [])], groups=[Group.from_api_response(x) for x in response_entry.get('gruppi', [])], scenes=[Scene.from_api_response(x) for x in response_entry.get('scenari', [])] ) return panel_status class EndpointStatus: """Representation of an endpoint status""" def __init__(self, release: str, cover_feature: bool, scene_feature: bool, zones: List[Zone], actuators: List[Actuator], areas: List[Area], groups: List[Group], scenes: List[Scene], covers: List[Cover], push_feature: bool, accessory_type: str, accessory_release: str, *args, **kwargs): self._release = release self._cover_feature = cover_feature self._scene_feature = scene_feature self._zones = zones self._actuators = actuators self._areas = areas self._groups = groups self._scenes = scenes self._covers = covers self._push_feature = push_feature self._accessory_type = accessory_type self._accessory_release = accessory_release @property def release(self) -> str: return self._release @property def cover_feature(self) -> bool: return self._cover_feature @property def scene_feature(self) -> bool: return self._scene_feature @property def zones(self) -> List[Zone]: return self._zones @property def actuators(self) -> List[Actuator]: return self._actuators @property def areas(self) -> List[Area]: return self._areas @property def groups(self) -> List[Group]: return self._groups @property def scenes(self) -> List[Scene]: return self._scenes @property def covers(self) -> List[Cover]: return self._covers @property def all_endpoints(self) -> List[DeviceEndpoint]: res = [] res.extend(self.actuators) res.extend(self.areas) res.extend(self.groups) res.extend(self.scenes) res.extend(self.zones) return res @property def push_feature(self) -> bool: return self._push_feature @property def accessory_type(self) -> str: return self._accessory_type @property def accessory_release(self) -> str: return self._accessory_release @staticmethod def from_api_response(response_entry: Dict) -> 'EndpointStatus': """Create a new endpoint status object from the API json response""" status = EndpointStatus( release=response_entry.get('release', 'Unknown'), cover_feature=response_entry.get('tappFeature', False), scene_feature=response_entry.get('sceneFeature', False), push_feature=response_entry.get('pushFeature', False), accessory_type=response_entry.get('tipo_accessorio', 'Unknown'), accessory_release=response_entry.get('release_accessorio', 'Unknown'), zones=[Zone.from_api_response(x) for x in response_entry.get('zone', [])], actuators=[Actuator.from_api_response(x) for x in response_entry.get('uscite', [])], areas=[Area.from_api_response(x) for x in response_entry.get('aree', [])], covers=[Cover.from_api_response(x) for x in response_entry.get('tapparelle', [])], groups=[Group.from_api_response(x) for x in response_entry.get('gruppi', [])], scenes=[Scene.from_api_response(x) for x in response_entry.get('scenari', [])] ) return statusalbertogeniola-elmax-api-1fba54b/elmax_api/model/scene.py000066400000000000000000000014571451056263400235650ustar00rootroot00000000000000from typing import Dict from elmax_api.model.endpoint import DeviceEndpoint class Scene(DeviceEndpoint): """Representation of a Scene configuration""" def __init__(self, endpoint_id: str, visible: bool, index: int, name: str): super().__init__(endpoint_id=endpoint_id, visible=visible, index=index, name=name) @staticmethod def from_api_response(response_entry: Dict) -> 'Scene': """Create a new scene configuration object from the API json response""" scene = Scene( endpoint_id=response_entry.get('endpointId'), visible=response_entry.get('visibile'), index=response_entry.get('indice'), name=response_entry.get('nome'), ) return scene albertogeniola-elmax-api-1fba54b/elmax_api/model/zone.py000066400000000000000000000025561451056263400234440ustar00rootroot00000000000000from typing import Dict from elmax_api.model.endpoint import DeviceEndpoint class Zone(DeviceEndpoint): """Representation of a zone configuration""" def __init__(self, endpoint_id: str, visible: bool, index: int, name: str, opened: bool, excluded: bool): super().__init__(endpoint_id=endpoint_id, visible=visible, index=index, name=name) self._opened = opened self._excluded = excluded @property def opened(self) -> bool: return self._opened @property def excluded(self) -> bool: return self._excluded def __eq__(self, other): super_equals = super().__eq__(other) if not super_equals: return False return self.opened==other.opened and self.excluded==other.excluded @staticmethod def from_api_response(response_entry: Dict) -> 'Zone': """Create a new zone configuration object from the API json response""" zone = Zone( endpoint_id=response_entry.get('endpointId'), visible=response_entry.get('visibile'), index=response_entry.get('indice'), name=response_entry.get('nome'), opened=response_entry.get('aperta'), excluded=response_entry.get('esclusa') ) return zone albertogeniola-elmax-api-1fba54b/elmax_api/push/000077500000000000000000000000001451056263400217665ustar00rootroot00000000000000albertogeniola-elmax-api-1fba54b/elmax_api/push/__init__.py000066400000000000000000000000001451056263400240650ustar00rootroot00000000000000albertogeniola-elmax-api-1fba54b/elmax_api/push/push.py000066400000000000000000000123121451056263400233160ustar00rootroot00000000000000import asyncio import json import logging import ssl from asyncio import FIRST_COMPLETED, Event, Task, AbstractEventLoop from typing import Awaitable, Callable, Optional import websockets from elmax_api.http import GenericElmax from elmax_api.model.panel import PanelStatus _LOGGER = logging.getLogger(__name__) _ERROR_WAIT_PERIOD = 15 class PushNotificationHandler: """ Helper class to listen for push notifications over a websocket. Panels supporting push notification dispatching do expose a pushFeature=True. """ _event_handlers: set[Callable[[PanelStatus], Awaitable[None]]] _client: GenericElmax _endpoint: str _ssl_context: ssl.SSLContext _should_run: bool _task: Optional[Task] _loop: Optional[AbstractEventLoop] _stop_event: Event def __init__(self, endpoint: str, http_client: GenericElmax, ssl_context: ssl.SSLContext = None): """ Constructor. @param endpoint: panel push-notification websocket endpoint. It should start with ws:// or wss://. It should be wss://ELMAX_PANEL_IP/api/v2/push @param http_client: instance of GenericElmax (or Elmax) object to use as http API client @param ssl_context: custom ssl context configuration. Useful to accept self-signed certificates or similar. """ self._endpoint = endpoint self._client = http_client self._event_handlers = set() if ssl_context is None: self._ssl_context = ssl.create_default_context() else: self._ssl_context = ssl_context self._should_run = False self._stop_event = Event() self._task = None self._loop = None def register_push_notification_handler(self, coro: Callable[[PanelStatus], Awaitable[None]]) -> None: """ Registers a push notification handler coroutine. Every time a new event is received, that coro will be invoked and awaited. @param coro: callback coroutine which takes a PanelStatus object as argument @return: """ if coro not in self._event_handlers: self._event_handlers.add(coro) def unregister_push_notification_handler(self, coro: Callable[[PanelStatus], Awaitable[None]]): """ Unregisters the given coroutine callback from the event push notifications @param coro: callback to unregister @return: """ if coro in self._event_handlers: self._event_handlers.remove(coro) def start(self, loop: AbstractEventLoop): """ Starts the push-notification loop handler task. @param loop: @return: """ self._stop_event.clear() self._should_run = True self._loop = loop self._task = loop.create_task(self._looper()) def stop(self): """ Stops the push-notification loop handler task. @return: """ self._should_run = False self._stop_event.set() async def _connect(self): token = await self._client.login() return await websockets.connect(self._endpoint, ssl=self._ssl_context, extra_headers={ "Authorization": self._client._raw_jwt }) async def _notify_handlers(self, message): _LOGGER.debug("Handling message dispatching for handlers") message_dict = json.loads(message) status = PanelStatus.from_api_response(message_dict) _LOGGER.debug("Parsed panel-status: %s", status) _LOGGER.debug("There are %d registered event handlers.", len(self._event_handlers)) for coro in self._event_handlers: try: _LOGGER.debug("Dispatching to event handler %s.", str(coro)) await coro(status) except Exception as e: _LOGGER.exception("Error occurred when notifying a push-notification handler") async def _wait_for_messages(self, connection): while self._should_run: stop_event_waiter = self._loop.create_task(self._stop_event.wait()) receive_waiter = self._loop.create_task(connection.recv()) done, pending = await asyncio.wait([receive_waiter, stop_event_waiter], return_when=FIRST_COMPLETED) if stop_event_waiter in done: _LOGGER.info("Push notification handler has received stop signal. Aborting wait for messages...") receive_waiter.cancel() return message = receive_waiter.result() _LOGGER.debug("Push notification message received from websocket: %s", str(message)) await self._notify_handlers(message) async def _looper(self): while self._should_run: _LOGGER.debug("Push Notification looper has started.") try: connection = await self._connect() _LOGGER.debug("Push Notification looper has connected successfully to the websocket. Waiting for messages...") await self._wait_for_messages(connection) except Exception as e: _LOGGER.exception("Error occurred when handling websocket connection. We will re-establish the " "connection in %d seconds.", _ERROR_WAIT_PERIOD) await asyncio.sleep(_ERROR_WAIT_PERIOD) albertogeniola-elmax-api-1fba54b/examples/000077500000000000000000000000001451056263400206665ustar00rootroot00000000000000albertogeniola-elmax-api-1fba54b/examples/basic.py000066400000000000000000000035241451056263400223250ustar00rootroot00000000000000import asyncio import os from elmax_api.http import Elmax from elmax_api.model.command import SwitchCommand MY_USERNAME = os.getenv('ELMAX_USERNAME') or 'TYPE_HERE_YOUR_USERNAME' MY_PASSWORD = os.getenv('ELMAX_PASSWORD') or 'TYPE_HERE_YOUR_PASSWORD' async def main(): # Instantiate the Elmax API client client = Elmax(username=MY_USERNAME, password=MY_PASSWORD) # List panels for your user panels = await client.list_control_panels() print(f"Found {len(panels)} panels for user {client.get_authenticated_username()}") # Get online panels only online_panels = [] for p in panels: status = 'ONLINE' if p.online else 'OFFLINE' print(f"+ {p.hash}: {status}") if p.online: online_panels.append(p) if len(online_panels) == 0: print("Sorry, no panel to work with. Exiting.") exit(0) # Fetch status of first panel p = online_panels[0] panel_status = await client.get_panel_status(control_panel_id=p.hash) # Print some zone status for z in panel_status.zones: print(f"Zone '{z.name}' open: {z.opened}") # Toggle some actuator actuator = panel_status.actuators[0] old_status = actuator.opened print(f"Actuator {actuator.name} was {'ON' if old_status else 'OFF'}") print(f"Switching {'OFF' if old_status else 'ON'} actuator {actuator.name}") await client.execute_command(endpoint_id=actuator.endpoint_id, command=SwitchCommand.TURN_ON if not old_status else SwitchCommand.TURN_OFF) print("Waiting a bit...") await asyncio.sleep(5) print("Reverting back original actuator status") await client.execute_command(endpoint_id=actuator.endpoint_id, command=SwitchCommand.TURN_ON if old_status else SwitchCommand.TURN_OFF) print("Done!") if __name__ == '__main__': asyncio.run(main())albertogeniola-elmax-api-1fba54b/examples/push.py000066400000000000000000000020001451056263400222070ustar00rootroot00000000000000import asyncio import ssl from elmax_api.http import ElmaxLocal from elmax_api.model.panel import PanelStatus from elmax_api.push.push import PushNotificationHandler PANEL_ENDPOINT="https://192.168.7.249/api/v2" PUSH_ENDPOINT="wss://192.168.7.249/api/v2/push" async def main(): context = ssl.create_default_context() context.check_hostname = False context.verify_mode = ssl.VerifyMode(ssl.CERT_NONE) client = ElmaxLocal(panel_api_url=PANEL_ENDPOINT, panel_code="000000", ssl_context=context) handler = PushNotificationHandler(PUSH_ENDPOINT, client, context) async def _panel_updated(status: PanelStatus): print("An event has occurred!") print(status) handler.register_push_notification_handler(_panel_updated) handler.start(asyncio.get_event_loop()) for i in range(60): await asyncio.sleep(1) print("Slept!") handler.unregister_push_notification_handler(_panel_updated) handler.stop() if __name__ == '__main__': asyncio.run(main()) albertogeniola-elmax-api-1fba54b/examples/retrieve_cert.py000066400000000000000000000015471451056263400241110ustar00rootroot00000000000000import asyncio import os import ssl from elmax_api.http import ElmaxLocal from elmax_api.model.command import SwitchCommand LOCAL_PANEL_IP = "192.168.1.139" LOCAL_PANEL_PORT = 8443 async def main(): # Instantiate the Elmax API client cert = await ElmaxLocal.retrieve_server_certificate(hostname=LOCAL_PANEL_IP, port=LOCAL_PANEL_PORT) ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS) ssl_context.verify_mode = ssl.CERT_REQUIRED ssl_context.check_hostname = False # We only load a very specific CA certificate ssl_context.load_verify_locations(cadata=cert) client = ElmaxLocal(panel_api_url=f"https://{LOCAL_PANEL_IP}:{LOCAL_PANEL_PORT}/api/v2", panel_code="000000", ssl_context=ssl_context) status = await client.get_current_panel_status() print(f"Status: {status}") if __name__ == '__main__': asyncio.run(main())albertogeniola-elmax-api-1fba54b/requirements.txt000066400000000000000000000000711451056263400223320ustar00rootroot00000000000000pyjwt>=1.7.1 httpx>=0.18.0 yarl>=1.6.3 websockets>=11.0.3albertogeniola-elmax-api-1fba54b/setup.py000066400000000000000000000032131451056263400205610ustar00rootroot00000000000000from os import path from setuptools import setup, find_packages from elmax_api import __name__ as target_name from elmax_api import __version__ as target_version from elmax_api import __url__ as target_url from elmax_api import __license__ as target_license from elmax_api import __author__ as target_author from elmax_api import __description__ as target_description from elmax_api import __keywords__ as target_keywords from elmax_api import __author_email__ as target_author_email here = path.abspath(path.dirname(__file__)) # Read the readme and put it into the description field of setup.py with open(path.join(here, "README.md"), encoding="utf-8") as f: long_description = f.read() # Read the requirements file and set the dependencies accordingly with open(path.join(here, "requirements.txt"), encoding="utf-8") as f: requirements = [line.strip() for line in f] setup( name=target_name, version=target_version, packages=find_packages(exclude=("tests",)), url=target_url, license=target_license, author=target_author, author_email=target_author_email, classifiers=[ "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Operating System :: OS Independent", ], description=target_description, long_description=long_description, long_description_content_type="text/markdown", keywords=target_keywords, project_urls={ "Documentation": target_url, "Source": target_url, "Tracker": target_url, }, data_files=[('.', ['requirements.txt'])], install_requires=requirements, python_requires=">=3.7", test_suite="tests", ) albertogeniola-elmax-api-1fba54b/tests/000077500000000000000000000000001451056263400202125ustar00rootroot00000000000000albertogeniola-elmax-api-1fba54b/tests/__init__.py000066400000000000000000000010051451056263400223170ustar00rootroot00000000000000"""Test for the Elmax Cloud service client.""" import os from elmax_api.http import Elmax, ElmaxLocal USERNAME = os.environ.get("ELMAX_USERNAME") PASSWORD = os.environ.get("ELMAX_PASSWORD") PANEL_PIN = os.environ.get("ELMAX_PANEL_PIN") LOCAL_TEST = os.environ.get("LOCAL_TEST", "false") LOCAL_API_URL = os.environ.get("LOCAL_API_URL") LOCAL_TEST = LOCAL_TEST.lower() == "true" client = Elmax(username=USERNAME, password=PASSWORD) if not LOCAL_TEST else ElmaxLocal(panel_api_url=LOCAL_API_URL, panel_code=PANEL_PIN)albertogeniola-elmax-api-1fba54b/tests/test_actuator.py000066400000000000000000000036421451056263400234520ustar00rootroot00000000000000"""Test the actuator functionalities.""" import asyncio import pytest from elmax_api.model.command import SwitchCommand from elmax_api.model.panel import PanelStatus, PanelEntry from tests import client, LOCAL_TEST def setup_module(module): if not LOCAL_TEST: panels = asyncio.run(client.list_control_panels()) online_panels = list(filter(lambda x: x.online, panels)) assert len(online_panels) > 0 # Select the first online panel entry = online_panels[0] # type:PanelEntry client.set_current_panel(panel_id=entry.hash) @pytest.mark.asyncio async def test_device_command(): # Retrieve its status panel = await client.get_current_panel_status() # type: PanelStatus assert isinstance(panel, PanelStatus) # Store old status into a dictionary for later comparison expected_actuator_status = { actuator.endpoint_id:actuator.opened for actuator in panel.actuators} # Toggle the first 3 actuators actuators actuators = list(expected_actuator_status.items())[:min(len(expected_actuator_status.items()),3)] tasks = [] for endpoint_id, curr_status in actuators: command = SwitchCommand.TURN_OFF if curr_status else SwitchCommand.TURN_ON print(f"Actuator {endpoint_id} was {curr_status}, issuing {command}...") tasks.append(client.execute_command(endpoint_id=endpoint_id, command=command)) # Set actuator expected status expected_actuator_status[endpoint_id] = not curr_status await asyncio.gather(*tasks) # Ensure all the actuators switched correctly await asyncio.sleep(3) panel = await client.get_current_panel_status() # type: PanelStatus for actuator in panel.actuators: expected_status = expected_actuator_status[actuator.endpoint_id] print(f"Actuator {actuator.endpoint_id} expected {expected_status}, was {actuator.opened}...") assert actuator.opened == expected_status albertogeniola-elmax-api-1fba54b/tests/test_area.py000066400000000000000000000103401451056263400225310ustar00rootroot00000000000000"""Test the actuator functionalities.""" import asyncio import pytest from elmax_api.constants import DEFAULT_PANEL_PIN from elmax_api.exceptions import ElmaxApiError from elmax_api.model.alarm_status import AlarmStatus, AlarmArmStatus from elmax_api.model.area import Area from elmax_api.model.command import AreaCommand from elmax_api.model.panel import PanelStatus, PanelEntry from tests import client, LOCAL_TEST def setup_module(module): if not LOCAL_TEST: panels = asyncio.run(client.list_control_panels()) online_panels = list(filter(lambda x: x.online, panels)) assert len(online_panels) > 0 # Select the first online panel entry = online_panels[0] # type:PanelEntry client.set_current_panel(panel_id=entry.hash) async def get_area(only_armable=False): # Retrieve current area status panel = await client.get_current_panel_status() assert isinstance(panel, PanelStatus) assert len(panel.areas) > 0 # Do not work with un-armable areas if only_armable: a = list(filter(lambda x: x.status != AlarmStatus.NOT_ARMED_NOT_ARMABLE, panel.areas)) else: a = panel.areas if len(a) < 1: return None else: return a[0] async def reset_area_status(area: Area, command: AreaCommand, expected_arm_status: AlarmArmStatus, code: str = DEFAULT_PANEL_PIN) -> Area: res = await client.execute_command(endpoint_id=area.endpoint_id, command=command, extra_payload={"code": code}) attempts = 0 while attempts < 3: # Make sure the area is now consistent status = await client.get_endpoint_status(endpoint_id=area.endpoint_id) area = status.areas[0] if area.armed_status != expected_arm_status: attempts += 1 await asyncio.sleep(delay=2.0) else: return area pytest.fail(f"RESET_AREA_STATUS failed to set status {expected_arm_status} on area {area}") @pytest.mark.asyncio async def test_area_wrong_disarm_code(): area = await get_area(only_armable=True) if area is None: pytest.skip("No armable areas found to test") if area.armed_status != AlarmArmStatus.NOT_ARMED: await reset_area_status(area=area, command=AreaCommand.DISARM, expected_arm_status=AlarmArmStatus.NOT_ARMED) # ARM TOTALLY area = await get_area() area = await reset_area_status(area=area, command=AreaCommand.ARM_TOTALLY, expected_arm_status=AlarmArmStatus.ARMED_TOTALLY) # Check status assert area.status == AlarmStatus.ARMED_STANDBY # Disarm with wrong code error403 = False try: area = await reset_area_status(area=area, command=AreaCommand.DISARM, expected_arm_status=AlarmArmStatus.NOT_ARMED, code="999999") except ElmaxApiError as e: error403 = e.status_code == 403 if not error403: pytest.fail("Expected ERROR 403, but it hasn't occurred") assert area.status == AlarmStatus.ARMED_STANDBY @pytest.mark.asyncio async def test_area_arming_totally(): # Make sure the area is disarmed area = await get_area(only_armable=True) if area is None: pytest.skip("No armable areas found to test") if area.armed_status != AlarmArmStatus.NOT_ARMED: await reset_area_status(area=area, command=AreaCommand.DISARM, expected_arm_status=AlarmArmStatus.NOT_ARMED) # ARM TOTALLY area = await get_area() area = await reset_area_status(area=area, command=AreaCommand.ARM_TOTALLY, expected_arm_status=AlarmArmStatus.ARMED_TOTALLY) # Check status assert area.status == AlarmStatus.ARMED_STANDBY @pytest.mark.asyncio async def test_area_disarm(): # Make sure the area is armed first area = await get_area(only_armable=True) if area is None: pytest.skip("No armable areas found to test") if area.armed_status != AlarmArmStatus.ARMED_TOTALLY: await reset_area_status(area=area, command=AreaCommand.ARM_TOTALLY, expected_arm_status=AlarmArmStatus.ARMED_TOTALLY) # DISARM area = await get_area() area = await reset_area_status(area=area, command=AreaCommand.DISARM, expected_arm_status=AlarmArmStatus.NOT_ARMED) # Check status assert area.status in (AlarmStatus.NOT_ARMED_NOT_TRIGGERED, AlarmStatus.NOT_ARMED_NOT_ARMABLE) albertogeniola-elmax-api-1fba54b/tests/test_authentication.py000066400000000000000000000015151451056263400246440ustar00rootroot00000000000000"""Test the authentication process.""" import pytest from elmax_api.exceptions import ElmaxBadLoginError from elmax_api.http import ElmaxLocal, Elmax from tests import client, LOCAL_TEST, LOCAL_API_URL, PANEL_PIN BAD_USERNAME = "thisIsWrong@gmail.com" BAD_PASSWORD = "fakePassword" @pytest.mark.asyncio async def test_wrong_credentials(): client = Elmax(username=BAD_USERNAME, password=BAD_PASSWORD) if LOCAL_TEST != "true" else ElmaxLocal( panel_api_url=LOCAL_API_URL, panel_code=PANEL_PIN) with pytest.raises(ElmaxBadLoginError): await client.login() @pytest.mark.asyncio async def test_good_credentials(): jwt_data = await client.login() assert isinstance(jwt_data, dict) username = client.get_authenticated_username() # TODO: parametrize the following control #assert username == USERNAME albertogeniola-elmax-api-1fba54b/tests/test_control_panels.py000066400000000000000000000061041451056263400246460ustar00rootroot00000000000000"""Test control panel functionalities.""" import asyncio import pytest from elmax_api.constants import DEFAULT_PANEL_PIN from elmax_api.exceptions import ElmaxBadPinError from elmax_api.model.actuator import Actuator from elmax_api.model.area import Area from elmax_api.model.cover import Cover from elmax_api.model.goup import Group from elmax_api.model.panel import PanelStatus from elmax_api.model.scene import Scene from elmax_api.model.zone import Zone from tests import client, LOCAL_TEST def setup_module(module): if not LOCAL_TEST: panels = asyncio.run(client.list_control_panels()) online_panels = list(filter(lambda x: x.online, panels)) assert len(online_panels) > 0 # Select the first online panel entry = online_panels[0] client.set_current_panel(panel_id=entry.hash) @pytest.mark.asyncio async def test_list_control_panels(): if LOCAL_TEST: pytest.skip("Skipping test_list_control_panels as testing local API") return panels = await client.list_control_panels() assert len(panels) > 0 @pytest.mark.asyncio async def test_get_control_panel_status(): # Retrieve its status status = await client.get_current_panel_status() # type: PanelStatus assert isinstance(status, PanelStatus) # Make sure the username matches the one used by the client # TODO: parametrize the following check #assert status.user_email == USERNAME @pytest.mark.asyncio async def test_wrong_pin(): if LOCAL_TEST: pytest.skip("Skipping bad pin test for LOCAL API tests") panels = await client.list_control_panels() online_panels = list(filter(lambda x: x.online, panels)) assert len(online_panels) > 0 # Select the first panel panel = online_panels[0] # Retrieve its status with pytest.raises(ElmaxBadPinError): client.set_current_panel(panel.hash, "111111") # This will trigger the exception await client.get_current_panel_status() # type: PanelStatus # Make sure to re-set the original panel pin client.set_current_panel(panel.hash, DEFAULT_PANEL_PIN) @pytest.mark.asyncio async def test_single_device_status(): # Retrieve its status status = await client.get_current_panel_status() # type: PanelStatus assert isinstance(status, PanelStatus) # Make sure we can read each status correctly for endpoint in status.all_endpoints: epstatus = await client.get_endpoint_status(endpoint_id=endpoint.endpoint_id) if isinstance(endpoint, Actuator): assert epstatus.actuators[0] == endpoint elif isinstance(endpoint, Area): assert epstatus.areas[0] == endpoint elif isinstance(endpoint, Group): assert epstatus.groups[0] == endpoint elif isinstance(endpoint, Scene): assert epstatus.scenes[0] == endpoint elif isinstance(endpoint, Zone): assert epstatus.zones[0] == endpoint elif isinstance(endpoint, Cover): assert epstatus.covers[0] == endpoint else: raise ValueError("Unexpected/unhandled endpoint") albertogeniola-elmax-api-1fba54b/tests/test_cover.py000066400000000000000000000110701451056263400227400ustar00rootroot00000000000000"""Test the actuator functionalities.""" import asyncio import time import pytest from elmax_api.http import GenericElmax from elmax_api.model.command import CoverCommand from elmax_api.model.cover import Cover from elmax_api.model.cover_status import CoverStatus from elmax_api.model.panel import PanelStatus, PanelEntry from tests import client, LOCAL_TEST async def wait_for_cover_status(client: GenericElmax, endpoint_id: str, status: CoverStatus, timeout: float) -> bool: t = time.time() deadline = t + timeout while time.time() < deadline: try: cur_status = await client.get_endpoint_status(endpoint_id=endpoint_id) cover: Cover = cur_status.covers[0] if cover.status == status: return True finally: await asyncio.sleep(delay=2) return False async def wait_for_cover_position(client: GenericElmax, endpoint_id: str, position: int, timeout: float) -> bool: t = time.time() deadline = t + timeout while time.time() < deadline: try: cur_status = await client.get_endpoint_status(endpoint_id=endpoint_id) cover: Cover = cur_status.covers[0] if cover.position == position: return True finally: await asyncio.sleep(delay=2) print("TIMEOUT WHILE WAITING FOR COVER MOVING") raise TimeoutError() def setup_module(module): if not LOCAL_TEST: panels = asyncio.run(client.list_control_panels()) online_panels = list(filter(lambda x: x.online, panels)) assert len(online_panels) > 0 # Select the first online panel which has covers panel_found = False for panel in online_panels: panel_status = asyncio.run(client.get_panel_status(panel.hash)) if len(panel_status.covers) > 0: panel_found = True client.set_current_panel(panel_id=panel.hash) break if not panel_found: pytest.skip("No panel found to run this test set.") @pytest.mark.asyncio async def test_open_close(): # Do this twice so we toggle every cover up->down and down ->up for i in range(2): # Retrieve its status panel = await client.get_current_panel_status() # type: PanelStatus assert isinstance(panel, PanelStatus) if len(panel.covers) < 1: pytest.skip(f"No covers found on the specified panel: {client.current_panel_id}") # Store old status into a dictionary for later comparison cover_position = { cover.endpoint_id: cover.position for cover in panel.covers} # Toggle all the actuators tasks = [] for endpoint_id, curr_status in cover_position.items(): command = CoverCommand.UP if curr_status==0 else CoverCommand.DOWN await client.execute_command(endpoint_id=endpoint_id, command=command) expected_position = 100 if command==CoverCommand.UP else 0 t = wait_for_cover_position(client=client, endpoint_id=endpoint_id, position=expected_position, timeout=30.0) tasks.append(t) # Ensure all the actuators switched correctly done, pending = await asyncio.wait(tasks, return_when="FIRST_EXCEPTION") for t in done: if t.exception(): pytest.fail("One of the covers failed") t.cancel() @pytest.mark.asyncio async def test_up_down_states(): # Do this twice so we toggle every cover up->down and down ->up for i in range(2): # Retrieve its status panel = await client.get_current_panel_status() # type: PanelStatus assert isinstance(panel, PanelStatus) # Store old status into a dictionary for later comparison cover_position = {cover.endpoint_id: cover.position for cover in panel.covers} # Toggle all the actuators tasks = [] for endpoint_id, curr_status in cover_position.items(): command = CoverCommand.UP if curr_status==0 else CoverCommand.DOWN await client.execute_command(endpoint_id=endpoint_id, command=command) expected_status = CoverStatus.UP if command == CoverCommand.UP else CoverStatus.DOWN t = wait_for_cover_status(client=client, endpoint_id=endpoint_id, status=expected_status,timeout=4.0) tasks.append(t) # Ensure all the actuators switched correctly done, pending = await asyncio.wait(tasks, return_when="FIRST_EXCEPTION") for t in done: if t.exception(): pytest.fail("One of the covers failed") t.cancel()