diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 000000000..b3c58f623
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,2 @@
+[run]
+omit = */__main__.py
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..02f2ab716
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,19 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ # Check for updates to GitHub Actions every weekday
+ interval: "daily"
+
+ - package-ecosystem: "pip" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
+ # Check for pip updates on Sundays
+ day: "sunday"
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 000000000..c3b3bdb48
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,68 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+# ******** NOTE ********
+
+name: "CodeQL"
+
+on:
+ push:
+ branches: [ development, master ]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [ development ]
+ schedule:
+ - cron: '21 22 * * 0'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'python' ]
+ # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
+ # Learn more...
+ # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v1
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+ # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+ # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
+ # If this step fails, then you should remove it and run the build manually (see below)
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v1
+
+ # ℹ️ Command-line programs to run using the OS shell.
+ # 📚 https://git.io/JvXDl
+
+ # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
+ # and modify them (or add more) to build your code if your project
+ # uses a compiled language
+
+ #- run: |
+ # make bootstrap
+ # make release
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v1
diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml
new file mode 100644
index 000000000..875a7e6e7
--- /dev/null
+++ b/.github/workflows/publish-to-pypi.yml
@@ -0,0 +1,31 @@
+name: Publish 📦 to PyPI
+
+on:
+ push:
+ branches:
+ - master
+ paths:
+ - "**/version.py"
+
+jobs:
+ build-n-publish:
+ name: Build and publish 📦 to PyPI
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@master
+ - name: Set up Python 3.7
+ uses: actions/setup-python@v2.1.4
+ with:
+ python-version: 3.7
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install setuptools wheel
+ - name: Build a binary wheel and a source tarball
+ run: >-
+ python setup.py sdist bdist_wheel
+ - name: Publish 📦 to PyPI
+ if: startsWith(github.ref, 'refs/tags')
+ uses: pypa/gh-action-pypi-publish@master
+ with:
+ password: ${{ secrets.pypi_prod_token }}
diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml
new file mode 100644
index 000000000..0fe4b695e
--- /dev/null
+++ b/.github/workflows/publish-to-test-pypi.yml
@@ -0,0 +1,31 @@
+name: Publish 📦 to TestPyPI
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ build-n-publish:
+ name: Build and publish 📦 to TestPyPI
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ with:
+ ref: development
+ - name: Set up Python 3.7
+ uses: actions/setup-python@v2.1.4
+ with:
+ python-version: 3.7
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install setuptools wheel
+ - name: Build a binary wheel and a source tarball
+ run: >-
+ python setup.py sdist bdist_wheel
+ - name: Publish 📦 to Test PyPI
+ uses: pypa/gh-action-pypi-publish@master
+ with:
+ password: ${{ secrets.PYPI_TEST_TOKEN }}
+ repository_url: https://test.pypi.org/legacy/
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
new file mode 100644
index 000000000..77151c343
--- /dev/null
+++ b/.github/workflows/pythonpackage.yml
@@ -0,0 +1,80 @@
+name: Python package
+
+on:
+ push:
+ branches:
+ - master
+ - development
+ pull_request:
+ branches:
+ - master
+ - development
+
+jobs:
+ black:
+ runs-on: ubuntu-latest
+ strategy:
+ max-parallel: 1
+ matrix:
+ python-version: [3.7]
+
+ steps:
+ - uses: actions/checkout@v2.3.3
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v2.1.4
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install black
+ - name: Check with black
+ run: |
+ black --check --diff src/pyatmo/ tests/ setup.py
+
+ linter:
+ runs-on: ubuntu-latest
+ strategy:
+ max-parallel: 4
+ matrix:
+ python-version: [ 3.8 ]
+
+ steps:
+ - uses: actions/checkout@v2.3.3
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v2.1.4
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install flake8
+ - 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 --max-line-length=127 --statistics
+
+ build:
+
+ runs-on: ubuntu-latest
+ strategy:
+ max-parallel: 4
+ matrix:
+ python-version: [3.7, 3.8, 3.9]
+
+ steps:
+ - uses: actions/checkout@v2.3.3
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v2.1.4
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e .
+ - name: Run tests with tox
+ run: |
+ pip install tox tox-gh-actions
+ tox
diff --git a/.github/workflows/release_gh.yml b/.github/workflows/release_gh.yml
new file mode 100644
index 000000000..4af6c0765
--- /dev/null
+++ b/.github/workflows/release_gh.yml
@@ -0,0 +1,32 @@
+# This is a basic workflow to help you get started with Actions
+
+name: Create Github Release
+
+# Controls when the action will run. Triggers the workflow on push or pull request
+# events but only for the development branch
+on:
+ push:
+ # Sequence of patterns matched against refs/tags
+ tags:
+ - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+ # This workflow contains a single job called "build"
+ build:
+ # The type of runner that the job will run on
+ runs-on: ubuntu-latest
+
+ # Steps represent a sequence of tasks that will be executed as part of the job
+ steps:
+ # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
+ - uses: actions/checkout@v2
+
+ # Runs a single command using the runners shell
+ - name: Create a Release
+ uses: actions/create-release@v1.1.4
+ with:
+ # The name of the tag. This should come from the webhook payload, `github.GITHUB_REF` when a user pushes a new tag
+ tag_name: ${{ github.ref }}
+ # The name of the release. For example, `Release v1.0.1`
+ release_name: Release ${{ github.ref }}
diff --git a/.gitignore b/.gitignore
index 2ac44a943..89a74b34f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,7 +3,314 @@ build/
dist/
-lnetatmo.egg-info/
+pyatmo.egg-info/
*.pyc
.DS_Store
+
+archive/
+access.token
+cov.xml
+
+venv/
+.venv
+
+# Created by https://www.gitignore.io/api/python,pycharm
+# Edit at https://www.gitignore.io/?templates=python,pycharm
+
+### PyCharm ###
+# 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/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+### PyCharm Patch ###
+# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
+
+# *.iml
+# modules.xml
+# .idea/misc.xml
+# *.ipr
+
+# Sonarlint plugin
+.idea/**/sonarlint/
+
+# SonarQube Plugin
+.idea/**/sonarIssues.xml
+
+# Markdown Navigator plugin
+.idea/**/markdown-navigator.xml
+.idea/**/markdown-navigator/
+
+### Python ###
+# 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
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# 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
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# Mr Developer
+.mr.developer.cfg
+.project
+.pydevproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# End of https://www.gitignore.io/api/python,pycharm
+
+# Created by https://www.gitignore.io/api/code
+# Edit at https://www.gitignore.io/?templates=code
+
+### Code ###
+.vscode/*
+
+# End of https://www.gitignore.io/api/code
+
+# Created by https://www.gitignore.io/api/jetbrains
+# Edit at https://www.gitignore.io/?templates=jetbrains
+
+### JetBrains ###
+# 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/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+### JetBrains Patch ###
+# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
+
+# *.iml
+# modules.xml
+# .idea/misc.xml
+# *.ipr
+
+# Sonarlint plugin
+.idea/**/sonarlint/
+
+# SonarQube Plugin
+.idea/**/sonarIssues.xml
+
+# Markdown Navigator plugin
+.idea/**/markdown-navigator.xml
+.idea/**/markdown-navigator/
+
+# End of https://www.gitignore.io/api/jetbrains
+.idea
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 000000000..eb0336668
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,84 @@
+# Note: don't use this config for your own repositories. Instead, see
+# "Version control integration" in README.md.
+default_stages: [commit, push]
+exclude: ^(fixtures/)
+repos:
+ - repo: https://github.com/asottile/seed-isort-config
+ rev: v2.2.0
+ hooks:
+ - id: seed-isort-config
+ args: [--application-directories,./src]
+
+ - repo: https://github.com/asottile/pyupgrade
+ rev: v2.7.4
+ hooks:
+ - id: pyupgrade
+ args: [--py37-plus]
+ exclude: 'external_src/int-tools'
+
+ - repo: https://github.com/asottile/add-trailing-comma
+ rev: v2.0.1
+ hooks:
+ - id: add-trailing-comma
+ args: [ --py36-plus ]
+ exclude: 'external_src/int-tools'
+
+ - repo: https://github.com/asottile/yesqa
+ rev: v1.2.2
+ hooks:
+ - id: yesqa
+ - repo: local
+ hooks:
+ - id: isort
+ name: isort
+ language: system
+ entry: pipenv run isort
+ types: [python]
+ exclude: tests/
+
+ - id: black
+ name: black
+ language: system
+ entry: pipenv run black
+ types: [python]
+
+ - id: pylint
+ name: pylint
+ language: system
+ entry: pipenv run pylint
+ types: [python]
+
+ - id: mypy
+ name: mypy
+ language: system
+ entry: pipenv run mypy
+ types: [python]
+ exclude: tests/
+
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v3.3.0 # Use the ref you want to point at
+ hooks:
+ - id: check-ast
+ - id: no-commit-to-branch
+ args: [--branch, master, --branch, devel]
+ - id: forbid-new-submodules
+ - id: check-merge-conflict
+ - id: detect-private-key
+ - id: end-of-file-fixer
+ - id: mixed-line-ending
+ args: [--fix=lf]
+ - id: trailing-whitespace
+ - id: debug-statements
+ - id: check-toml
+
+ - repo: https://gitlab.com/pycqa/flake8
+ rev: 3.8.4 # pick a git hash / tag to point to
+ hooks:
+ - id: flake8
+ exclude: (otp)
+ additional_dependencies: [flake8-typing-imports==1.10.0]
+
+ - repo: https://github.com/asottile/setup-cfg-fmt
+ rev: v1.15.1
+ hooks:
+ - id: setup-cfg-fmt
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 000000000..2ed283d65
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,43 @@
+[MESSAGES CONTROL]
+# Reasons disabled:
+# duplicate-code - unavoidable
+# cyclic-import - doesn't test if both import on load
+# unused-argument - generic callbacks and setup methods create a lot of warnings
+# global-statement - used for the on-demand requirement installation
+# too-many-* - are not enforced for the sake of readability
+# too-few-* - same as too-many-*
+# abstract-method - with intro of async there are always methods missing
+# not-an-iterable - https://github.com/PyCQA/pylint/issues/2311
+disable=
+ abstract-method,
+ cyclic-import,
+ duplicate-code,
+ global-statement,
+ inconsistent-return-statements,
+ missing-docstring,
+ too-few-public-methods,
+ too-many-arguments,
+ too-many-branches,
+ too-many-instance-attributes,
+ too-many-lines,
+ too-many-locals,
+ too-many-public-methods,
+ too-many-return-statements,
+ too-many-statements,
+ abstract-method,
+ not-an-iterable,
+ format,
+
+[REPORTS]
+reports=no
+
+[TYPECHECK]
+# For attrs
+ignored-classes=_CountingAttr
+generated-members=botocore.errorfactory
+
+[FORMAT]
+expected-line-ending-format=LF
+
+[EXCEPTIONS]
+overgeneral-exceptions=Exception
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 000000000..4742cd498
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 Hugo DUPRAS
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 000000000..3e677d057
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,2 @@
+include README.md
+include LICENSE.txt
diff --git a/Pipfile b/Pipfile
new file mode 100644
index 000000000..e3c6fb111
--- /dev/null
+++ b/Pipfile
@@ -0,0 +1,32 @@
+[[source]]
+name = "pypi"
+url = "https://pypi.org/simple"
+verify_ssl = true
+
+[dev-packages]
+black = "*"
+bleach = "~=3.1.4"
+docutils = "*"
+flake8 = "*"
+freezegun = "*"
+isort = "*"
+mypy = "*"
+pre-commit = "*"
+pylint = "*"
+pytest = "*"
+pytest-cov = "*"
+pytest-mock = "*"
+requests-mock = "*"
+tox = "*"
+twine = "*"
+
+[packages]
+requests = "*"
+requests-oauthlib = "*"
+pyatmo = {editable = true,path = "."}
+
+[requires]
+python_version = "3.7"
+
+[pipenv]
+allow_prereleases = true
diff --git a/Pipfile.lock b/Pipfile.lock
new file mode 100644
index 000000000..2ddb03f05
--- /dev/null
+++ b/Pipfile.lock
@@ -0,0 +1,692 @@
+{
+ "_meta": {
+ "hash": {
+ "sha256": "04da7ba3b0557449cfc88503e8949a784f2d0040ed3eec3db8c6f9ba9c30ce61"
+ },
+ "pipfile-spec": 6,
+ "requires": {
+ "python_version": "3.7"
+ },
+ "sources": [
+ {
+ "name": "pypi",
+ "url": "https://pypi.org/simple",
+ "verify_ssl": true
+ }
+ ]
+ },
+ "default": {
+ "certifi": {
+ "hashes": [
+ "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd",
+ "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"
+ ],
+ "version": "==2020.11.8"
+ },
+ "chardet": {
+ "hashes": [
+ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
+ "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+ ],
+ "version": "==3.0.4"
+ },
+ "idna": {
+ "hashes": [
+ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
+ "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.10"
+ },
+ "oauthlib": {
+ "hashes": [
+ "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889",
+ "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==3.1.0"
+ },
+ "pyatmo": {
+ "editable": true,
+ "path": "."
+ },
+ "requests": {
+ "hashes": [
+ "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
+ "sha256:5d2d0ffbb515f39417009a46c14256291061ac01ba8f875b90cad137de83beb4",
+ "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
+ ],
+ "index": "pypi",
+ "version": "==2.23.0"
+ },
+ "requests-oauthlib": {
+ "hashes": [
+ "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d",
+ "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a",
+ "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc"
+ ],
+ "index": "pypi",
+ "version": "==1.3.0"
+ },
+ "urllib3": {
+ "hashes": [
+ "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2",
+ "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
+ "version": "==1.25.11"
+ }
+ },
+ "develop": {
+ "appdirs": {
+ "hashes": [
+ "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
+ "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
+ ],
+ "version": "==1.4.4"
+ },
+ "astroid": {
+ "hashes": [
+ "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703",
+ "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386"
+ ],
+ "markers": "python_version >= '3.5'",
+ "version": "==2.4.2"
+ },
+ "attrs": {
+ "hashes": [
+ "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
+ "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==20.3.0"
+ },
+ "black": {
+ "hashes": [
+ "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"
+ ],
+ "index": "pypi",
+ "version": "==20.8b1"
+ },
+ "bleach": {
+ "hashes": [
+ "sha256:2bce3d8fab545a6528c8fa5d9f9ae8ebc85a56da365c7f85180bfe96a35ef22f",
+ "sha256:3c4c520fdb9db59ef139915a5db79f8b51bc2a7257ea0389f30c846883430a4b"
+ ],
+ "index": "pypi",
+ "version": "==3.1.5"
+ },
+ "certifi": {
+ "hashes": [
+ "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd",
+ "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"
+ ],
+ "version": "==2020.11.8"
+ },
+ "cfgv": {
+ "hashes": [
+ "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d",
+ "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"
+ ],
+ "markers": "python_full_version >= '3.6.1'",
+ "version": "==3.2.0"
+ },
+ "chardet": {
+ "hashes": [
+ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
+ "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+ ],
+ "version": "==3.0.4"
+ },
+ "click": {
+ "hashes": [
+ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
+ "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+ "version": "==7.1.2"
+ },
+ "colorama": {
+ "hashes": [
+ "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",
+ "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+ "version": "==0.4.4"
+ },
+ "coverage": {
+ "hashes": [
+ "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516",
+ "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259",
+ "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9",
+ "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097",
+ "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0",
+ "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f",
+ "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7",
+ "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c",
+ "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5",
+ "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7",
+ "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729",
+ "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978",
+ "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9",
+ "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f",
+ "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9",
+ "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822",
+ "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418",
+ "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82",
+ "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f",
+ "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d",
+ "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221",
+ "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4",
+ "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21",
+ "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709",
+ "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54",
+ "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d",
+ "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270",
+ "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24",
+ "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751",
+ "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a",
+ "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237",
+ "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7",
+ "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636",
+ "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
+ "version": "==5.3"
+ },
+ "distlib": {
+ "hashes": [
+ "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb",
+ "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"
+ ],
+ "version": "==0.3.1"
+ },
+ "docutils": {
+ "hashes": [
+ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af",
+ "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"
+ ],
+ "index": "pypi",
+ "version": "==0.16"
+ },
+ "filelock": {
+ "hashes": [
+ "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59",
+ "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"
+ ],
+ "version": "==3.0.12"
+ },
+ "flake8": {
+ "hashes": [
+ "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839",
+ "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"
+ ],
+ "index": "pypi",
+ "version": "==3.8.4"
+ },
+ "freezegun": {
+ "hashes": [
+ "sha256:02b35de52f4699a78f6ac4518e4cd3390dddc43b0aeb978335a8f270a2d9668b",
+ "sha256:1cf08e441f913ff5e59b19cc065a8faa9dd1ddc442eaf0375294f344581a0643"
+ ],
+ "index": "pypi",
+ "version": "==1.0.0"
+ },
+ "identify": {
+ "hashes": [
+ "sha256:943cd299ac7f5715fcb3f684e2fc1594c1e0f22a90d15398e5888143bd4144b5",
+ "sha256:cc86e6a9a390879dcc2976cef169dd9cc48843ed70b7380f321d1b118163c60e"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==1.5.10"
+ },
+ "idna": {
+ "hashes": [
+ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
+ "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.10"
+ },
+ "importlib-metadata": {
+ "hashes": [
+ "sha256:030f3b1bdb823ecbe4a9659e14cc861ce5af403fe99863bae173ec5fe00ab132",
+ "sha256:caeee3603f5dcf567864d1be9b839b0bcfdf1383e3e7be33ce2dead8144ff19c"
+ ],
+ "markers": "python_version < '3.8'",
+ "version": "==2.1.0"
+ },
+ "iniconfig": {
+ "hashes": [
+ "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3",
+ "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"
+ ],
+ "version": "==1.1.1"
+ },
+ "isort": {
+ "hashes": [
+ "sha256:dcab1d98b469a12a1a624ead220584391648790275560e1a43e54c5dceae65e7",
+ "sha256:dcaeec1b5f0eca77faea2a35ab790b4f3680ff75590bfcb7145986905aab2f58"
+ ],
+ "index": "pypi",
+ "version": "==5.6.4"
+ },
+ "keyring": {
+ "hashes": [
+ "sha256:12de23258a95f3b13e5b167f7a641a878e91eab8ef16fafc077720a95e6115bb",
+ "sha256:207bd66f2a9881c835dad653da04e196c678bf104f8252141d2d3c4f31051579"
+ ],
+ "markers": "python_version >= '3.6'",
+ "version": "==21.5.0"
+ },
+ "lazy-object-proxy": {
+ "hashes": [
+ "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d",
+ "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449",
+ "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08",
+ "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a",
+ "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50",
+ "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd",
+ "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239",
+ "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb",
+ "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea",
+ "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e",
+ "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156",
+ "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142",
+ "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442",
+ "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62",
+ "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db",
+ "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531",
+ "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383",
+ "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a",
+ "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357",
+ "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4",
+ "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==1.4.3"
+ },
+ "mccabe": {
+ "hashes": [
+ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
+ "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
+ ],
+ "version": "==0.6.1"
+ },
+ "mypy": {
+ "hashes": [
+ "sha256:0a0d102247c16ce93c97066443d11e2d36e6cc2a32d8ccc1f705268970479324",
+ "sha256:0d34d6b122597d48a36d6c59e35341f410d4abfa771d96d04ae2c468dd201abc",
+ "sha256:2170492030f6faa537647d29945786d297e4862765f0b4ac5930ff62e300d802",
+ "sha256:2842d4fbd1b12ab422346376aad03ff5d0805b706102e475e962370f874a5122",
+ "sha256:2b21ba45ad9ef2e2eb88ce4aeadd0112d0f5026418324176fd494a6824b74975",
+ "sha256:72060bf64f290fb629bd4a67c707a66fd88ca26e413a91384b18db3876e57ed7",
+ "sha256:af4e9ff1834e565f1baa74ccf7ae2564ae38c8df2a85b057af1dbbc958eb6666",
+ "sha256:bd03b3cf666bff8d710d633d1c56ab7facbdc204d567715cb3b9f85c6e94f669",
+ "sha256:c614194e01c85bb2e551c421397e49afb2872c88b5830e3554f0519f9fb1c178",
+ "sha256:cf4e7bf7f1214826cf7333627cb2547c0db7e3078723227820d0a2490f117a01",
+ "sha256:da56dedcd7cd502ccd3c5dddc656cb36113dd793ad466e894574125945653cea",
+ "sha256:e86bdace26c5fe9cf8cb735e7cedfe7850ad92b327ac5d797c656717d2ca66de",
+ "sha256:e97e9c13d67fbe524be17e4d8025d51a7dca38f90de2e462243ab8ed8a9178d1",
+ "sha256:eea260feb1830a627fb526d22fbb426b750d9f5a47b624e8d5e7e004359b219c"
+ ],
+ "index": "pypi",
+ "version": "==0.790"
+ },
+ "mypy-extensions": {
+ "hashes": [
+ "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
+ "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
+ ],
+ "version": "==0.4.3"
+ },
+ "nodeenv": {
+ "hashes": [
+ "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9",
+ "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"
+ ],
+ "version": "==1.5.0"
+ },
+ "packaging": {
+ "hashes": [
+ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
+ "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==20.4"
+ },
+ "pathspec": {
+ "hashes": [
+ "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd",
+ "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"
+ ],
+ "version": "==0.8.1"
+ },
+ "pkginfo": {
+ "hashes": [
+ "sha256:a6a4ac943b496745cec21f14f021bbd869d5e9b4f6ec06918cffea5a2f4b9193",
+ "sha256:ce14d7296c673dc4c61c759a0b6c14bae34e34eb819c0017bb6ca5b7292c56e9"
+ ],
+ "version": "==1.6.1"
+ },
+ "pluggy": {
+ "hashes": [
+ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
+ "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==0.13.1"
+ },
+ "pre-commit": {
+ "hashes": [
+ "sha256:949b13efb7467ae27e2c8f9e83434dacf2682595124d8902554a4e18351e5781",
+ "sha256:e31c04bc23741194a7c0b983fe512801e151a0638c6001c49f2bd034f8a664a1"
+ ],
+ "index": "pypi",
+ "version": "==2.9.2"
+ },
+ "py": {
+ "hashes": [
+ "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2",
+ "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==1.9.0"
+ },
+ "pycodestyle": {
+ "hashes": [
+ "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
+ "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.6.0"
+ },
+ "pyflakes": {
+ "hashes": [
+ "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
+ "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.2.0"
+ },
+ "pygments": {
+ "hashes": [
+ "sha256:381985fcc551eb9d37c52088a32914e00517e57f4a21609f48141ba08e193fa0",
+ "sha256:88a0bbcd659fcb9573703957c6b9cff9fab7295e6e76db54c9d00ae42df32773"
+ ],
+ "markers": "python_version >= '3.5'",
+ "version": "==2.7.2"
+ },
+ "pylint": {
+ "hashes": [
+ "sha256:bb4a908c9dadbc3aac18860550e870f58e1a02c9f2c204fdf5693d73be061210",
+ "sha256:bfe68f020f8a0fece830a22dd4d5dddb4ecc6137db04face4c3420a46a52239f"
+ ],
+ "index": "pypi",
+ "version": "==2.6.0"
+ },
+ "pyparsing": {
+ "hashes": [
+ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
+ "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
+ ],
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.4.7"
+ },
+ "pytest": {
+ "hashes": [
+ "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe",
+ "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"
+ ],
+ "index": "pypi",
+ "version": "==6.1.2"
+ },
+ "pytest-cov": {
+ "hashes": [
+ "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191",
+ "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"
+ ],
+ "index": "pypi",
+ "version": "==2.10.1"
+ },
+ "pytest-mock": {
+ "hashes": [
+ "sha256:024e405ad382646318c4281948aadf6fe1135632bea9cc67366ea0c4098ef5f2",
+ "sha256:a4d6d37329e4a893e77d9ffa89e838dd2b45d5dc099984cf03c703ac8411bb82"
+ ],
+ "index": "pypi",
+ "version": "==3.3.1"
+ },
+ "python-dateutil": {
+ "hashes": [
+ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
+ "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.8.1"
+ },
+ "pyyaml": {
+ "hashes": [
+ "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
+ "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
+ "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
+ "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e",
+ "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
+ "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
+ "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
+ "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
+ "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
+ "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a",
+ "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
+ "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
+ "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
+ ],
+ "version": "==5.3.1"
+ },
+ "readme-renderer": {
+ "hashes": [
+ "sha256:267854ac3b1530633c2394ead828afcd060fc273217c42ac36b6be9c42cd9a9d",
+ "sha256:6b7e5aa59210a40de72eb79931491eaf46fefca2952b9181268bd7c7c65c260a"
+ ],
+ "version": "==28.0"
+ },
+ "regex": {
+ "hashes": [
+ "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538",
+ "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4",
+ "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc",
+ "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa",
+ "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444",
+ "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1",
+ "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af",
+ "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8",
+ "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9",
+ "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88",
+ "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba",
+ "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364",
+ "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e",
+ "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7",
+ "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0",
+ "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31",
+ "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683",
+ "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee",
+ "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b",
+ "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884",
+ "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c",
+ "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e",
+ "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562",
+ "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85",
+ "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c",
+ "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6",
+ "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d",
+ "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b",
+ "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70",
+ "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b",
+ "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b",
+ "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f",
+ "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0",
+ "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5",
+ "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5",
+ "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f",
+ "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e",
+ "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512",
+ "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d",
+ "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917",
+ "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f"
+ ],
+ "version": "==2020.11.13"
+ },
+ "requests": {
+ "hashes": [
+ "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
+ "sha256:5d2d0ffbb515f39417009a46c14256291061ac01ba8f875b90cad137de83beb4",
+ "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
+ ],
+ "index": "pypi",
+ "version": "==2.23.0"
+ },
+ "requests-mock": {
+ "hashes": [
+ "sha256:11215c6f4df72702aa357f205cf1e537cffd7392b3e787b58239bde5fb3db53b",
+ "sha256:e68f46844e4cee9d447150343c9ae875f99fa8037c6dcf5f15bf1fe9ab43d226"
+ ],
+ "index": "pypi",
+ "version": "==1.8.0"
+ },
+ "requests-toolbelt": {
+ "hashes": [
+ "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f",
+ "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"
+ ],
+ "version": "==0.9.1"
+ },
+ "rfc3986": {
+ "hashes": [
+ "sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d",
+ "sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50"
+ ],
+ "version": "==1.4.0"
+ },
+ "six": {
+ "hashes": [
+ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
+ "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==1.15.0"
+ },
+ "toml": {
+ "hashes": [
+ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
+ "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
+ ],
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==0.10.2"
+ },
+ "tox": {
+ "hashes": [
+ "sha256:42ce19ce5dc2f6d6b1fdc5666c476e1f1e2897359b47e0aa3a5b774f335d57c2",
+ "sha256:4321052bfe28f9d85082341ca8e233e3ea901fdd14dab8a5d3fbd810269fbaf6"
+ ],
+ "index": "pypi",
+ "version": "==3.20.1"
+ },
+ "tqdm": {
+ "hashes": [
+ "sha256:5c0d04e06ccc0da1bd3fa5ae4550effcce42fcad947b4a6cafa77bdc9b09ff22",
+ "sha256:9e7b8ab0ecbdbf0595adadd5f0ebbb9e69010e0bd48bbb0c15e550bf2a5292df"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==4.54.0"
+ },
+ "twine": {
+ "hashes": [
+ "sha256:34352fd52ec3b9d29837e6072d5a2a7c6fe4290e97bba46bb8d478b5c598f7ab",
+ "sha256:ba9ff477b8d6de0c89dd450e70b2185da190514e91c42cc62f96850025c10472"
+ ],
+ "index": "pypi",
+ "version": "==3.2.0"
+ },
+ "typed-ast": {
+ "hashes": [
+ "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
+ "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
+ "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d",
+ "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
+ "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
+ "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
+ "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c",
+ "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
+ "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
+ "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
+ "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
+ "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
+ "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
+ "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d",
+ "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
+ "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
+ "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c",
+ "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
+ "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395",
+ "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
+ "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
+ "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
+ "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
+ "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
+ "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072",
+ "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298",
+ "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91",
+ "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
+ "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f",
+ "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
+ ],
+ "markers": "python_version < '3.8' and implementation_name == 'cpython'",
+ "version": "==1.4.1"
+ },
+ "typing-extensions": {
+ "hashes": [
+ "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
+ "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
+ "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
+ ],
+ "version": "==3.7.4.3"
+ },
+ "urllib3": {
+ "hashes": [
+ "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2",
+ "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
+ "version": "==1.25.11"
+ },
+ "virtualenv": {
+ "hashes": [
+ "sha256:07cff122e9d343140366055f31be4dcd61fd598c69d11cd33a9d9c8df4546dd7",
+ "sha256:e0aac7525e880a429764cefd3aaaff54afb5d9f25c82627563603f5d7de5a6e5"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==20.2.1"
+ },
+ "webencodings": {
+ "hashes": [
+ "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
+ "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
+ ],
+ "version": "==0.5.1"
+ },
+ "wrapt": {
+ "hashes": [
+ "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"
+ ],
+ "version": "==1.12.1"
+ },
+ "zipp": {
+ "hashes": [
+ "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108",
+ "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"
+ ],
+ "markers": "python_version >= '3.6'",
+ "version": "==3.4.0"
+ }
+ }
+}
diff --git a/README.md b/README.md
index 7b6bdeec9..7f8bb6619 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,67 @@
netatmo-api-python
==================
-Simple API to access Netatmo weather station data from any python script
-For more detailed information see http://dev.netatmo.com
+[](https://github.com/ambv/black)
+[](https://github.com/jabesq/netatmo-api-python/actions?workflow=Python+package)
+[](https://pypi.python.org/pypi/pyatmo)
+[](https://github.com/jabesq/netatmo-api-python/blob/master/LICENSE.txt)
-I have no relation with the netatmo company, I wrote this because I needed it myself,
-and published it to save time to anyone who would have same needs.
+Simple API to access Netatmo devices and data like weather station or camera data from Python 3.
+For more detailed information see [dev.netatmo.com](http://dev.netatmo.com)
-### Install ###
+This project has no relation with the Netatmo company.
-To install lnetatmo simply run:
+Install
+-------
- python setup.py install
+To install pyatmo simply run:
+
+ pip install pyatmo
Depending on your permissions you might be required to use sudo.
-Once installed you can simple add lnetatmo to your python scripts by including:
+Once installed you can simple add `pyatmo` to your Python 3 scripts by including:
+
+ import pyatmo
+
+Note
+----
+
+The module requires a valid user account and a registered application. See [usage.md](./usage.md) for further information.
+Be aware that the module may stop working if Netatmo decides to change their API.
+
+Development
+-----------
+
+Clone the repo and install dependencies:
+
+ git clone
+ cd netatmo-api-python
+ pipenv install --dev
+
+To add the pre-commit hook to your environment run:
+
+ pip install pre-commit
+ pre-commit install
+
+Testing
+-------
+
+To run the full suite simply run the following command from within the virtual environment:
+
+ pytest
+
+or
+
+ python -m pytest tests/
+
+To generate code coverage xml (e.g. for use in VSCode) run
+
+ python -m pytest --cov-report xml:cov.xml --cov smart_home --cov-append tests/
+
+Another way to run the tests is by using `tox`. This runs the tests against the installed package and multiple versions of python.
- import lnetatmo
+ tox
-### Note ###
+or by specifying a python version
-this release is an interim release waiting for Netatmo dashboard finalization. As any work in progress, it can stop to work at any time if serious changes occurs.
+ tox -e py38
diff --git a/fixtures/camera_data_empty.json b/fixtures/camera_data_empty.json
new file mode 100644
index 000000000..63c51349f
--- /dev/null
+++ b/fixtures/camera_data_empty.json
@@ -0,0 +1,5 @@
+{
+ "status": "ok",
+ "time_exec": 0.03621506690979,
+ "time_server": 1560626960
+}
\ No newline at end of file
diff --git a/fixtures/camera_data_events_until.json b/fixtures/camera_data_events_until.json
new file mode 100644
index 000000000..941d3c1f9
--- /dev/null
+++ b/fixtures/camera_data_events_until.json
@@ -0,0 +1,100 @@
+{
+ "body": {
+ "events_list": [
+ {
+ "id": "a1b2c3d4e5f6abcdef123461",
+ "type": "person",
+ "time": 1560706232,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "person_id": "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "video_status": "deleted",
+ "is_arrival": true,
+ "message": "John Doe gesehen"
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef123462",
+ "type": "person_away",
+ "time": 1560706237,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "person_id": "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "message": "John Doe hat das Haus verlassen",
+ "sub_message": "John Doe gilt als „Abwesend“, da das mit diesem Profil verbundene Telefon den Bereich des Hauses verlassen hat."
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef123463",
+ "type": "person",
+ "time": 1560706241,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "person_id": "91827375-7e04-5298-83ae-a0cb8372dff2",
+ "snapshot": {
+ "id": "19b13efa945ec892c6da2a8c",
+ "version": 1,
+ "key": "1704853cfc9571bd10618591dc9035e5bc0fa3203f44739c49a5b26d2f7ad67f",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/5ecfa94c6da5e5bc0fa3203f3cfdc903489219b13e2a8c548547b26d2f7ad6717039c49ac9571bd10618591f"
+ },
+ "video_id": "f914-aa7da416643-4744-82f9-4e7d4440b",
+ "video_status": "available",
+ "is_arrival": false,
+ "message": "Jane Doe gesehen"
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef123464",
+ "type": "wifi_status",
+ "time": 1560706271,
+ "camera_id": "12:34:56:00:8b:a2",
+ "device_id": "12:34:56:00:8b:a2",
+ "sub_type": 1,
+ "message": "Hall:WLAN-Verbindung erfolgreich hergestellt"
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef123465",
+ "type": "outdoor",
+ "time": 1560706283,
+ "camera_id": "12:34:56:00:a5:a4",
+ "device_id": "12:34:56:00:a5:a4",
+ "video_id": "string",
+ "video_status": "available",
+ "event_list": [
+ {
+ "type": "string",
+ "time": 1560706283,
+ "offset": 0,
+ "id": "c81bcf7b-2cfg-4ac9-8455-487ed00c0001",
+ "message": "Animal détecté",
+ "snapshot": {
+ "id": "5715e16849c75xxxx00000000xxxxx",
+ "version": 1,
+ "key": "7ac578d05030d0e170643a787ee0a29663dxxx00000xxxxx00000",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/1aa"
+ },
+ "vignette": {
+ "id": "5715e16849c75xxxx00000000xxxxx",
+ "version": 1,
+ "key": "7ac578d05030d0e170643a787ee0a29663dxxx00000xxxxx00000",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/1aa00000"
+ }
+ },
+ {
+ "type": "string",
+ "time": 1560706283,
+ "offset": 0,
+ "id": "c81bcf7b-2cfg-4ac9-8455-487ed00c0002",
+ "message": "Animal détecté",
+ "snapshot": {
+ "filename": "vod\/af74631d-8311-42dc-825b-82e3abeaab09\/events\/c53b-aze7a.jpg"
+ },
+ "vignette": {
+ "filename": "vod\/af74631d-8311-42dc-825b-82e3abeaab09\/events\/c5.jpg"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "status": "ok",
+ "time_exec": 0.03666909215079,
+ "time_server": 15607062321
+}
\ No newline at end of file
diff --git a/fixtures/camera_home_data.json b/fixtures/camera_home_data.json
new file mode 100644
index 000000000..ab850b03c
--- /dev/null
+++ b/fixtures/camera_home_data.json
@@ -0,0 +1,318 @@
+{
+ "body": {
+ "homes": [
+ {
+ "id": "91763b24c43d3e344f424e8b",
+ "name": "MYHOME",
+ "persons": [
+ {
+ "id": "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "last_seen": 1557071156,
+ "out_of_sight": true,
+ "face": {
+ "id": "d74fad765b9100ef480720a9",
+ "version": 1,
+ "key": "a4a95c24b808a89f8d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d7",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c24b808a89f8d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d7"
+ },
+ "pseudo": "John Doe"
+ },
+ {
+ "id": "91827375-7e04-5298-83ae-a0cb8372dff2",
+ "last_seen": 1560600726,
+ "out_of_sight": true,
+ "face": {
+ "id": "d74fad765b9100ef480720a9",
+ "version": 3,
+ "key": "a4a95c24b808a89f8d1730039d2c69928b029d67fc40cb2d7fb69ecdf2bb8b72",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c24b808a89f8d1730039d2c69928b029d67fc40cb2d7fb69ecdf2bb8b72"
+ },
+ "pseudo": "Jane Doe"
+ },
+ {
+ "id": "91827376-7e04-5298-83af-a0cb8372dff3",
+ "last_seen": 1560626666,
+ "out_of_sight": false,
+ "face": {
+ "id": "d74fad765b9100ef480720a9",
+ "version": 1,
+ "key": "a4a95c2d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d74b808a89f8",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c2d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d74b808a89f8"
+ },
+ "pseudo": "Richard Doe"
+ },
+ {
+ "id": "91827376-7e04-5298-83af-a0cb8372dff4",
+ "last_seen": 1560621666,
+ "out_of_sight": true,
+ "face": {
+ "id": "d0ef44fad765b980720710a9",
+ "version": 1,
+ "key": "ab029da89f84a95c2d1730fb67fc40cb2d74b80869ecdf2bb8b72039d2c69928",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d0ef44fad765b980720710a9ab029da89f84a95c2d1730fb67fc40cb2d74b80869ecdf2bb8b72039d2c69928"
+ }
+ }
+ ],
+ "place": {
+ "city": "Frankfurt",
+ "country": "DE",
+ "timezone": "Europe/Berlin"
+ },
+ "cameras": [
+ {
+ "id": "12:34:56:00:f1:62",
+ "type": "NACamera",
+ "status": "on",
+ "vpn_url": "https://prodvpn-eu-2.netatmo.net/restricted/10.255.248.91/6d278460699e56180d47ab47169efb31/MpEylTU2MDYzNjRVD-LJxUnIndumKzLboeAwMDqTTg,,",
+ "is_local": true,
+ "sd_status": "on",
+ "alim_status": "on",
+ "name": "Hall",
+ "modules": [
+ {
+ "id": "12:34:56:00:f2:f1",
+ "type": "NIS",
+ "battery_percent": 84,
+ "rf": 68,
+ "status": "no_news",
+ "monitoring": "on",
+ "alim_source": "battery",
+ "tamper_detection_enabled": true,
+ "name": "Welcome's Siren"
+ }
+ ],
+ "use_pin_code": false,
+ "last_setup": 1544828430
+ },
+ {
+ "id": "12:34:56:00:a5:a4",
+ "type": "NOC",
+ "status": "on",
+ "vpn_url": "https://prodvpn-eu-2.netatmo.net/restricted/10.255.248.91/6d278460699e56180d47ab47169efb31/MpEylTU2MDYzNjRVD-LJxUnIndumKzLboeAwMDqTTw,,",
+ "is_local": true,
+ "sd_status": "on",
+ "alim_status": "on",
+ "name": "Garden",
+ "last_setup": 1563737661,
+ "light_mode_status": "auto"
+ }
+ ],
+ "smokedetectors": [
+ {
+ "id": "12:34:56:00:8b:a2",
+ "type": "NSD",
+ "last_setup": 1567261859,
+ "name": "Hall"
+ },
+ {
+ "id": "12:34:56:00:8b:ac",
+ "type": "NSD",
+ "last_setup": 1567262759,
+ "name": "Kitchen"
+ }
+ ],
+ "events": [
+ {
+ "id": "a1b2c3d4e5f6abcdef123456",
+ "type": "person",
+ "time": 1560604700,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "person_id": "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "video_status": "deleted",
+ "is_arrival": false,
+ "message": "John Doe gesehen"
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef123457",
+ "type": "person_away",
+ "time": 1560602400,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "person_id": "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "message": "John Doe hat das Haus verlassen",
+ "sub_message": "John Doe gilt als abwesend, da das mit diesem Profil verbundene Telefon den Bereich des Hauses verlassen hat."
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef123458",
+ "type": "person",
+ "time": 1560601200,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "person_id": "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "video_status": "deleted",
+ "is_arrival": false,
+ "message": "John Doe gesehen"
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef123459",
+ "type": "person",
+ "time": 1560600100,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "person_id": "91827375-7e04-5298-83ae-a0cb8372dff2",
+ "snapshot": {
+ "id": "d74fad765b9100ef480720a9",
+ "version": 1,
+ "key": "a4a95c24b808a89f8d1730039d2c69928b029d67fc40cb2d7fb69ecdf2bb8b72",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c24b808a89f8d1730039d2c69928b029d67fc40cb2d7fb69ecdf2bb8b72"
+ },
+ "video_id": "12345678-36bc-4b9a-9762-5194e707ed51",
+ "video_status": "available",
+ "is_arrival": false,
+ "message": "Jane Doe gesehen"
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef12345a",
+ "type": "person",
+ "time": 1560603600,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "person_id": "91827375-7e04-5298-83ae-a0cb8372dff3",
+ "snapshot": {
+ "id": "532dde8d17554c022ab071b8",
+ "version": 1,
+ "key": "9fbe490fffacf45b8416241946541b031a004a09b6747feb6c38c3ccbc456b28",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/532dde8d17554c022ab071b89fbe490fffacf45b8416241946541b031a004a09b6747feb6c38c3ccbc456b28"
+ },
+ "video_id": "12345678-1234-46cb-ad8f-23d893874099",
+ "video_status": "available",
+ "is_arrival": false,
+ "message": "Bewegung erkannt"
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef12345b",
+ "type": "movement",
+ "time": 1560506200,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "category": "human",
+ "snapshot": {
+ "id": "532dde8d17554c022ab071b9",
+ "version": 1,
+ "key": "8fbe490fffacf45b8416241946541b031a004a09b6747feb6c38c3ccbc456b28",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/532dde8d17554c022ab071b98fbe490fffacf45b8416241946541b031a004a09b6747feb6c38c3ccbc456b28"
+ },
+ "vignette": {
+ "id": "5dc021b5dea854bd2321707a",
+ "version": 1,
+ "key": "58c5a05bd6bd908f6bf368865ef7355231c44215f8eb7ae458c919b2c67b4944",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/5dc021b5dea854bd2321707a58c5a05bd6bd908f6bf368865ef7355231c44215f8eb7ae458c919b2c67b4944"
+ },
+ "video_id": "12345678-1234-46cb-ad8f-23d89387409a",
+ "video_status": "available",
+ "message": "Bewegung erkannt"
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef12345c",
+ "type": "sound_test",
+ "time": 1560506210,
+ "camera_id": "12:34:56:00:8b:a2",
+ "device_id": "12:34:56:00:8b:a2",
+ "sub_type": 0,
+ "message": "Hall: Alarmton erfolgreich getestet"
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef12345d",
+ "type": "wifi_status",
+ "time": 1560506220,
+ "camera_id": "12:34:56:00:8b:a2",
+ "device_id": "12:34:56:00:8b:a2",
+ "sub_type": 1,
+ "message": "Hall:WLAN-Verbindung erfolgreich hergestellt"
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef12345e",
+ "type": "outdoor",
+ "time": 1560643100,
+ "camera_id": "12:34:56:00:a5:a4",
+ "device_id": "12:34:56:00:a5:a4",
+ "video_id": "string",
+ "video_status": "available",
+ "event_list": [
+ {
+ "type": "string",
+ "time": 1560643100,
+ "offset": 0,
+ "id": "c81bcf7b-2cfg-4ac9-8455-487ed00c0000",
+ "message": "Animal détecté",
+ "snapshot": {
+ "id": "5715e16849c75xxxx00000000xxxxx",
+ "version": 1,
+ "key": "7ac578d05030d0e170643a787ee0a29663dxxx00000xxxxx00000",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/1aa"
+ },
+ "vignette": {
+ "id": "5715e16849c75xxxx00000000xxxxx",
+ "version": 1,
+ "key": "7ac578d05030d0e170643a787ee0a29663dxxx00000xxxxx00000",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/1aa00000"
+ }
+ },
+ {
+ "type": "string",
+ "time": 1560506222,
+ "offset": 0,
+ "id": "c81bcf7b-2cfg-4ac9-8455-487ed00c0000",
+ "message": "Animal détecté",
+ "snapshot": {
+ "filename": "vod\/af74631d-8311-42dc-825b-82e3abeaab09\/events\/c53b-aze7a.jpg"
+ },
+ "vignette": {
+ "filename": "vod\/af74631d-8311-42dc-825b-82e3abeaab09\/events\/c5.jpg"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "91763b24c43d3e344f424e8c",
+ "persons": [],
+ "place": {
+ "city": "Frankfurt",
+ "country": "DE",
+ "timezone": "Europe/Berlin"
+ },
+ "cameras": [
+ {
+ "id": "12:34:56:00:a5:a5",
+ "type": "NOC",
+ "status": "on",
+ "vpn_url": "https://prodvpn-eu-2.netatmo.net/restricted/10.255.248.91/6d278460699e56180d47ab47169efb31/MpEylTU2MDYzNjRVD-LJxUnIndumKzLboeAwMDqTTz,,",
+ "is_local": true,
+ "sd_status": "on",
+ "alim_status": "on",
+ "name": "Street",
+ "last_setup": 1563737561,
+ "light_mode_status": "auto"
+ }
+ ],
+ "smokedetectors": []
+ },
+ {
+ "id": "91763b24c43d3e344f424e8d",
+ "persons": [],
+ "place": {
+ "city": "Frankfurt",
+ "country": "DE",
+ "timezone": "Europe/Berlin"
+ },
+ "cameras": [],
+ "smokedetectors": []
+ }
+ ],
+ "user": {
+ "reg_locale": "de-DE",
+ "lang": "de-DE",
+ "country": "DE",
+ "mail": "john@doe.com"
+ },
+ "global_info": {
+ "show_tags": true
+ }
+ },
+ "status": "ok",
+ "time_exec": 0.03621506690979,
+ "time_server": 1560626960
+}
\ No newline at end of file
diff --git a/fixtures/camera_home_data_disconnected.json b/fixtures/camera_home_data_disconnected.json
new file mode 100644
index 000000000..c0ee0d810
--- /dev/null
+++ b/fixtures/camera_home_data_disconnected.json
@@ -0,0 +1,182 @@
+{
+ "body": {
+ "homes": [
+ {
+ "id": "91763b24c43d3e344f424e8b",
+ "name": "MYHOME",
+ "persons": [
+ {
+ "id": "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "last_seen": 1557071156,
+ "out_of_sight": true,
+ "face": {
+ "id": "d74fad765b9100ef480720a9",
+ "version": 1,
+ "key": "a4a95c24b808a89f8d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d7",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c24b808a89f8d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d7"
+ },
+ "pseudo": "John Doe"
+ },
+ {
+ "id": "91827375-7e04-5298-83ae-a0cb8372dff2",
+ "last_seen": 1560600726,
+ "out_of_sight": true,
+ "face": {
+ "id": "d74fad765b9100ef480720a9",
+ "version": 3,
+ "key": "a4a95c24b808a89f8d1730039d2c69928b029d67fc40cb2d7fb69ecdf2bb8b72",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c24b808a89f8d1730039d2c69928b029d67fc40cb2d7fb69ecdf2bb8b72"
+ },
+ "pseudo": "Jane Doe"
+ },
+ {
+ "id": "91827376-7e04-5298-83af-a0cb8372dff3",
+ "last_seen": 1560626666,
+ "out_of_sight": false,
+ "face": {
+ "id": "d74fad765b9100ef480720a9",
+ "version": 1,
+ "key": "a4a95c2d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d74b808a89f8",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c2d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d74b808a89f8"
+ },
+ "pseudo": "Richard Doe"
+ },
+ {
+ "id": "91827376-7e04-5298-83af-a0cb8372dff4",
+ "last_seen": 1560621666,
+ "out_of_sight": true,
+ "face": {
+ "id": "d0ef44fad765b980720710a9",
+ "version": 1,
+ "key": "ab029da89f84a95c2d1730fb67fc40cb2d74b80869ecdf2bb8b72039d2c69928",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d0ef44fad765b980720710a9ab029da89f84a95c2d1730fb67fc40cb2d74b80869ecdf2bb8b72039d2c69928"
+ }
+ }
+ ],
+ "place": {
+ "city": "Frankfurt",
+ "country": "DE",
+ "timezone": "Europe/Berlin"
+ },
+ "cameras": [
+ {
+ "id": "12:34:56:00:f1:62",
+ "type": "NACamera",
+ "status": "disconnected",
+ "sd_status": "on",
+ "alim_status": "on",
+ "name": "Hall",
+ "use_pin_code": false,
+ "last_setup": 1544828430
+ }
+ ],
+ "smokedetectors": [],
+ "events": [
+ {
+ "id": "a1b2c3d4e5f6abcdef123456",
+ "type": "person",
+ "time": 1560604700,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "person_id": "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "video_status": "deleted",
+ "is_arrival": false,
+ "message": "John Doe gesehen"
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef123457",
+ "type": "person_away",
+ "time": 1560602400,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "person_id": "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "message": "John Doe hat das Haus verlassen",
+ "sub_message": "John Doe gilt als abwesend, da das mit diesem Profil verbundene Telefon den Bereich des Hauses verlassen hat."
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef123458",
+ "type": "person",
+ "time": 1560601200,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "person_id": "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "video_status": "deleted",
+ "is_arrival": false,
+ "message": "John Doe gesehen"
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef123459",
+ "type": "person",
+ "time": 1560600100,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "person_id": "91827375-7e04-5298-83ae-a0cb8372dff2",
+ "snapshot": {
+ "id": "d74fad765b9100ef480720a9",
+ "version": 1,
+ "key": "a4a95c24b808a89f8d1730039d2c69928b029d67fc40cb2d7fb69ecdf2bb8b72",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c24b808a89f8d1730039d2c69928b029d67fc40cb2d7fb69ecdf2bb8b72"
+ },
+ "video_id": "12345678-36bc-4b9a-9762-5194e707ed51",
+ "video_status": "available",
+ "is_arrival": false,
+ "message": "Jane Doe gesehen"
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef12345a",
+ "type": "person",
+ "time": 1560603600,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "person_id": "91827375-7e04-5298-83ae-a0cb8372dff3",
+ "snapshot": {
+ "id": "532dde8d17554c022ab071b8",
+ "version": 1,
+ "key": "9fbe490fffacf45b8416241946541b031a004a09b6747feb6c38c3ccbc456b28",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/532dde8d17554c022ab071b89fbe490fffacf45b8416241946541b031a004a09b6747feb6c38c3ccbc456b28"
+ },
+ "video_id": "12345678-1234-46cb-ad8f-23d893874099",
+ "video_status": "available",
+ "is_arrival": false,
+ "message": "Bewegung erkannt"
+ },
+ {
+ "id": "a1b2c3d4e5f6abcdef12345b",
+ "type": "movement",
+ "time": 1560506200,
+ "camera_id": "12:34:56:00:f1:62",
+ "device_id": "12:34:56:00:f1:62",
+ "category": "human",
+ "snapshot": {
+ "id": "532dde8d17554c022ab071b9",
+ "version": 1,
+ "key": "8fbe490fffacf45b8416241946541b031a004a09b6747feb6c38c3ccbc456b28",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/532dde8d17554c022ab071b98fbe490fffacf45b8416241946541b031a004a09b6747feb6c38c3ccbc456b28"
+ },
+ "vignette": {
+ "id": "5dc021b5dea854bd2321707a",
+ "version": 1,
+ "key": "58c5a05bd6bd908f6bf368865ef7355231c44215f8eb7ae458c919b2c67b4944",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/5dc021b5dea854bd2321707a58c5a05bd6bd908f6bf368865ef7355231c44215f8eb7ae458c919b2c67b4944"
+ },
+ "video_id": "12345678-1234-46cb-ad8f-23d89387409a",
+ "video_status": "available",
+ "message": "Bewegung erkannt"
+ }
+ ]
+ }
+ ],
+ "user": {
+ "reg_locale": "de-DE",
+ "lang": "de-DE",
+ "country": "DE",
+ "mail": "john@doe.com"
+ },
+ "global_info": {
+ "show_tags": true
+ }
+ },
+ "status": "ok",
+ "time_exec": 0.03621506690979,
+ "time_server": 1560626960
+}
\ No newline at end of file
diff --git a/fixtures/camera_home_data_no_homes.json b/fixtures/camera_home_data_no_homes.json
new file mode 100644
index 000000000..78e1450d5
--- /dev/null
+++ b/fixtures/camera_home_data_no_homes.json
@@ -0,0 +1,17 @@
+{
+ "body": {
+ "homes": [],
+ "user": {
+ "reg_locale": "de-DE",
+ "lang": "de-DE",
+ "country": "DE",
+ "mail": "john@doe.com"
+ },
+ "global_info": {
+ "show_tags": true
+ }
+ },
+ "status": "ok",
+ "time_exec": 0.03621506690979,
+ "time_server": 1560626960
+}
\ No newline at end of file
diff --git a/fixtures/camera_image_sample.jpg b/fixtures/camera_image_sample.jpg
new file mode 100644
index 000000000..40dcd0914
Binary files /dev/null and b/fixtures/camera_image_sample.jpg differ
diff --git a/fixtures/camera_ping.json b/fixtures/camera_ping.json
new file mode 100644
index 000000000..784975de5
--- /dev/null
+++ b/fixtures/camera_ping.json
@@ -0,0 +1,4 @@
+{
+ "local_url": "http://192.168.0.123/678460a0d47e5618699fb31169e2b47d",
+ "product_name": "Welcome Netatmo"
+}
\ No newline at end of file
diff --git a/fixtures/camera_set_state_error.json b/fixtures/camera_set_state_error.json
new file mode 100644
index 000000000..43c25b230
--- /dev/null
+++ b/fixtures/camera_set_state_error.json
@@ -0,0 +1,6 @@
+{
+ "error": {
+ "code": 21,
+ "message": "Invalid device_id, 12:34:56:00:f1:ff"
+ }
+}
\ No newline at end of file
diff --git a/fixtures/camera_set_state_error_already_on.json b/fixtures/camera_set_state_error_already_on.json
new file mode 100644
index 000000000..1251647e5
--- /dev/null
+++ b/fixtures/camera_set_state_error_already_on.json
@@ -0,0 +1,17 @@
+{
+ "status": "ok",
+ "time_server": 1582932399,
+ "body": {
+ "home": {
+ "id": "91763b24c43d3e344f424e8b"
+ },
+ "errors": [
+ {
+ "code": 23,
+ "message": "Already on",
+ "id": "12:34:56:00:f1:62",
+ "command": "command/changestatus"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/fixtures/camera_set_state_error_wrong_parameter.json b/fixtures/camera_set_state_error_wrong_parameter.json
new file mode 100644
index 000000000..9575dd2a4
--- /dev/null
+++ b/fixtures/camera_set_state_error_wrong_parameter.json
@@ -0,0 +1,6 @@
+{
+ "error": {
+ "code": 21,
+ "message": "cannot set property floodlight for module 12:34:56:00:f1:62"
+ }
+}
\ No newline at end of file
diff --git a/fixtures/camera_set_state_ok.json b/fixtures/camera_set_state_ok.json
new file mode 100644
index 000000000..0885d3d37
--- /dev/null
+++ b/fixtures/camera_set_state_ok.json
@@ -0,0 +1,4 @@
+{
+ "status": "ok",
+ "time_server": 1582932411
+}
\ No newline at end of file
diff --git a/fixtures/error_scope.json b/fixtures/error_scope.json
new file mode 100644
index 000000000..cfbbb8e1f
--- /dev/null
+++ b/fixtures/error_scope.json
@@ -0,0 +1 @@
+{"error":{"code":13,"message":"Application does not have the good scope rights"}}
diff --git a/fixtures/home_coach_no_devices.json b/fixtures/home_coach_no_devices.json
new file mode 100644
index 000000000..95d0181f8
--- /dev/null
+++ b/fixtures/home_coach_no_devices.json
@@ -0,0 +1,20 @@
+{
+ "body": {
+ "devices": [],
+ "user": {
+ "mail": "john@doe.com",
+ "administrative": {
+ "lang": "de-DE",
+ "reg_locale": "de-DE",
+ "country": "DE",
+ "unit": 0,
+ "windunit": 0,
+ "pressureunit": 0,
+ "feel_like_algo": 0
+ }
+ }
+ },
+ "status": "ok",
+ "time_exec": 0.05824708938598633,
+ "time_server": 1565377059
+}
\ No newline at end of file
diff --git a/fixtures/home_coach_simple.json b/fixtures/home_coach_simple.json
new file mode 100644
index 000000000..3f9de74bd
--- /dev/null
+++ b/fixtures/home_coach_simple.json
@@ -0,0 +1,202 @@
+{
+ "body": {
+ "devices": [
+ {
+ "_id": "12:34:56:26:69:0c",
+ "cipher_id": "enc:16:1UqwQlYV5AY2pfyEi5H47dmmFOOL3mCUo+KAkchL4A2CLI5u0e45Xr5jeAswO+XO",
+ "date_setup": 1544560184,
+ "last_setup": 1544560184,
+ "type": "NHC",
+ "last_status_store": 1558268332,
+ "firmware": 45,
+ "last_upgrade": 1544560186,
+ "wifi_status": 58,
+ "reachable": false,
+ "co2_calibrating": false,
+ "station_name": "Bedroom",
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure",
+ "health_idx"
+ ],
+ "place": {
+ "city": "Frankfurt",
+ "country": "DE",
+ "timezone": "Europe/Berlin",
+ "location": [
+ 52.516263,
+ 13.377726
+ ]
+ }
+ },
+ {
+ "_id": "12:34:56:25:cf:a8",
+ "cipher_id": "enc:16:A+Jm0yFWBwUyKinFDutPZK7I2PuHN1fqaE9oB/KF+McbFs3oN9CKpR/dYbqL4om2",
+ "date_setup": 1544562192,
+ "last_setup": 1544562192,
+ "type": "NHC",
+ "last_status_store": 1559198922,
+ "firmware": 45,
+ "last_upgrade": 1544562194,
+ "wifi_status": 41,
+ "reachable": true,
+ "co2_calibrating": false,
+ "station_name": "Kitchen",
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure",
+ "health_idx"
+ ],
+ "place": {
+ "city": "Frankfurt",
+ "country": "DE",
+ "timezone": "Europe/Berlin",
+ "location": [
+ 52.516263,
+ 13.377726
+ ]
+ }
+ },
+ {
+ "_id": "12:34:56:26:65:14",
+ "cipher_id": "enc:16:7kK6ZzG4L7NgfZZ6+dMvNxw4l6vXu+88SEJkCUklNdPa4KYIHmsfa1moOilEK61i",
+ "date_setup": 1544564061,
+ "last_setup": 1544564061,
+ "type": "NHC",
+ "last_status_store": 1559067159,
+ "firmware": 45,
+ "last_upgrade": 1544564302,
+ "wifi_status": 66,
+ "reachable": true,
+ "co2_calibrating": false,
+ "station_name": "Livingroom",
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure",
+ "health_idx"
+ ],
+ "place": {
+ "city": "Frankfurt",
+ "country": "DE",
+ "timezone": "Europe/Berlin",
+ "location": [
+ 52.516263,
+ 13.377726
+ ]
+ }
+ },
+ {
+ "_id": "12:34:56:3e:c5:46",
+ "station_name": "Parents Bedroom",
+ "date_setup": 1570732241,
+ "last_setup": 1570732241,
+ "type": "NHC",
+ "last_status_store": 1572073818,
+ "module_name": "Indoor",
+ "firmware": 45,
+ "wifi_status": 67,
+ "reachable": true,
+ "co2_calibrating": false,
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure",
+ "health_idx"
+ ],
+ "place": {
+ "city": "Frankfurt",
+ "country": "DE",
+ "timezone": "Europe/Berlin",
+ "location": [
+ 52.516263,
+ 13.377726
+ ]
+ },
+ "dashboard_data": {
+ "time_utc": 1572073816,
+ "Temperature": 20.3,
+ "CO2": 494,
+ "Humidity": 63,
+ "Noise": 42,
+ "Pressure": 1014.5,
+ "AbsolutePressure": 1004.1,
+ "health_idx": 1,
+ "min_temp": 20.3,
+ "max_temp": 21.6,
+ "date_max_temp": 1572059333,
+ "date_min_temp": 1572073816
+ }
+ },
+ {
+ "_id": "12:34:56:26:68:92",
+ "station_name": "Baby Bedroom",
+ "date_setup": 1571342643,
+ "last_setup": 1571342643,
+ "type": "NHC",
+ "last_status_store": 1572073995,
+ "module_name": "Indoor",
+ "firmware": 45,
+ "wifi_status": 68,
+ "reachable": true,
+ "co2_calibrating": false,
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure",
+ "health_idx"
+ ],
+ "place": {
+ "city": "Frankfurt",
+ "country": "DE",
+ "timezone": "Europe/Berlin",
+ "location": [
+ 52.516263,
+ 13.377726
+ ]
+ },
+ "dashboard_data": {
+ "time_utc": 1572073994,
+ "Temperature": 21.6,
+ "CO2": 1053,
+ "Humidity": 66,
+ "Noise": 45,
+ "Pressure": 1021.4,
+ "AbsolutePressure": 1011,
+ "health_idx": 1,
+ "min_temp": 20.9,
+ "max_temp": 21.6,
+ "date_max_temp": 1572073690,
+ "date_min_temp": 1572064254
+ }
+ }
+ ],
+ "user": {
+ "mail": "john@doe.com",
+ "administrative": {
+ "lang": "de-DE",
+ "reg_locale": "de-DE",
+ "country": "DE",
+ "unit": 0,
+ "windunit": 0,
+ "pressureunit": 0,
+ "feel_like_algo": 0
+ }
+ }
+ },
+ "status": "ok",
+ "time_exec": 0.095954179763794,
+ "time_server": 1559463229
+}
\ No newline at end of file
diff --git a/fixtures/home_data_empty.json b/fixtures/home_data_empty.json
new file mode 100644
index 000000000..b929cf6ee
--- /dev/null
+++ b/fixtures/home_data_empty.json
@@ -0,0 +1,6 @@
+{
+ "body": {},
+ "status": "ok",
+ "time_exec": 0.056135892868042,
+ "time_server": 1559171003
+}
\ No newline at end of file
diff --git a/fixtures/home_data_no_devices.json b/fixtures/home_data_no_devices.json
new file mode 100644
index 000000000..583644367
--- /dev/null
+++ b/fixtures/home_data_no_devices.json
@@ -0,0 +1,31 @@
+{
+ "body": {
+ "homes": [
+ {
+ "id": "91763b24c43d3e344f424e8c",
+ "altitude": 112,
+ "coordinates": [
+ 52.516263,
+ 13.377726
+ ],
+ "country": "DE",
+ "timezone": "Europe/Berlin",
+ "therm_setpoint_default_duration": 180,
+ "therm_mode": "schedule"
+ }
+ ],
+ "user": {
+ "email": "john@doe.com",
+ "language": "de-DE",
+ "locale": "de-DE",
+ "feel_like_algorithm": 0,
+ "unit_pressure": 0,
+ "unit_system": 0,
+ "unit_wind": 0,
+ "id": "91763b24c43d3e344f424e8b"
+ }
+ },
+ "status": "ok",
+ "time_exec": 0.056135892868042,
+ "time_server": 1559171003
+}
\ No newline at end of file
diff --git a/fixtures/home_data_no_homes.json b/fixtures/home_data_no_homes.json
new file mode 100644
index 000000000..486695ae2
--- /dev/null
+++ b/fixtures/home_data_no_homes.json
@@ -0,0 +1,18 @@
+{
+ "body": {
+ "homes": [],
+ "user": {
+ "email": "john@doe.com",
+ "language": "de-DE",
+ "locale": "de-DE",
+ "feel_like_algorithm": 0,
+ "unit_pressure": 0,
+ "unit_system": 0,
+ "unit_wind": 0,
+ "id": "91763b24c43d3e344f424e8b"
+ }
+ },
+ "status": "ok",
+ "time_exec": 0.056135892868042,
+ "time_server": 1559171003
+}
\ No newline at end of file
diff --git a/fixtures/home_data_nohomename.json b/fixtures/home_data_nohomename.json
new file mode 100644
index 000000000..dc85d3c9b
--- /dev/null
+++ b/fixtures/home_data_nohomename.json
@@ -0,0 +1,404 @@
+{
+ "body": {
+ "homes": [
+ {
+ "id": "91763b24c43d3e344f424e8b",
+ "altitude": 112,
+ "coordinates": [
+ 52.516263,
+ 13.377726
+ ],
+ "country": "DE",
+ "timezone": "Europe/Berlin",
+ "rooms": [
+ {
+ "id": "2746182631",
+ "name": "Livingroom",
+ "type": "livingroom",
+ "module_ids": [
+ "12:34:56:00:01:ae"
+ ]
+ },
+ {
+ "id": "3688132631",
+ "name": "Hall",
+ "type": "custom",
+ "module_ids": [
+ "12:34:56:00:f1:62"
+ ]
+ }
+ ],
+ "modules": [
+ {
+ "id": "12:34:56:00:fa:d0",
+ "type": "NAPlug",
+ "name": "Thermostat",
+ "setup_date": 1494963356,
+ "modules_bridged": [
+ "12:34:56:00:01:ae"
+ ]
+ },
+ {
+ "id": "12:34:56:00:01:ae",
+ "type": "NATherm1",
+ "name": "Livingroom",
+ "setup_date": 1494963356,
+ "room_id": "2746182631",
+ "bridge": "12:34:56:00:fa:d0"
+ },
+ {
+ "id": "12:34:56:00:f1:62",
+ "type": "NACamera",
+ "name": "Hall",
+ "setup_date": 1544828430,
+ "room_id": "3688132631"
+ }
+ ],
+ "therm_schedules": [
+ {
+ "zones": [
+ {
+ "type": 0,
+ "name": "Comfort",
+ "rooms_temp": [
+ {
+ "temp": 21,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 0
+ },
+ {
+ "type": 1,
+ "name": "Night",
+ "rooms_temp": [
+ {
+ "temp": 17,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 1
+ },
+ {
+ "type": 5,
+ "name": "Eco",
+ "rooms_temp": [
+ {
+ "temp": 17,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 4
+ }
+ ],
+ "timetable": [
+ {
+ "zone_id": 1,
+ "m_offset": 0
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 360
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 420
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 960
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 1410
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 1800
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 1860
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 2400
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 2850
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 3240
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 3300
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 3840
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 4290
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 4680
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 4740
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 5280
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 5730
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 6120
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 6180
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 6720
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 7170
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 7620
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 8610
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 9060
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 10050
+ }
+ ],
+ "hg_temp": 7,
+ "away_temp": 14,
+ "name": "Default",
+ "selected": true,
+ "id": "591b54a2764ff4d50d8b5795",
+ "type": "therm"
+ }
+ ],
+ "therm_setpoint_default_duration": 120,
+ "persons": [
+ {
+ "id": "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "pseudo": "John Doe",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c24b808a89f8d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d7"
+ },
+ {
+ "id": "91827375-7e04-5298-83ae-a0cb8372dff2",
+ "pseudo": "Jane Doe",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c24b808a89f8d1730039d2c69928b029d67fc40cb2d7fb69ecdf2bb8b72"
+ },
+ {
+ "id": "91827376-7e04-5298-83af-a0cb8372dff3",
+ "pseudo": "Richard Doe",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c2d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d74b808a89f8"
+ }
+ ],
+ "schedules": [
+ {
+ "zones": [
+ {
+ "type": 0,
+ "name": "Komfort",
+ "rooms_temp": [
+ {
+ "temp": 21,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 0,
+ "rooms": [
+ {
+ "id": "2746182631",
+ "therm_setpoint_temperature": 21
+ }
+ ]
+ },
+ {
+ "type": 1,
+ "name": "Nacht",
+ "rooms_temp": [
+ {
+ "temp": 17,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 1,
+ "rooms": [
+ {
+ "id": "2746182631",
+ "therm_setpoint_temperature": 17
+ }
+ ]
+ },
+ {
+ "type": 5,
+ "name": "Eco",
+ "rooms_temp": [
+ {
+ "temp": 17,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 4,
+ "rooms": [
+ {
+ "id": "2746182631",
+ "therm_setpoint_temperature": 17
+ }
+ ]
+ }
+ ],
+ "timetable": [
+ {
+ "zone_id": 1,
+ "m_offset": 0
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 360
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 420
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 960
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 1410
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 1800
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 1860
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 2400
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 2850
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 3240
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 3300
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 3840
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 4290
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 4680
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 4740
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 5280
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 5730
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 6120
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 6180
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 6720
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 7170
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 7620
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 8610
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 9060
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 10050
+ }
+ ],
+ "hg_temp": 7,
+ "away_temp": 14,
+ "name": "Default",
+ "id": "591b54a2764ff4d50d8b5795",
+ "selected": true,
+ "type": "therm"
+ }
+ ],
+ "therm_mode": "schedule"
+ }
+ ],
+ "user": {
+ "email": "john@doe.com",
+ "language": "de-DE",
+ "locale": "de-DE",
+ "feel_like_algorithm": 0,
+ "unit_pressure": 0,
+ "unit_system": 0,
+ "unit_wind": 0,
+ "id": "91763b24c43d3e344f424e8b"
+ }
+ },
+ "status": "ok",
+ "time_exec": 0.056135892868042,
+ "time_server": 1559171003
+}
\ No newline at end of file
diff --git a/fixtures/home_data_simple.json b/fixtures/home_data_simple.json
new file mode 100644
index 000000000..aecab9155
--- /dev/null
+++ b/fixtures/home_data_simple.json
@@ -0,0 +1,595 @@
+{
+ "body": {
+ "homes": [
+ {
+ "id": "91763b24c43d3e344f424e8b",
+ "name": "MYHOME",
+ "altitude": 112,
+ "coordinates": [
+ 52.516263,
+ 13.377726
+ ],
+ "country": "DE",
+ "timezone": "Europe/Berlin",
+ "rooms": [
+ {
+ "id": "2746182631",
+ "name": "Livingroom",
+ "type": "livingroom",
+ "module_ids": [
+ "12:34:56:00:01:ae"
+ ]
+ },
+ {
+ "id": "3688132631",
+ "name": "Hall",
+ "type": "custom",
+ "module_ids": [
+ "12:34:56:00:f1:62"
+ ]
+ },
+ {
+ "id": "2833524037",
+ "name": "Entrada",
+ "type": "lobby",
+ "module_ids": [
+ "12:34:56:03:a5:54"
+ ]
+ },
+ {
+ "id": "2940411577",
+ "name": "Cocina",
+ "type": "kitchen",
+ "module_ids": [
+ "12:34:56:03:a0:ac"
+ ]
+ }
+ ],
+ "modules": [
+ {
+ "id": "12:34:56:00:fa:d0",
+ "type": "NAPlug",
+ "name": "Thermostat",
+ "setup_date": 1494963356,
+ "modules_bridged": [
+ "12:34:56:00:01:ae",
+ "12:34:56:03:a0:ac",
+ "12:34:56:03:a5:54"
+ ]
+ },
+ {
+ "id": "12:34:56:00:01:ae",
+ "type": "NATherm1",
+ "name": "Livingroom",
+ "setup_date": 1494963356,
+ "room_id": "2746182631",
+ "bridge": "12:34:56:00:fa:d0"
+ },
+ {
+ "id": "12:34:56:03:a5:54",
+ "type": "NRV",
+ "name": "Valve1",
+ "setup_date": 1554549767,
+ "room_id": "2833524037",
+ "bridge": "12:34:56:00:fa:d0"
+ },
+ {
+ "id": "12:34:56:03:a0:ac",
+ "type": "NRV",
+ "name": "Valve2",
+ "setup_date": 1554554444,
+ "room_id": "2940411577",
+ "bridge": "12:34:56:00:fa:d0"
+ },
+ {
+ "id": "12:34:56:00:f1:62",
+ "type": "NACamera",
+ "name": "Hall",
+ "setup_date": 1544828430,
+ "room_id": "3688132631"
+ }
+ ],
+ "therm_schedules": [
+ {
+ "zones": [
+ {
+ "type": 0,
+ "name": "Comfort",
+ "rooms_temp": [
+ {
+ "temp": 21,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 0
+ },
+ {
+ "type": 1,
+ "name": "Night",
+ "rooms_temp": [
+ {
+ "temp": 17,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 1
+ },
+ {
+ "type": 5,
+ "name": "Eco",
+ "rooms_temp": [
+ {
+ "temp": 17,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 4
+ }
+ ],
+ "timetable": [
+ {
+ "zone_id": 1,
+ "m_offset": 0
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 360
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 420
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 960
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 1410
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 1800
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 1860
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 2400
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 2850
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 3240
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 3300
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 3840
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 4290
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 4680
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 4740
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 5280
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 5730
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 6120
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 6180
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 6720
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 7170
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 7620
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 8610
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 9060
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 10050
+ }
+ ],
+ "hg_temp": 7,
+ "away_temp": 14,
+ "name": "Default",
+ "selected": true,
+ "id": "591b54a2764ff4d50d8b5795",
+ "type": "therm"
+ },
+ {
+ "zones": [
+ {
+ "type": 0,
+ "name": "Comfort",
+ "rooms_temp": [
+ {
+ "temp": 21,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 0
+ },
+ {
+ "type": 1,
+ "name": "Night",
+ "rooms_temp": [
+ {
+ "temp": 17,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 1
+ },
+ {
+ "type": 5,
+ "name": "Eco",
+ "rooms_temp": [
+ {
+ "temp": 17,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 4
+ }
+ ],
+ "timetable": [
+ {
+ "zone_id": 1,
+ "m_offset": 0
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 360
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 420
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 960
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 1410
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 1800
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 1860
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 2400
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 2850
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 3240
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 3300
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 3840
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 4290
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 4680
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 4740
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 5280
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 5730
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 6120
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 6180
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 6720
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 7170
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 7620
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 8610
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 9060
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 10050
+ }
+ ],
+ "hg_temp": 7,
+ "away_temp": 14,
+ "name": "Winter",
+ "id": "b1b54a2f45795764f59d50d8",
+ "type": "therm"
+ }
+ ],
+ "therm_setpoint_default_duration": 120,
+ "persons": [
+ {
+ "id": "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "pseudo": "John Doe",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c24b808a89f8d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d7"
+ },
+ {
+ "id": "91827375-7e04-5298-83ae-a0cb8372dff2",
+ "pseudo": "Jane Doe",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c24b808a89f8d1730039d2c69928b029d67fc40cb2d7fb69ecdf2bb8b72"
+ },
+ {
+ "id": "91827376-7e04-5298-83af-a0cb8372dff3",
+ "pseudo": "Richard Doe",
+ "url": "https://netatmocameraimage.blob.core.windows.net/production/d74fad765b9100ef480720a9a4a95c2d1730fb69ecdf2bb8b72039d2c69928b029d67fc40cb2d74b808a89f8"
+ }
+ ],
+ "schedules": [
+ {
+ "zones": [
+ {
+ "type": 0,
+ "name": "Komfort",
+ "rooms_temp": [
+ {
+ "temp": 21,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 0,
+ "rooms": [
+ {
+ "id": "2746182631",
+ "therm_setpoint_temperature": 21
+ }
+ ]
+ },
+ {
+ "type": 1,
+ "name": "Nacht",
+ "rooms_temp": [
+ {
+ "temp": 17,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 1,
+ "rooms": [
+ {
+ "id": "2746182631",
+ "therm_setpoint_temperature": 17
+ }
+ ]
+ },
+ {
+ "type": 5,
+ "name": "Eco",
+ "rooms_temp": [
+ {
+ "temp": 17,
+ "room_id": "2746182631"
+ }
+ ],
+ "id": 4,
+ "rooms": [
+ {
+ "id": "2746182631",
+ "therm_setpoint_temperature": 17
+ }
+ ]
+ }
+ ],
+ "timetable": [
+ {
+ "zone_id": 1,
+ "m_offset": 0
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 360
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 420
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 960
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 1410
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 1800
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 1860
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 2400
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 2850
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 3240
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 3300
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 3840
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 4290
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 4680
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 4740
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 5280
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 5730
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 6120
+ },
+ {
+ "zone_id": 4,
+ "m_offset": 6180
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 6720
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 7170
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 7620
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 8610
+ },
+ {
+ "zone_id": 0,
+ "m_offset": 9060
+ },
+ {
+ "zone_id": 1,
+ "m_offset": 10050
+ }
+ ],
+ "hg_temp": 7,
+ "away_temp": 14,
+ "name": "Default",
+ "id": "591b54a2764ff4d50d8b5795",
+ "selected": true,
+ "type": "therm"
+ }
+ ],
+ "therm_mode": "schedule"
+ },
+ {
+ "id": "91763b24c43d3e344f424e8c",
+ "altitude": 112,
+ "coordinates": [
+ 52.516263,
+ 13.377726
+ ],
+ "country": "DE",
+ "timezone": "Europe/Berlin",
+ "therm_setpoint_default_duration": 180,
+ "therm_mode": "schedule"
+ }
+ ],
+ "user": {
+ "email": "john@doe.com",
+ "language": "de-DE",
+ "locale": "de-DE",
+ "feel_like_algorithm": 0,
+ "unit_pressure": 0,
+ "unit_system": 0,
+ "unit_wind": 0,
+ "id": "91763b24c43d3e344f424e8b"
+ }
+ },
+ "status": "ok",
+ "time_exec": 0.056135892868042,
+ "time_server": 1559171003
+}
\ No newline at end of file
diff --git a/fixtures/home_status_empty.json b/fixtures/home_status_empty.json
new file mode 100644
index 000000000..62415ee2f
--- /dev/null
+++ b/fixtures/home_status_empty.json
@@ -0,0 +1,5 @@
+{
+ "status": "ok",
+ "time_server": 1559292039,
+ "body": {}
+}
\ No newline at end of file
diff --git a/fixtures/home_status_error_and_data.json b/fixtures/home_status_error_and_data.json
new file mode 100644
index 000000000..49228b44d
--- /dev/null
+++ b/fixtures/home_status_error_and_data.json
@@ -0,0 +1,109 @@
+{
+ "status": "ok",
+ "time_server": 1559292039,
+ "body": {
+ "home": {
+ "modules": [
+ {
+ "id": "12:34:56:00:fa:d0",
+ "type": "NAPlug",
+ "firmware_revision": 174,
+ "rf_strength": 107,
+ "wifi_strength": 42
+ },
+ {
+ "id": "12:34:56:00:01:ae",
+ "reachable": true,
+ "type": "NATherm1",
+ "firmware_revision": 65,
+ "rf_strength": 58,
+ "battery_level": 3793,
+ "boiler_valve_comfort_boost": false,
+ "boiler_status": false,
+ "anticipating": false,
+ "bridge": "12:34:56:00:fa:d0",
+ "battery_state": "high"
+ },
+ {
+ "id": "12:34:56:03:a5:54",
+ "reachable": true,
+ "type": "NRV",
+ "firmware_revision": 79,
+ "rf_strength": 51,
+ "battery_level": 3025,
+ "bridge": "12:34:56:00:fa:d0",
+ "battery_state": "full"
+ },
+ {
+ "id": "12:34:56:03:a0:ac",
+ "reachable": true,
+ "type": "NRV",
+ "firmware_revision": 79,
+ "rf_strength": 59,
+ "battery_level": 3029,
+ "bridge": "12:34:56:00:fa:d0",
+ "battery_state": "full"
+ }
+ ],
+ "rooms": [
+ {
+ "id": "2746182631",
+ "reachable": true,
+ "therm_measured_temperature": 19.8,
+ "therm_setpoint_temperature": 12,
+ "therm_setpoint_mode": "away",
+ "therm_setpoint_start_time": 1559229567,
+ "therm_setpoint_end_time": 0
+ },
+ {
+ "id": "2940411577",
+ "reachable": true,
+ "therm_measured_temperature": 27,
+ "heating_power_request": 0,
+ "therm_setpoint_temperature": 7,
+ "therm_setpoint_mode": "hg",
+ "therm_setpoint_start_time": 0,
+ "therm_setpoint_end_time": 0,
+ "anticipating": false,
+ "open_window": false
+ },
+ {
+ "id": "2833524037",
+ "reachable": true,
+ "therm_measured_temperature": 24.5,
+ "heating_power_request": 0,
+ "therm_setpoint_temperature": 7,
+ "therm_setpoint_mode": "hg",
+ "therm_setpoint_start_time": 0,
+ "therm_setpoint_end_time": 0,
+ "anticipating": false,
+ "open_window": false
+ }
+ ],
+ "id": "91763b24c43d3e344f424e8b",
+ "persons": [
+ {
+ "id": "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "last_seen": 1557071156,
+ "out_of_sight": true
+ },
+ {
+ "id": "91827375-7e04-5298-83ae-a0cb8372dff2",
+ "last_seen": 1559282761,
+ "out_of_sight": false
+ },
+ {
+ "id": "91827376-7e04-5298-83af-a0cb8372dff3",
+ "last_seen": 1559224132,
+ "out_of_sight": true
+ }
+ ]
+ },
+ "errors": [
+ {
+ "code": 6,
+ "id": "12:34:56:00:f1:62"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/fixtures/home_status_error_disconnected.json b/fixtures/home_status_error_disconnected.json
new file mode 100644
index 000000000..0059e9b2a
--- /dev/null
+++ b/fixtures/home_status_error_disconnected.json
@@ -0,0 +1,15 @@
+{
+ "status":"ok",
+ "body":{
+ "errors":[
+ {
+ "code":6,
+ "id":"12:34:56:00:fa:d0"
+ }
+ ],
+ "home":{
+ "id":"12:34:56:00:f1:62"
+ }
+ },
+ "time_server":1559292039
+}
\ No newline at end of file
diff --git a/fixtures/home_status_error_invalid_id.json b/fixtures/home_status_error_invalid_id.json
new file mode 100644
index 000000000..c8868a91f
--- /dev/null
+++ b/fixtures/home_status_error_invalid_id.json
@@ -0,0 +1,6 @@
+{
+ "error": {
+ "code": 21,
+ "message": "Invalid id"
+ }
+}
\ No newline at end of file
diff --git a/fixtures/home_status_error_invalid_schedule_id.json b/fixtures/home_status_error_invalid_schedule_id.json
new file mode 100644
index 000000000..1ff011bf9
--- /dev/null
+++ b/fixtures/home_status_error_invalid_schedule_id.json
@@ -0,0 +1,6 @@
+{
+ "error": {
+ "code": 21,
+ "message": "schedule is not therm schedule"
+ }
+}
\ No newline at end of file
diff --git a/fixtures/home_status_error_missing_home_id.json b/fixtures/home_status_error_missing_home_id.json
new file mode 100644
index 000000000..1a73477c4
--- /dev/null
+++ b/fixtures/home_status_error_missing_home_id.json
@@ -0,0 +1,6 @@
+{
+ "error": {
+ "code": 10,
+ "message": "Missing home_id"
+ }
+}
\ No newline at end of file
diff --git a/fixtures/home_status_error_missing_parameters.json b/fixtures/home_status_error_missing_parameters.json
new file mode 100644
index 000000000..ee7c53d45
--- /dev/null
+++ b/fixtures/home_status_error_missing_parameters.json
@@ -0,0 +1,6 @@
+{
+ "error": {
+ "code": 10,
+ "message": "Missing parameters"
+ }
+}
\ No newline at end of file
diff --git a/fixtures/home_status_error_mode_is_missing.json b/fixtures/home_status_error_mode_is_missing.json
new file mode 100644
index 000000000..14cf378dd
--- /dev/null
+++ b/fixtures/home_status_error_mode_is_missing.json
@@ -0,0 +1,6 @@
+{
+ "error": {
+ "code": 10,
+ "message": "mode is missing"
+ }
+}
\ No newline at end of file
diff --git a/fixtures/home_status_error_mode_not_authorized.json b/fixtures/home_status_error_mode_not_authorized.json
new file mode 100644
index 000000000..62764ee69
--- /dev/null
+++ b/fixtures/home_status_error_mode_not_authorized.json
@@ -0,0 +1,6 @@
+{
+ "error": {
+ "code": 21,
+ "message": "mode not authorized"
+ }
+}
\ No newline at end of file
diff --git a/fixtures/home_status_simple.json b/fixtures/home_status_simple.json
new file mode 100644
index 000000000..98c16f8d2
--- /dev/null
+++ b/fixtures/home_status_simple.json
@@ -0,0 +1,113 @@
+{
+ "status": "ok",
+ "time_server": 1559292039,
+ "body": {
+ "home": {
+ "modules": [
+ {
+ "id": "12:34:56:00:f1:62",
+ "type": "NACamera",
+ "monitoring": "on",
+ "sd_status": 4,
+ "alim_status": 2,
+ "locked": false,
+ "vpn_url": "https://prodvpn-eu-2.netatmo.net/restricted/10.255.123.45/609e27de5699fb18147ab47d06846631/MTRPn_BeWCav5RBq4U1OMDruTW4dkQ0NuMwNDAw11g,,",
+ "is_local": true
+ },
+ {
+ "id": "12:34:56:00:fa:d0",
+ "type": "NAPlug",
+ "firmware_revision": 174,
+ "rf_strength": 107,
+ "wifi_strength": 42
+ },
+ {
+ "id": "12:34:56:00:01:ae",
+ "reachable": true,
+ "type": "NATherm1",
+ "firmware_revision": 65,
+ "rf_strength": 58,
+ "battery_level": 3793,
+ "boiler_valve_comfort_boost": false,
+ "boiler_status": false,
+ "anticipating": false,
+ "bridge": "12:34:56:00:fa:d0",
+ "battery_state": "high"
+ },
+ {
+ "id": "12:34:56:03:a5:54",
+ "reachable": true,
+ "type": "NRV",
+ "firmware_revision": 79,
+ "rf_strength": 51,
+ "battery_level": 3025,
+ "bridge": "12:34:56:00:fa:d0",
+ "battery_state": "full"
+ },
+ {
+ "id": "12:34:56:03:a0:ac",
+ "reachable": true,
+ "type": "NRV",
+ "firmware_revision": 79,
+ "rf_strength": 59,
+ "battery_level": 3029,
+ "bridge": "12:34:56:00:fa:d0",
+ "battery_state": "full"
+ }
+ ],
+ "rooms": [
+ {
+ "id": "2746182631",
+ "reachable": true,
+ "therm_measured_temperature": 19.8,
+ "therm_setpoint_temperature": 12,
+ "therm_setpoint_mode": "away",
+ "therm_setpoint_start_time": 1559229567,
+ "therm_setpoint_end_time": 0
+ },
+ {
+ "id": "2940411577",
+ "reachable": true,
+ "therm_measured_temperature": 27,
+ "heating_power_request": 0,
+ "therm_setpoint_temperature": 7,
+ "therm_setpoint_mode": "hg",
+ "therm_setpoint_start_time": 0,
+ "therm_setpoint_end_time": 0,
+ "anticipating": false,
+ "open_window": false
+ },
+ {
+ "id": "2833524037",
+ "reachable": true,
+ "therm_measured_temperature": 24.5,
+ "heating_power_request": 0,
+ "therm_setpoint_temperature": 7,
+ "therm_setpoint_mode": "hg",
+ "therm_setpoint_start_time": 0,
+ "therm_setpoint_end_time": 0,
+ "anticipating": false,
+ "open_window": false
+ }
+ ],
+ "id": "91763b24c43d3e344f424e8b",
+ "persons": [
+ {
+ "id": "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "last_seen": 1557071156,
+ "out_of_sight": true
+ },
+ {
+ "id": "91827375-7e04-5298-83ae-a0cb8372dff2",
+ "last_seen": 1559282761,
+ "out_of_sight": false
+ },
+ {
+ "id": "91827376-7e04-5298-83af-a0cb8372dff3",
+ "last_seen": 1559224132,
+ "out_of_sight": true
+ }
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/fixtures/invalid_grant.json b/fixtures/invalid_grant.json
new file mode 100644
index 000000000..5d2f1fda9
--- /dev/null
+++ b/fixtures/invalid_grant.json
@@ -0,0 +1,3 @@
+{
+ "error": "invalid_grant"
+}
\ No newline at end of file
diff --git a/fixtures/oauth2_token.json b/fixtures/oauth2_token.json
new file mode 100644
index 000000000..444af4f46
--- /dev/null
+++ b/fixtures/oauth2_token.json
@@ -0,0 +1,15 @@
+{
+ "access_token": "91763b24c43d3e344f424e8b|880b55a08c758e87ff8755a00c6b8a12",
+ "refresh_token": "91763b24c43d3e344f424e8b|87ff8755a00c6b8a120b55a08c758e93",
+ "scope": [
+ "read_station",
+ "read_camera",
+ "access_camera",
+ "read_thermostat",
+ "write_thermostat",
+ "read_presence",
+ "access_presence"
+ ],
+ "expires_in": 10800,
+ "expire_in": 10800
+}
\ No newline at end of file
diff --git a/fixtures/public_data_error_mongo.json b/fixtures/public_data_error_mongo.json
new file mode 100644
index 000000000..d99e5487c
--- /dev/null
+++ b/fixtures/public_data_error_mongo.json
@@ -0,0 +1,6 @@
+{
+ "error": {
+ "message": "failed to connect to server [localhost:27020] on first connect [MongoError: connect ECONNREFUSED 127.0.0.1:27020]",
+ "code": 0
+ }
+}
\ No newline at end of file
diff --git a/fixtures/public_data_simple.json b/fixtures/public_data_simple.json
new file mode 100644
index 000000000..552027138
--- /dev/null
+++ b/fixtures/public_data_simple.json
@@ -0,0 +1,392 @@
+{
+ "status": "ok",
+ "time_server": 1560248397,
+ "time_exec": 0,
+ "body": [
+ {
+ "_id": "70:ee:50:36:94:7c",
+ "place": {
+ "location": [
+ 8.791382999999996,
+ 50.2136394
+ ],
+ "timezone": "Europe/Berlin",
+ "country": "DE",
+ "altitude": 132
+ },
+ "mark": 14,
+ "measures": {
+ "02:00:00:36:f2:94": {
+ "res": {
+ "1560248022": [
+ 21.4,
+ 62
+ ]
+ },
+ "type": [
+ "temperature",
+ "humidity"
+ ]
+ },
+ "70:ee:50:36:94:7c": {
+ "res": {
+ "1560248030": [
+ 1010.6
+ ]
+ },
+ "type": [
+ "pressure"
+ ]
+ },
+ "05:00:00:05:33:84": {
+ "rain_60min": 0.2,
+ "rain_24h": 12.322000000000001,
+ "rain_live": 0.5,
+ "rain_timeutc": 1560248022
+ }
+ },
+ "modules": [
+ "05:00:00:05:33:84",
+ "02:00:00:36:f2:94"
+ ],
+ "module_types": {
+ "05:00:00:05:33:84": "NAModule3",
+ "02:00:00:36:f2:94": "NAModule1"
+ }
+ },
+ {
+ "_id": "70:ee:50:1f:68:9e",
+ "place": {
+ "location": [
+ 8.795445200000017,
+ 50.2130169
+ ],
+ "timezone": "Europe/Berlin",
+ "country": "DE",
+ "altitude": 125
+ },
+ "mark": 14,
+ "measures": {
+ "02:00:00:1f:82:28": {
+ "res": {
+ "1560248312": [
+ 21.1,
+ 69
+ ]
+ },
+ "type": [
+ "temperature",
+ "humidity"
+ ]
+ },
+ "70:ee:50:1f:68:9e": {
+ "res": {
+ "1560248344": [
+ 1007.3
+ ]
+ },
+ "type": [
+ "pressure"
+ ]
+ },
+ "05:00:00:02:bb:6e": {
+ "rain_60min": 0,
+ "rain_24h": 9.999,
+ "rain_live": 0,
+ "rain_timeutc": 1560248344
+ }
+ },
+ "modules": [
+ "02:00:00:1f:82:28",
+ "05:00:00:02:bb:6e"
+ ],
+ "module_types": {
+ "02:00:00:1f:82:28": "NAModule1",
+ "05:00:00:02:bb:6e": "NAModule3"
+ }
+ },
+ {
+ "_id": "70:ee:50:27:25:b0",
+ "place": {
+ "location": [
+ 8.7807159,
+ 50.1946167
+ ],
+ "timezone": "Europe/Berlin",
+ "country": "DE",
+ "altitude": 112
+ },
+ "mark": 14,
+ "measures": {
+ "02:00:00:27:19:b2": {
+ "res": {
+ "1560247889": [
+ 23.2,
+ 60
+ ]
+ },
+ "type": [
+ "temperature",
+ "humidity"
+ ]
+ },
+ "70:ee:50:27:25:b0": {
+ "res": {
+ "1560247907": [
+ 1012.8
+ ]
+ },
+ "type": [
+ "pressure"
+ ]
+ },
+ "05:00:00:03:5d:2e": {
+ "rain_60min": 0,
+ "rain_24h": 11.716000000000001,
+ "rain_live": 0,
+ "rain_timeutc": 1560247896
+ }
+ },
+ "modules": [
+ "02:00:00:27:19:b2",
+ "05:00:00:03:5d:2e"
+ ],
+ "module_types": {
+ "02:00:00:27:19:b2": "NAModule1",
+ "05:00:00:03:5d:2e": "NAModule3"
+ }
+ },
+ {
+ "_id": "70:ee:50:04:ed:7a",
+ "place": {
+ "location": [
+ 8.785034,
+ 50.192169
+ ],
+ "timezone": "Europe/Berlin",
+ "country": "DE",
+ "altitude": 112
+ },
+ "mark": 14,
+ "measures": {
+ "02:00:00:04:c2:2e": {
+ "res": {
+ "1560248137": [
+ 19.8,
+ 76
+ ]
+ },
+ "type": [
+ "temperature",
+ "humidity"
+ ]
+ },
+ "70:ee:50:04:ed:7a": {
+ "res": {
+ "1560248152": [
+ 1005.4
+ ]
+ },
+ "type": [
+ "pressure"
+ ]
+ }
+ },
+ "modules": [
+ "02:00:00:04:c2:2e"
+ ],
+ "module_types": {
+ "02:00:00:04:c2:2e": "NAModule1"
+ }
+ },
+ {
+ "_id": "70:ee:50:27:9f:2c",
+ "place": {
+ "location": [
+ 8.785342,
+ 50.193573
+ ],
+ "timezone": "Europe/Berlin",
+ "country": "DE",
+ "altitude": 116
+ },
+ "mark": 1,
+ "measures": {
+ "02:00:00:27:aa:70": {
+ "res": {
+ "1560247821": [
+ 25.5,
+ 56
+ ]
+ },
+ "type": [
+ "temperature",
+ "humidity"
+ ]
+ },
+ "70:ee:50:27:9f:2c": {
+ "res": {
+ "1560247853": [
+ 1010.6
+ ]
+ },
+ "type": [
+ "pressure"
+ ]
+ }
+ },
+ "modules": [
+ "02:00:00:27:aa:70"
+ ],
+ "module_types": {
+ "02:00:00:27:aa:70": "NAModule1"
+ }
+ },
+ {
+ "_id": "70:ee:50:01:20:fa",
+ "place": {
+ "location": [
+ 8.7953,
+ 50.195241
+ ],
+ "timezone": "Europe/Berlin",
+ "country": "DE",
+ "altitude": 119
+ },
+ "mark": 1,
+ "measures": {
+ "02:00:00:00:f7:ba": {
+ "res": {
+ "1560247831": [
+ 27.4,
+ 58
+ ]
+ },
+ "type": [
+ "temperature",
+ "humidity"
+ ]
+ },
+ "70:ee:50:01:20:fa": {
+ "res": {
+ "1560247876": [
+ 1014.4
+ ]
+ },
+ "type": [
+ "pressure"
+ ]
+ }
+ },
+ "modules": [
+ "02:00:00:00:f7:ba"
+ ],
+ "module_types": {
+ "02:00:00:00:f7:ba": "NAModule1"
+ }
+ },
+ {
+ "_id": "70:ee:50:3c:02:78",
+ "place": {
+ "location": [
+ 8.795953681700666,
+ 50.19530139868166
+ ],
+ "timezone": "Europe/Berlin",
+ "country": "DE",
+ "altitude": 119
+ },
+ "mark": 7,
+ "measures": {
+ "02:00:00:3c:21:f2": {
+ "res": {
+ "1560248225": [
+ 23.3,
+ 58
+ ]
+ },
+ "type": [
+ "temperature",
+ "humidity"
+ ]
+ },
+ "70:ee:50:3c:02:78": {
+ "res": {
+ "1560248270": [
+ 1011.7
+ ]
+ },
+ "type": [
+ "pressure"
+ ]
+ }
+ },
+ "modules": [
+ "02:00:00:3c:21:f2"
+ ],
+ "module_types": {
+ "02:00:00:3c:21:f2": "NAModule1"
+ }
+ },
+ {
+ "_id": "70:ee:50:36:a9:fc",
+ "place": {
+ "location": [
+ 8.801164269110814,
+ 50.19596181704958
+ ],
+ "timezone": "Europe/Berlin",
+ "country": "DE",
+ "altitude": 113
+ },
+ "mark": 14,
+ "measures": {
+ "02:00:00:36:a9:50": {
+ "res": {
+ "1560248145": [
+ 20.1,
+ 67
+ ]
+ },
+ "type": [
+ "temperature",
+ "humidity"
+ ]
+ },
+ "70:ee:50:36:a9:fc": {
+ "res": {
+ "1560248191": [
+ 1010
+ ]
+ },
+ "type": [
+ "pressure"
+ ]
+ },
+ "05:00:00:02:92:82": {
+ "rain_60min": 0,
+ "rain_24h": 11.009,
+ "rain_live": 0,
+ "rain_timeutc": 1560248184
+ },
+ "06:00:00:03:19:76": {
+ "wind_strength": 15,
+ "wind_angle": 17,
+ "gust_strength": 31,
+ "gust_angle": 217,
+ "wind_timeutc": 1560248190
+ }
+ },
+ "modules": [
+ "05:00:00:02:92:82",
+ "02:00:00:36:a9:50",
+ "06:00:00:03:19:76"
+ ],
+ "module_types": {
+ "05:00:00:02:92:82": "NAModule3",
+ "02:00:00:36:a9:50": "NAModule1",
+ "06:00:00:03:19:76": "NAModule2"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/fixtures/status_ok.json b/fixtures/status_ok.json
new file mode 100644
index 000000000..672749493
--- /dev/null
+++ b/fixtures/status_ok.json
@@ -0,0 +1,5 @@
+{
+ "status": "ok",
+ "time_exec": 0.020781993865967,
+ "time_server": 1559162635
+}
\ No newline at end of file
diff --git a/fixtures/thermostat_data_simple.json b/fixtures/thermostat_data_simple.json
new file mode 100644
index 000000000..cda81b1ea
--- /dev/null
+++ b/fixtures/thermostat_data_simple.json
@@ -0,0 +1,268 @@
+{
+ "body": {
+ "devices": [
+ {
+ "_id": "12:34:56:00:fa:d0",
+ "firmware": 174,
+ "last_bilan": {
+ "y": 2019,
+ "m": 4
+ },
+ "last_setup": 1494963356,
+ "last_status_store": 1559297986,
+ "place": {
+ "altitude": 112,
+ "city": "Berlin",
+ "country": "DE",
+ "improveLocProposed": true,
+ "location": [
+ 52.516263,
+ 13.377726
+ ],
+ "timezone": "Europe/Berlin",
+ "trust_location": true
+ },
+ "plug_connected_boiler": 1,
+ "type": "NAPlug",
+ "udp_conn": true,
+ "wifi_status": 42,
+ "modules": [
+ {
+ "_id": "12:34:56:00:01:ae",
+ "module_name": "Livingroom",
+ "type": "NATherm1",
+ "firmware": 65,
+ "last_message": 1559297976,
+ "rf_status": 59,
+ "battery_vp": 3798,
+ "therm_orientation": 3,
+ "therm_relay_cmd": 0,
+ "anticipating": false,
+ "battery_percent": 53,
+ "event_history": {
+ "boiler_not_responding_events": [
+ {
+ "K": 1506103090
+ },
+ {
+ "K": 1514496738
+ },
+ {
+ "K": 1514583682
+ },
+ {
+ "K": 1518695843
+ },
+ {
+ "K": 1518813960
+ }
+ ],
+ "boiler_responding_events": [
+ {
+ "K": 1506281109
+ },
+ {
+ "K": 1514552830
+ },
+ {
+ "K": 1514757686
+ },
+ {
+ "K": 1518798339
+ },
+ {
+ "K": 1518965265
+ }
+ ]
+ },
+ "setpoint_history": [
+ {
+ "setpoint": {
+ "setpoint_mode": "hg"
+ },
+ "timestamp": 1559229554
+ },
+ {
+ "setpoint": {
+ "setpoint_mode": "program"
+ },
+ "timestamp": 1559229565
+ },
+ {
+ "setpoint": {
+ "setpoint_mode": "away"
+ },
+ "timestamp": 1559229567
+ }
+ ],
+ "last_therm_seen": 1559297976,
+ "setpoint": {
+ "setpoint_mode": "away"
+ },
+ "therm_program_list": [
+ {
+ "zones": [
+ {
+ "type": 0,
+ "name": "Comfort",
+ "id": 0,
+ "temp": 21
+ },
+ {
+ "type": 1,
+ "name": "Night",
+ "id": 1,
+ "temp": 17
+ },
+ {
+ "type": 5,
+ "name": "Eco",
+ "id": 4,
+ "temp": 17
+ },
+ {
+ "type": 2,
+ "id": 2,
+ "temp": 14
+ },
+ {
+ "type": 3,
+ "id": 3,
+ "temp": 7
+ }
+ ],
+ "timetable": [
+ {
+ "m_offset": 0,
+ "id": 1
+ },
+ {
+ "m_offset": 360,
+ "id": 0
+ },
+ {
+ "m_offset": 420,
+ "id": 4
+ },
+ {
+ "m_offset": 960,
+ "id": 0
+ },
+ {
+ "m_offset": 1410,
+ "id": 1
+ },
+ {
+ "m_offset": 1800,
+ "id": 0
+ },
+ {
+ "m_offset": 1860,
+ "id": 4
+ },
+ {
+ "m_offset": 2400,
+ "id": 0
+ },
+ {
+ "m_offset": 2850,
+ "id": 1
+ },
+ {
+ "m_offset": 3240,
+ "id": 0
+ },
+ {
+ "m_offset": 3300,
+ "id": 4
+ },
+ {
+ "m_offset": 3840,
+ "id": 0
+ },
+ {
+ "m_offset": 4290,
+ "id": 1
+ },
+ {
+ "m_offset": 4680,
+ "id": 0
+ },
+ {
+ "m_offset": 4740,
+ "id": 4
+ },
+ {
+ "m_offset": 5280,
+ "id": 0
+ },
+ {
+ "m_offset": 5730,
+ "id": 1
+ },
+ {
+ "m_offset": 6120,
+ "id": 0
+ },
+ {
+ "m_offset": 6180,
+ "id": 4
+ },
+ {
+ "m_offset": 6720,
+ "id": 0
+ },
+ {
+ "m_offset": 7170,
+ "id": 1
+ },
+ {
+ "m_offset": 7620,
+ "id": 0
+ },
+ {
+ "m_offset": 8610,
+ "id": 1
+ },
+ {
+ "m_offset": 9060,
+ "id": 0
+ },
+ {
+ "m_offset": 10050,
+ "id": 1
+ }
+ ],
+ "name": "Default",
+ "program_id": "591b54a2764ff4d50d8b5795",
+ "selected": true
+ }
+ ],
+ "measured": {
+ "time": 1559297836,
+ "temperature": 19.8,
+ "setpoint_temp": 12
+ }
+ }
+ ],
+ "station_name": "Thermostat",
+ "last_plug_seen": 1559297986
+ }
+ ],
+ "user": {
+ "mail": "john@doe.com",
+ "administrative": {
+ "lang": "de-DE",
+ "reg_locale": "de-DE",
+ "country": "DE",
+ "unit": 0,
+ "windunit": 0,
+ "pressureunit": 0,
+ "feel_like_algo": 0
+ }
+ }
+ },
+ "status": "ok",
+ "time_exec": 0.12061500549316,
+ "time_server": 1559300497
+}
\ No newline at end of file
diff --git a/fixtures/too_many_connections.json b/fixtures/too_many_connections.json
new file mode 100644
index 000000000..b8f8a4711
--- /dev/null
+++ b/fixtures/too_many_connections.json
@@ -0,0 +1,3 @@
+{
+ "error": "too_many_connections"
+}
\ No newline at end of file
diff --git a/fixtures/weatherstation_data_simple.json b/fixtures/weatherstation_data_simple.json
new file mode 100644
index 000000000..2a18c7bd2
--- /dev/null
+++ b/fixtures/weatherstation_data_simple.json
@@ -0,0 +1,600 @@
+{
+ "body": {
+ "devices": [
+ {
+ "_id": "12:34:56:37:11:ca",
+ "cipher_id": "enc:16:zjiZF/q8jTScXVdDa/kvhUAIUPGeYszaD1ClEf8byAJkRjxc5oth7cAocrMUIApX",
+ "date_setup": 1544558432,
+ "last_setup": 1544558432,
+ "type": "NAMain",
+ "last_status_store": 1559413181,
+ "module_name": "NetatmoIndoor",
+ "firmware": 137,
+ "last_upgrade": 1544558433,
+ "wifi_status": 45,
+ "reachable": true,
+ "co2_calibrating": false,
+ "station_name": "MyStation",
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure"
+ ],
+ "place": {
+ "altitude": 664,
+ "city": "Frankfurt",
+ "country": "DE",
+ "timezone": "Europe/Berlin",
+ "location": [
+ 52.516263,
+ 13.377726
+ ]
+ },
+ "dashboard_data": {
+ "time_utc": 1559413171,
+ "Temperature": 24.6,
+ "CO2": 749,
+ "Humidity": 36,
+ "Noise": 37,
+ "Pressure": 1017.3,
+ "AbsolutePressure": 939.7,
+ "min_temp": 23.4,
+ "max_temp": 25.6,
+ "date_min_temp": 1559371924,
+ "date_max_temp": 1559411964,
+ "temp_trend": "stable",
+ "pressure_trend": "down"
+ },
+ "modules": [
+ {
+ "_id": "12:34:56:36:fc:de",
+ "type": "NAModule1",
+ "module_name": "NetatmoOutdoor",
+ "data_type": [
+ "Temperature",
+ "Humidity"
+ ],
+ "last_setup": 1544558433,
+ "reachable": true,
+ "dashboard_data": {
+ "time_utc": 1559413157,
+ "Temperature": 28.6,
+ "Humidity": 24,
+ "min_temp": 16.9,
+ "max_temp": 30.3,
+ "date_min_temp": 1559365579,
+ "date_max_temp": 1559404698,
+ "temp_trend": "down"
+ },
+ "firmware": 46,
+ "last_message": 1559413177,
+ "last_seen": 1559413157,
+ "rf_status": 65,
+ "battery_vp": 5738,
+ "battery_percent": 87
+ },
+ {
+ "_id": "12:34:56:07:bb:3e",
+ "type": "NAModule4",
+ "module_name": "Kitchen",
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity"
+ ],
+ "last_setup": 1548956696,
+ "reachable": true,
+ "dashboard_data": {
+ "time_utc": 1559413125,
+ "Temperature": 28,
+ "CO2": 503,
+ "Humidity": 26,
+ "min_temp": 25,
+ "max_temp": 28,
+ "date_min_temp": 1559371577,
+ "date_max_temp": 1559412561,
+ "temp_trend": "up"
+ },
+ "firmware": 44,
+ "last_message": 1559413177,
+ "last_seen": 1559413177,
+ "rf_status": 73,
+ "battery_vp": 5687,
+ "battery_percent": 83
+ },
+ {
+ "_id": "12:34:56:07:bb:0e",
+ "type": "NAModule4",
+ "module_name": "Livingroom",
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity"
+ ],
+ "last_setup": 1548957209,
+ "reachable": true,
+ "dashboard_data": {
+ "time_utc": 1559413093,
+ "Temperature": 26.4,
+ "CO2": 451,
+ "Humidity": 31,
+ "min_temp": 25.1,
+ "max_temp": 26.4,
+ "date_min_temp": 1559365290,
+ "date_max_temp": 1559413093,
+ "temp_trend": "stable"
+ },
+ "firmware": 44,
+ "last_message": 1559413177,
+ "last_seen": 1559413093,
+ "rf_status": 84,
+ "battery_vp": 5626,
+ "battery_percent": 79
+ },
+ {
+ "_id": "12:34:56:03:1b:e4",
+ "type": "NAModule2",
+ "module_name": "Garden",
+ "data_type": [
+ "Wind"
+ ],
+ "last_setup": 1549193862,
+ "reachable": true,
+ "dashboard_data": {
+ "time_utc": 1559413170,
+ "WindStrength": 4,
+ "WindAngle": 217,
+ "GustStrength": 9,
+ "GustAngle": 206,
+ "max_wind_str": 21,
+ "max_wind_angle": 217,
+ "date_max_wind_str": 1559386669
+ },
+ "firmware": 19,
+ "last_message": 1559413177,
+ "last_seen": 1559413177,
+ "rf_status": 59,
+ "battery_vp": 5689,
+ "battery_percent": 85
+ },
+ {
+ "_id": "12:34:56:05:51:20",
+ "type": "NAModule3",
+ "module_name": "Yard",
+ "data_type": [
+ "Rain"
+ ],
+ "last_setup": 1549194580,
+ "reachable": true,
+ "dashboard_data": {
+ "time_utc": 1559413170,
+ "Rain": 0,
+ "sum_rain_24": 0,
+ "sum_rain_1": 0
+ },
+ "firmware": 8,
+ "last_message": 1559413177,
+ "last_seen": 1559413170,
+ "rf_status": 67,
+ "battery_vp": 5860,
+ "battery_percent": 93
+ }
+ ]
+ },
+ {
+ "_id": "12 :34: 56:36:fd:3c",
+ "station_name": "Valley Road",
+ "date_setup": 1545897146,
+ "last_setup": 1545897146,
+ "type": "NAMain",
+ "last_status_store": 1581835369,
+ "firmware": 137,
+ "last_upgrade": 1545897125,
+ "wifi_status": 53,
+ "reachable": true,
+ "co2_calibrating": false,
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure"
+ ],
+ "place": {
+ "altitude": 69,
+ "city": "Valley",
+ "country": "AU",
+ "timezone": "Australia/Hobart",
+ "location": [
+ 148.444226,
+ -41.721282
+ ]
+ },
+ "read_only": true,
+ "dashboard_data": {
+ "time_utc": 1581835330,
+ "Temperature": 22.4,
+ "CO2": 471,
+ "Humidity": 46,
+ "Noise": 47,
+ "Pressure": 1011.5,
+ "AbsolutePressure": 1002.8,
+ "min_temp": 18.1,
+ "max_temp": 22.5,
+ "date_max_temp": 1581829891,
+ "date_min_temp": 1581794878,
+ "temp_trend": "stable",
+ "pressure_trend": "stable"
+ },
+ "modules": [
+ {
+ "_id": "12 :34: 56:36:e6:c0",
+ "type": "NAModule1",
+ "module_name": "Module",
+ "data_type": [
+ "Temperature",
+ "Humidity"
+ ],
+ "last_setup": 1545897146,
+ "battery_percent": 22,
+ "reachable": false,
+ "firmware": 46,
+ "last_message": 1572497781,
+ "last_seen": 1572497742,
+ "rf_status": 88,
+ "battery_vp": 4118
+ },
+ {
+ "_id": "12:34:56:05:25:6e",
+ "type": "NAModule3",
+ "module_name": "Rain Gauge",
+ "data_type": [
+ "Rain"
+ ],
+ "last_setup": 1553997427,
+ "battery_percent": 82,
+ "reachable": true,
+ "firmware": 8,
+ "last_message": 1581835362,
+ "last_seen": 1581835354,
+ "rf_status": 78,
+ "battery_vp": 5594,
+ "dashboard_data": {
+ "time_utc": 1581835329,
+ "Rain": 0,
+ "sum_rain_1": 0,
+ "sum_rain_24": 0
+ }
+ }
+ ]
+ },
+ {
+ "_id": "12:34:56:32:a7:60",
+ "home_name": "Ateljen",
+ "date_setup": 1566714693,
+ "last_setup": 1566714693,
+ "type": "NAMain",
+ "last_status_store": 1588481079,
+ "module_name": "Indoor",
+ "firmware": 177,
+ "last_upgrade": 1566714694,
+ "wifi_status": 50,
+ "reachable": true,
+ "co2_calibrating": false,
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure"
+ ],
+ "place": {
+ "altitude": 93,
+ "city": "Gothenburg",
+ "country": "SE",
+ "timezone": "Europe/Stockholm",
+ "location": [
+ 11.6136629,
+ 57.7006827
+ ]
+ },
+ "dashboard_data": {
+ "time_utc": 1588481073,
+ "Temperature": 18.2,
+ "CO2": 542,
+ "Humidity": 45,
+ "Noise": 45,
+ "Pressure": 1013,
+ "AbsolutePressure": 1001.9,
+ "min_temp": 18.2,
+ "max_temp": 19.5,
+ "date_max_temp": 1588456861,
+ "date_min_temp": 1588479561,
+ "temp_trend": "stable",
+ "pressure_trend": "up"
+ },
+ "modules": [
+ {
+ "_id": "12:34:56:32:db:06",
+ "type": "NAModule1",
+ "last_setup": 1587635819,
+ "data_type": [
+ "Temperature",
+ "Humidity"
+ ],
+ "battery_percent": 100,
+ "reachable": false,
+ "firmware": 255,
+ "last_message": 0,
+ "last_seen": 0,
+ "rf_status": 255,
+ "battery_vp": 65535
+ }
+ ]
+ },
+ {
+ "_id": "12:34:56:1c:68:2e",
+ "station_name": "Bol\u00e5s",
+ "date_setup": 1470935400,
+ "last_setup": 1470935400,
+ "type": "NAMain",
+ "last_status_store": 1588481399,
+ "module_name": "Inne - Nere",
+ "firmware": 177,
+ "last_upgrade": 1470935401,
+ "wifi_status": 13,
+ "reachable": true,
+ "co2_calibrating": false,
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure"
+ ],
+ "place": {
+ "altitude": 93,
+ "city": "Gothenburg",
+ "country": "SE",
+ "timezone": "Europe/Stockholm",
+ "location": [
+ 11.6136629,
+ 57.7006827
+ ]
+ },
+ "dashboard_data": {
+ "time_utc": 1588481387,
+ "Temperature": 20.8,
+ "CO2": 674,
+ "Humidity": 41,
+ "Noise": 34,
+ "Pressure": 1012.1,
+ "AbsolutePressure": 1001,
+ "min_temp": 20.8,
+ "max_temp": 22.2,
+ "date_max_temp": 1588456859,
+ "date_min_temp": 1588480176,
+ "temp_trend": "stable",
+ "pressure_trend": "up"
+ },
+ "modules": [
+ {
+ "_id": "12:34:56:02:b3:da",
+ "type": "NAModule3",
+ "module_name": "Regnm\u00e4tare",
+ "last_setup": 1470937706,
+ "data_type": [
+ "Rain"
+ ],
+ "battery_percent": 81,
+ "reachable": true,
+ "firmware": 12,
+ "last_message": 1588481393,
+ "last_seen": 1588481386,
+ "rf_status": 67,
+ "battery_vp": 5582,
+ "dashboard_data": {
+ "time_utc": 1588481386,
+ "Rain": 0,
+ "sum_rain_1": 0,
+ "sum_rain_24": 0.1
+ }
+ },
+ {
+ "_id": "12:34:56:03:76:60",
+ "type": "NAModule4",
+ "module_name": "Inne - Uppe",
+ "last_setup": 1470938089,
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity"
+ ],
+ "battery_percent": 14,
+ "reachable": true,
+ "firmware": 50,
+ "last_message": 1588481393,
+ "last_seen": 1588481374,
+ "rf_status": 70,
+ "battery_vp": 4448,
+ "dashboard_data": {
+ "time_utc": 1588481374,
+ "Temperature": 19.6,
+ "CO2": 696,
+ "Humidity": 41,
+ "min_temp": 19.6,
+ "max_temp": 20.5,
+ "date_max_temp": 1588456817,
+ "date_min_temp": 1588481374,
+ "temp_trend": "stable"
+ }
+ },
+ {
+ "_id": "12:34:56:32:db:06",
+ "type": "NAModule1",
+ "module_name": "Ute",
+ "last_setup": 1566326027,
+ "data_type": [
+ "Temperature",
+ "Humidity"
+ ],
+ "battery_percent": 81,
+ "reachable": true,
+ "firmware": 50,
+ "last_message": 1588481393,
+ "last_seen": 1588481380,
+ "rf_status": 61,
+ "battery_vp": 5544,
+ "dashboard_data": {
+ "time_utc": 1588481380,
+ "Temperature": 6.4,
+ "Humidity": 91,
+ "min_temp": 3.6,
+ "max_temp": 6.4,
+ "date_max_temp": 1588481380,
+ "date_min_temp": 1588471383,
+ "temp_trend": "up"
+ }
+ }
+ ]
+ },
+ {
+ "_id": "12:34:56:1d:68:2e",
+ "date_setup": 1470935500,
+ "last_setup": 1470935500,
+ "type": "NAMain",
+ "last_status_store": 1588481399,
+ "module_name": "Basisstation",
+ "firmware": 177,
+ "last_upgrade": 1470935401,
+ "wifi_status": 13,
+ "reachable": true,
+ "co2_calibrating": false,
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure"
+ ],
+ "place": {
+ "altitude": 93,
+ "city": "Gothenburg",
+ "country": "SE",
+ "timezone": "Europe/Stockholm",
+ "location": [
+ 11.6136629,
+ 57.7006827
+ ]
+ },
+ "dashboard_data": {
+ "time_utc": 1588481387,
+ "Temperature": 20.8,
+ "CO2": 674,
+ "Humidity": 41,
+ "Noise": 34,
+ "Pressure": 1012.1,
+ "AbsolutePressure": 1001,
+ "min_temp": 20.8,
+ "max_temp": 22.2,
+ "date_max_temp": 1588456859,
+ "date_min_temp": 1588480176,
+ "temp_trend": "stable",
+ "pressure_trend": "up"
+ },
+ "modules": []
+ },
+ {
+ "_id": "12:34:56:58:c8:54",
+ "date_setup": 1605594014,
+ "last_setup": 1605594014,
+ "type": "NAMain",
+ "last_status_store": 1605878352,
+ "firmware": 178,
+ "wifi_status": 47,
+ "reachable": true,
+ "co2_calibrating": false,
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure"
+ ],
+ "place": {
+ "altitude": 65,
+ "city": "Njurunda District",
+ "country": "SE",
+ "timezone": "Europe/Stockholm",
+ "location": [
+ 17.123456,
+ 62.123456
+ ]
+ },
+ "station_name": "Njurunda (Indoor)",
+ "home_id": "5fb36b9ec68fd10c6467ca65",
+ "home_name": "Njurunda",
+ "dashboard_data": {
+ "time_utc": 1605878349,
+ "Temperature": 19.7,
+ "CO2": 993,
+ "Humidity": 40,
+ "Noise": 40,
+ "Pressure": 1015.6,
+ "AbsolutePressure": 1007.8,
+ "min_temp": 19.7,
+ "max_temp": 20.4,
+ "date_max_temp": 1605826917,
+ "date_min_temp": 1605873207,
+ "temp_trend": "stable",
+ "pressure_trend": "up"
+ },
+ "modules": [
+ {
+ "_id": "12:34:56:58:e6:38",
+ "type": "NAModule1",
+ "last_setup": 1605594034,
+ "data_type": [
+ "Temperature",
+ "Humidity"
+ ],
+ "battery_percent": 100,
+ "reachable": true,
+ "firmware": 50,
+ "last_message": 1605878347,
+ "last_seen": 1605878328,
+ "rf_status": 62,
+ "battery_vp": 6198,
+ "dashboard_data": {
+ "time_utc": 1605878328,
+ "Temperature": 0.6,
+ "Humidity": 77,
+ "min_temp": -2.1,
+ "max_temp": 1.5,
+ "date_max_temp": 1605865920,
+ "date_min_temp": 1605826904,
+ "temp_trend": "down"
+ }
+ }
+ ]
+ }
+ ],
+ "user": {
+ "mail": "john@doe.com",
+ "administrative": {
+ "lang": "de-DE",
+ "reg_locale": "de-DE",
+ "country": "DE",
+ "unit": 0,
+ "windunit": 0,
+ "pressureunit": 0,
+ "feel_like_algo": 0
+ }
+ }
+ },
+ "status": "ok",
+ "time_exec": 0.91107702255249,
+ "time_server": 1559413602
+}
\ No newline at end of file
diff --git a/fixtures/weatherstation_data_unreachable_station.json b/fixtures/weatherstation_data_unreachable_station.json
new file mode 100644
index 000000000..1642b86d7
--- /dev/null
+++ b/fixtures/weatherstation_data_unreachable_station.json
@@ -0,0 +1,271 @@
+{
+ "body": {
+ "devices": [
+ {
+ "_id": "12:34:56:37:11:ca",
+ "cipher_id": "enc:16:zjiZF/q8jTScXVdDa/kvhUAIUPGeYszaD1ClEf8byAJkRjxc5oth7cAocrMUIApX",
+ "date_setup": 1544558432,
+ "last_setup": 1544558432,
+ "type": "NAMain",
+ "last_status_store": 1559413181,
+ "module_name": "NetatmoIndoor",
+ "firmware": 137,
+ "last_upgrade": 1544558433,
+ "wifi_status": 45,
+ "reachable": true,
+ "co2_calibrating": false,
+ "station_name": "MyStation",
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure"
+ ],
+ "place": {
+ "altitude": 664,
+ "city": "Frankfurt",
+ "country": "DE",
+ "timezone": "Europe/Berlin",
+ "location": [
+ 52.516263,
+ 13.377726
+ ]
+ },
+ "dashboard_data": {
+ "time_utc": 1559413171,
+ "Temperature": 24.6,
+ "CO2": 749,
+ "Humidity": 36,
+ "Noise": 37,
+ "Pressure": 1017.3,
+ "AbsolutePressure": 939.7,
+ "min_temp": 23.4,
+ "max_temp": 25.6,
+ "date_min_temp": 1559371924,
+ "date_max_temp": 1559411964,
+ "temp_trend": "stable",
+ "pressure_trend": "down"
+ },
+ "modules": [
+ {
+ "_id": "12:34:56:36:fc:de",
+ "type": "NAModule1",
+ "module_name": "NetatmoOutdoor",
+ "data_type": [
+ "Temperature",
+ "Humidity"
+ ],
+ "last_setup": 1544558433,
+ "reachable": true,
+ "dashboard_data": {
+ "time_utc": 1559413157,
+ "Temperature": 28.6,
+ "Humidity": 24,
+ "min_temp": 16.9,
+ "max_temp": 30.3,
+ "date_min_temp": 1559365579,
+ "date_max_temp": 1559404698,
+ "temp_trend": "down"
+ },
+ "firmware": 46,
+ "last_message": 1559413177,
+ "last_seen": 1559413157,
+ "rf_status": 65,
+ "battery_vp": 5738,
+ "battery_percent": 87
+ },
+ {
+ "_id": "12:34:56:07:bb:3e",
+ "type": "NAModule4",
+ "module_name": "Kitchen",
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity"
+ ],
+ "last_setup": 1548956696,
+ "reachable": true,
+ "dashboard_data": {
+ "time_utc": 1559413125,
+ "Temperature": 28,
+ "CO2": 503,
+ "Humidity": 26,
+ "min_temp": 25,
+ "max_temp": 28,
+ "date_min_temp": 1559371577,
+ "date_max_temp": 1559412561,
+ "temp_trend": "up"
+ },
+ "firmware": 44,
+ "last_message": 1559413177,
+ "last_seen": 1559413177,
+ "rf_status": 73,
+ "battery_vp": 5687,
+ "battery_percent": 83
+ },
+ {
+ "_id": "12:34:56:07:bb:0e",
+ "type": "NAModule4",
+ "module_name": "Livingroom",
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity"
+ ],
+ "last_setup": 1548957209,
+ "reachable": true,
+ "dashboard_data": {
+ "time_utc": 1559413093,
+ "Temperature": 26.4,
+ "CO2": 451,
+ "Humidity": 31,
+ "min_temp": 25.1,
+ "max_temp": 26.4,
+ "date_min_temp": 1559365290,
+ "date_max_temp": 1559413093,
+ "temp_trend": "stable"
+ },
+ "firmware": 44,
+ "last_message": 1559413177,
+ "last_seen": 1559413093,
+ "rf_status": 84,
+ "battery_vp": 5626,
+ "battery_percent": 79
+ },
+ {
+ "_id": "12:34:56:03:1b:e4",
+ "type": "NAModule2",
+ "module_name": "Garden",
+ "data_type": [
+ "Wind"
+ ],
+ "last_setup": 1549193862,
+ "reachable": true,
+ "dashboard_data": {
+ "time_utc": 1559413170,
+ "WindStrength": 4,
+ "WindAngle": 217,
+ "GustStrength": 9,
+ "GustAngle": 206,
+ "max_wind_str": 21,
+ "max_wind_angle": 217,
+ "date_max_wind_str": 1559386669
+ },
+ "firmware": 19,
+ "last_message": 1559413177,
+ "last_seen": 1559413177,
+ "rf_status": 59,
+ "battery_vp": 5689,
+ "battery_percent": 85
+ },
+ {
+ "_id": "12:34:56:05:51:20",
+ "type": "NAModule3",
+ "module_name": "Yard",
+ "data_type": [
+ "Rain"
+ ],
+ "last_setup": 1549194580,
+ "reachable": true,
+ "dashboard_data": {
+ "time_utc": 1559413170,
+ "Rain": 0,
+ "sum_rain_24": 0,
+ "sum_rain_1": 0
+ },
+ "firmware": 8,
+ "last_message": 1559413177,
+ "last_seen": 1559413170,
+ "rf_status": 67,
+ "battery_vp": 5860,
+ "battery_percent": 93
+ }
+ ]
+ },
+ {
+ "_id": "12:34:56:00:aa:01",
+ "station_name": "MyRemoteStation",
+ "date_setup": 1499189962,
+ "last_setup": 1499189962,
+ "type": "NAMain",
+ "last_status_store": 1554506294,
+ "module_name": "Indoor",
+ "firmware": 132,
+ "last_upgrade": 1499189915,
+ "wifi_status": 46,
+ "reachable": false,
+ "co2_calibrating": false,
+ "data_type": [
+ "Temperature",
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure"
+ ],
+ "place": {
+ "altitude": 6,
+ "city": "Harstad",
+ "country": "NO",
+ "timezone": "Europe/Oslo",
+ "location": [
+ 59.895000,
+ 10.620000
+ ]
+ },
+ "modules": [
+ {
+ "_id": "12:34:56:00:aa:02",
+ "type": "NAModule1",
+ "module_name": "Outdoor",
+ "data_type": [
+ "Temperature",
+ "Humidity"
+ ],
+ "last_setup": 1499189902,
+ "battery_percent": 17,
+ "reachable": false,
+ "firmware": 44,
+ "last_message": 1536805739,
+ "last_seen": 1536696388,
+ "rf_status": 87,
+ "battery_vp": 4018,
+ "main_device": "12:34:56:00:aa:01"
+ },
+ {
+ "_id": "12:34:56:00:aa:03",
+ "type": "NAModule2",
+ "module_name": "Wind Gauge",
+ "data_type": [
+ "Wind"
+ ],
+ "last_setup": 1499190606,
+ "battery_percent": 3,
+ "reachable": false,
+ "firmware": 18,
+ "last_message": 1537259554,
+ "last_seen": 1537259554,
+ "rf_status": 74,
+ "battery_vp": 4013,
+ "main_device": "12:34:56:00:aa:01"
+ }
+ ]
+ }
+ ],
+ "user": {
+ "mail": "john@doe.com",
+ "administrative": {
+ "lang": "de-DE",
+ "reg_locale": "de-DE",
+ "country": "DE",
+ "unit": 0,
+ "windunit": 0,
+ "pressureunit": 0,
+ "feel_like_algo": 0
+ }
+ }
+ },
+ "status": "ok",
+ "time_exec": 0.91107702255249,
+ "time_server": 1559413602
+}
\ No newline at end of file
diff --git a/fixtures/weatherstation_measure.json b/fixtures/weatherstation_measure.json
new file mode 100644
index 000000000..1b7724ec6
--- /dev/null
+++ b/fixtures/weatherstation_measure.json
@@ -0,0 +1,46 @@
+{
+ "body": {
+ "1544558433": [
+ 28.1
+ ],
+ "1544558449": [
+ 28.4
+ ],
+ "1544558504": [
+ 27
+ ],
+ "1544558807": [
+ 24
+ ],
+ "1544559062": [
+ 23.8
+ ],
+ "1544559211": [
+ 26.1
+ ],
+ "1544559308": [
+ 24.9
+ ],
+ "1544559415": [
+ 24.6
+ ],
+ "1544559576": [
+ 24.2
+ ],
+ "1544559974": [
+ 26.9
+ ],
+ "1544560021": [
+ 27.1
+ ],
+ "1544560058": [
+ 27.4
+ ],
+ "1544560361": [
+ 26
+ ]
+ },
+ "status": "ok",
+ "time_exec": 0.33915495872498,
+ "time_server": 1560590041
+}
\ No newline at end of file
diff --git a/lnetatmo.py b/lnetatmo.py
deleted file mode 100644
index 1ce999e34..000000000
--- a/lnetatmo.py
+++ /dev/null
@@ -1,151 +0,0 @@
-# Published Jan 2013
-# Revised Jan 2014 (to add new modules data)
-# Author : Philippe Larduinat, philippelt@users.sourceforge.net
-# Public domain source code
-"""
-This API provides access to the Netatmo weather station or/and the Netatmo
-cameras or/and the Netatmo smart thermostat
-This package can be used with Python2 or Python3 applications and do not
-require anything else than standard libraries
-
-PythonAPI Netatmo REST data access
-coding=utf-8
-"""
-import time
-
-from smart_home.WeatherStation import WeatherStationData, DeviceList
-from smart_home.Camera import CameraData
-from smart_home.Thermostat import ThermostatData
-from smart_home import _BASE_URL, postRequest, NoDevice
-
-######################## USER SPECIFIC INFORMATION ######################
-
-# To be able to have a program accessing your netatmo data, you have to register your program as
-# a Netatmo app in your Netatmo account. All you have to do is to give it a name (whatever) and you will be
-# returned a client_id and secret that your app has to supply to access netatmo servers.
-
-_CLIENT_ID = "" # Your client ID from Netatmo app registration at http://dev.netatmo.com/dev/listapps
-_CLIENT_SECRET = "" # Your client app secret ' '
-_USERNAME = "" # Your netatmo account username
-_PASSWORD = "" # Your netatmo account password
-
-#########################################################################
-
-
-# Common definitions
-_AUTH_REQ = _BASE_URL + "oauth2/token"
-
-class ClientAuth:
- """
- Request authentication and keep access token available through token method. Renew it automatically if necessary
-
- Args:
- clientId (str): Application clientId delivered by Netatmo on dev.netatmo.com
- clientSecret (str): Application Secret key delivered by Netatmo on dev.netatmo.com
- username (str)
- password (str)
- scope (Optional[str]): Default value is 'read_station'
- read_station: to retrieve weather station data (Getstationsdata, Getmeasure)
- read_camera: to retrieve Welcome data (Gethomedata, Getcamerapicture)
- access_camera: to access the camera, the videos and the live stream.
- read_thermostat: to retrieve thermostat data ( Getmeasure, Getthermostatsdata)
- write_thermostat: to set up the thermostat (Syncschedule, Setthermpoint)
- read_presence: to retrieve Presence data (Gethomedata, Getcamerapicture)
- access_presence: to access the live stream, any video stored on the SD card and to retrieve Presence's lightflood status
- Several value can be used at the same time, ie: 'read_station read_camera'
- """
-
- def __init__(self, clientId=_CLIENT_ID,
- clientSecret=_CLIENT_SECRET,
- username=_USERNAME,
- password=_PASSWORD,
- scope="read_station"):
- postParams = {
- "grant_type": "password",
- "client_id": clientId,
- "client_secret": clientSecret,
- "username": username,
- "password": password,
- "scope": scope
- }
- resp = postRequest(_AUTH_REQ, postParams)
- self._clientId = clientId
- self._clientSecret = clientSecret
- self._accessToken = resp['access_token']
- self.refreshToken = resp['refresh_token']
- self._scope = resp['scope']
- self.expiration = int(resp['expire_in'] + time.time() - 1800)
-
- @property
- def accessToken(self):
-
- if self.expiration < time.time(): # Token should be renewed
- postParams = {
- "grant_type": "refresh_token",
- "refresh_token": self.refreshToken,
- "client_id": self._clientId,
- "client_secret": self._clientSecret
- }
- resp = postRequest(_AUTH_REQ, postParams)
- self._accessToken = resp['access_token']
- self.refreshToken = resp['refresh_token']
- self.expiration = int(resp['expire_in'] + time.time() - 1800)
- return self._accessToken
-
-
-class User:
- """
- This class returns basic information about the user
-
- Args:
- authData (ClientAuth): Authentication information with a working access Token
- """
- def __init__(self, authData):
- postParams = {
- "access_token" : authData.accessToken
- }
- resp = postRequest(_GETSTATIONDATA_REQ, postParams)
- self.rawData = resp['body']
- self.devList = self.rawData['devices']
- self.ownerMail = self.rawData['user']['mail']
-
-# auto-test when executed directly
-
-if __name__ == "__main__":
-
- from sys import exit, stdout, stderr
-
- if not _CLIENT_ID or not _CLIENT_SECRET or not _USERNAME or not _PASSWORD :
- stderr.write("Library source missing identification arguments to check lnetatmo.py (user/password/etc...)")
- exit(1)
-
- authorization = ClientAuth(scope="read_station read_camera access_camera read_thermostat write_thermostat read_presence access_presence") # Test authentication method
-
- try:
- devList = DeviceList(authorization) # Test DEVICELIST
- except NoDevice:
- if stdout.isatty():
- print("lnetatmo.py : warning, no weather station available for testing")
- else:
- devList.MinMaxTH() # Test GETMEASUR
-
-
- try:
- Camera = CameraData(authorization)
- except NoDevice :
- if stdout.isatty():
- print("lnetatmo.py : warning, no camera available for testing")
-
- try:
- Thermostat = ThermostatData(authorization)
- except NoDevice :
- if stdout.isatty():
- print("lnetatmo.py : warning, no thermostat available for testing")
-
- # If we reach this line, all is OK
-
- # If launched interactively, display OK message
- if stdout.isatty():
- print("lnetatmo.py : OK")
-
- exit(0)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 000000000..eb1952f1b
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["wheel", "setuptools", "attrs>=17.1"]
+build-backend = "setuptools.build_meta"
diff --git a/samples/graphLast3Days b/samples/graphLast3Days
deleted file mode 100755
index fe3e97e22..000000000
--- a/samples/graphLast3Days
+++ /dev/null
@@ -1,72 +0,0 @@
-#!/usr/bin/python
-# coding=utf-8
-
-# 2013-01 : philippelt@users.sourceforge.net
-
-# This is an example of graphing Temperature and Humidity from a module on the last 3 days
-# The Matplotlib library is used and should be installed before running this sample program
-
-import datetime, time
-
-import lnetatmo
-
-from matplotlib import pyplot as plt
-from matplotlib import dates
-from matplotlib.ticker import FormatStrFormatter
-
-# Access to the sensors
-auth = lnetatmo.ClientAuth()
-dev = lnetatmo.DeviceList(auth)
-
-# Time of information collection : 3*24hours windows to now
-now = time.time()
-start = now - 3 * 24 * 3600
-
-# Get Temperature and Humidity with GETMEASURE web service (1 sample every 30min)
-resp = dev.getMeasure( device_id='xxxx', # Replace with your values
- module_id='xxxx', # " " " "
- scale="30min",
- mtype="Temperature,Humidity",
- date_begin=start,
- date_end=now)
-
-# Extract the timestamp, temperature and humidity from the more complex response structure
-result = [(int(k),v[0],v[1]) for k,v in resp['body'].items()]
-# Sort samples by timestamps (Warning, they are NOT sorted by default)
-result.sort()
-# Split in 3 lists for use with Matplotlib (timestamp on x, temperature and humidity on two y axis)
-xval, ytemp, yhum = zip(*result)
-
-# Convert the x axis values from Netatmo timestamp to matplotlib timestamp...
-xval = [dates.date2num(datetime.datetime.fromtimestamp(x)) for x in xval]
-
-# Build the two curves graph (check Matplotlib documentation for details)
-fig = plt.figure()
-plt.xticks(rotation='vertical')
-
-graph1 = fig.add_subplot(111)
-
-graph1.plot(xval, ytemp, color='r', linewidth=3)
-graph1.set_ylabel(u'Température', color='r')
-graph1.set_ylim(0, 25)
-graph1.yaxis.grid(color='gray', linestyle='dashed')
-for t in graph1.get_yticklabels() : t.set_color('r')
-graph1.yaxis.set_major_formatter(FormatStrFormatter(u'%2.0f °C'))
-
-graph2 = graph1.twinx()
-
-graph2.plot(xval, yhum, color='b', linewidth=3)
-graph2.set_ylabel(u'Humidité',color='b')
-graph2.set_ylim(50,100)
-for t in graph2.get_yticklabels(): t.set_color('b')
-graph2.yaxis.set_major_formatter(FormatStrFormatter(u'%2i %%'))
-
-graph1.xaxis.set_major_locator(dates.HourLocator(interval=6))
-graph1.xaxis.set_minor_locator(dates.HourLocator())
-graph1.xaxis.set_major_formatter(dates.DateFormatter("%d-%Hh"))
-graph1.xaxis.grid(color='gray')
-graph1.set_xlabel(u'Jour et heure de la journée')
-
-# X display the resulting graph (you could generate a PDF/PNG/... in place of display).
-# The display provides a minimal interface that notably allows you to save your graph
-plt.show()
diff --git a/samples/printAllLastData b/samples/printAllLastData
deleted file mode 100755
index 08165423e..000000000
--- a/samples/printAllLastData
+++ /dev/null
@@ -1,66 +0,0 @@
-#!/usr/bin/python3
-# encoding=utf-8
-
-# 2014-01 : philippelt@users.sourceforge.net
-
-# Just connect to a Netatmo account, and print all last informations available for
-# station and modules of the user account
-# (except if module data is more than one hour old that usually means module lost
-# wether out of radio range or battery exhausted thus information is no longer
-# significant)
-
-import time
-import lnetatmo
-
-authorization = lnetatmo.ClientAuth()
-devList = lnetatmo.WeatherStationData(authorization)
-
-# For each available module in the returned data that should not be older than one hour (3600 s) from now
-for module, moduleData in devList.lastData(exclude=3600).items() :
-
- # Name of the module (or station embedded module), the name you defined in the web netatmo account station management
- print(module)
-
- # List key/values pair of sensor information (eg Humidity, Temperature, etc...)
- for sensor, value in moduleData.items() :
- # To ease reading, print measurement event in readable text (hh:mm:ss)
- if sensor == "When" : value = time.strftime("%H:%M:%S",time.localtime(value))
- print("%30s : %s" % (sensor, value))
-
-
-# OUTPUT SAMPLE :
-#
-# $ printAllLastData
-#
-#Office
-# AbsolutePressure : 988.7
-# CO2 : 726
-# date_max_temp : 1400760301
-# date_min_temp : 1400736146
-# Humidity : 60
-# max_temp : 19.6
-# min_temp : 17.9
-# Noise : 46
-# Particle : 12768
-# Pressure : 988.7
-# Temperature : 19.6
-# When : 14:10:01
-#Outdoor
-# battery_vp : 5200
-# CO2 : 555
-# date_max_temp : 1400759951
-# date_min_temp : 1400732524
-# Humidity : 75
-# max_temp : 17.9
-# min_temp : 10.3
-# rf_status : 57
-# Temperature : 17.9
-# When : 14:09:25
-#Greenhouse
-# date_min_temp : 1400732204
-# Humidity : 89
-# max_temp : 19.9
-# min_temp : 9.1
-# rf_status : 83
-# Temperature : 19.9
-# When : 14:09:12
diff --git a/samples/simpleLastData b/samples/simpleLastData
deleted file mode 100755
index 0d3b67847..000000000
--- a/samples/simpleLastData
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/usr/bin/python3
-# encoding=utf-8
-
-# 2013-01 : philippelt@users.sourceforge.net
-
-# Just connect to a Netatmo account, and print internal and external temperature of the default (or single) station
-# In this case, sensors of the station and the external module have been named 'internal' and 'external' in the
-# Account station settings.
-
-import lnetatmo
-
-authorization = lnetatmo.ClientAuth()
-devList = lnetatmo.WeatherStationData(authorization)
-
-print ("Current temperature (inside/outside): %s / %s °C" %
- ( devList.lastData()['internal']['Temperature'],
- devList.lastData()['external']['Temperature'])
- )
-
diff --git a/samples/smsAlarm b/samples/smsAlarm
deleted file mode 100755
index 82c7b74f9..000000000
--- a/samples/smsAlarm
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/python3
-# encoding=utf-8
-
-# 2013-01 : philippelt@users.sourceforge.net
-
-# Simple example run in a cron job (every 30' for example) to send an alarm SMS if some condition is reached
-# and an other SMS when condition returned to normal. In both case, a single SMS is emitted to multiple
-# peoples (here phone1 and phone2 are two mobile phone numbers)
-# Note : lsms is my personnal library to send SMS using a GSM modem, you have to use your method/library
-
-import sys, os
-import lnetatmo,lsms
-
-MARKER_FILE = "//TempAlarm" # This flag file will be used to avoid sending multiple SMS on the same event
- # Remember that the user who run the cron job must have the rights to create the file
-
-# Access the station
-authorization = lnetatmo.ClientAuth()
-devList = lnetatmo.WeatherStationData(authorization)
-
-message = []
-
-# Condition 1 : the external temperature is below our limit
-curT = devList.lastData()['external']['Temperature']
-if curT < 5 : message.append("Temperature going below 5°C")
-
-# Condition 2 : The external temperature data is older that 1 hour
-someLost = devList.checkNotUpdated()
-if someLost and 'external' in someLost : message.append("Sensor is no longer active")
-
-# Condition 3 : The outdoor module battery is dying
-volts = devList.lastData()['external']['battery_vp'] # I suspect that this is the total Voltage in mV
-if volts < 5000 : message.append("External module battery needs replacement") # I will adjust the threshold over time
-
-# If one condition is present, at least, send an alarm by SMS
-if message :
- if not os.path.exists(MARKER_FILE) :
- message = "WEATHER ALERT\n" + "\n".join(message)
- for p in ('', '') :
- lsms.sendSMS(p, message, flash=True)
- open(MARKER_FILE,"w").close() # Just to create the empty marker file and avoid to resend the same alert
-else :
- if os.path.exists(MARKER_FILE) :
- os.remove(MARKER_FILE)
- for p in ('', '') :
- lsms.sendSMS(p, "END of WEATHER alert, current temperature is %s°C" % curT)
-
-sys.exit(0)
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 000000000..b9a6a9f21
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,70 @@
+[metadata]
+name = pyatmo
+version = attr:version.__version__
+description = Simple API to access Netatmo weather station data from any Python 3 script. Designed for Home-Assitant (but not only)
+long_description = file: README.md
+long_description_content_type = text/markdown
+url = https://github.com/jabesq/netatmo-api-python
+author = Hugo Dupras
+author_email = jabesq@gmail.com
+license = MIT
+license_file = LICENSE.txt
+classifiers =
+ License :: OSI Approved :: MIT License
+ Programming Language :: Python :: 3
+ Programming Language :: Python :: 3 :: Only
+ Programming Language :: Python :: 3.7
+ Programming Language :: Python :: 3.8
+ Programming Language :: Python :: 3.9
+ Programming Language :: Python :: Implementation :: CPython
+ Programming Language :: Python :: Implementation :: PyPy
+ Topic :: Home Automation
+
+[options]
+packages = find:
+py_modules = version
+install_requires =
+ oauthlib~=3.1.0
+ requests~=2.23.0
+ requests_oauthlib~=1.3.0
+python_requires = ~=3.7
+package_dir = =src
+
+[options.packages.find]
+exclude = tests
+where = src
+
+[flake8]
+max-line-length = 88
+ignore = W503, E501
+
+[pep8]
+max-line-length = 88
+ignore = W503, E501
+
+[mypy]
+ignore_errors = True
+ignore_missing_imports = True
+
+[mypy-pyatmo.auth]
+ignore_errors = False
+
+[mypy-pyatmo.camera]
+ignore_errors = False
+
+[mypy-pyatmo.exceptions]
+ignore_errors = False
+
+[mypy-pyatmo.helpers]
+ignore_errors = False
+
+[isort]
+multi_line_output = 3
+include_trailing_comma = True
+force_grid_wrap = 0
+use_parentheses = True
+forced_separate = tests
+combine_as_imports = true
+line_length = 88
+skip_glob = venv
+known_third_party = freezegun,oauthlib,pytest,requests,requests_oauthlib,setuptools,tests
diff --git a/setup.py b/setup.py
index a6e44754c..606849326 100755
--- a/setup.py
+++ b/setup.py
@@ -1,21 +1,3 @@
+from setuptools import setup
-# python setup.py --dry-run --verbose install
-
-from distutils.core import setup
-
-
-setup(
- name='lnetatmo',
- version='0.9.2.1', # Should be updated with new versions
- author='Philippe Larduinat',
- author_email='philippelt@users.sourceforge.net',
- py_modules=['lnetatmo'],
- packages=['smart_home'],
- package_dir={'smart_home': 'smart_home'},
- scripts=[],
- data_files=[],
- url='https://github.com/philippelt/netatmo-api-python',
- license='Open Source',
- description='Simple API to access Netatmo weather station data from any python script.',
- long_description=open('README.md').read()
-)
+setup()
diff --git a/smart_home/Camera.py b/smart_home/Camera.py
deleted file mode 100644
index 75359557a..000000000
--- a/smart_home/Camera.py
+++ /dev/null
@@ -1,506 +0,0 @@
-"""
-coding=utf-8
-"""
-import imghdr
-import time
-
-from urllib.error import URLError
-from . import NoDevice, postRequest, _BASE_URL
-
-_GETHOMEDATA_REQ = _BASE_URL + "api/gethomedata"
-_GETCAMERAPICTURE_REQ = _BASE_URL + "api/getcamerapicture"
-_GETEVENTSUNTIL_REQ = _BASE_URL + "api/geteventsuntil"
-
-
-class CameraData:
- """
- List the Netatmo cameras informations
- (Homes, cameras, modules, events, persons)
- Args:
- authData (ClientAuth):
- Authentication information with a working access Token
- """
- def __init__(self, authData, size=15):
- self.getAuthToken = authData.accessToken
- postParams = {
- "access_token": self.getAuthToken,
- "size": size
- }
- resp = postRequest(_GETHOMEDATA_REQ, postParams)
- self.rawData = resp['body']
- self.homes = {d['id']: d for d in self.rawData['homes']}
- if not self.homes:
- raise NoDevice("No camera available")
- self.persons = dict()
- self.events = dict()
- self.outdoor_events = dict()
- self.cameras = dict()
- self.modules = dict()
- self.lastEvent = dict()
- self.outdoor_lastEvent = dict()
- self.types = dict()
- for i in range(len(self.rawData['homes'])):
- nameHome = self.rawData['homes'][i]['name']
- if nameHome not in self.cameras:
- self.cameras[nameHome] = dict()
- if nameHome not in self.types:
- self.types[nameHome] = dict()
- for p in self.rawData['homes'][i]['persons']:
- self.persons[p['id']] = p
- for e in self.rawData['homes'][i]['events']:
- if e['type'] == 'outdoor':
- if e['camera_id'] not in self.outdoor_events:
- self.outdoor_events[e['camera_id']] = dict()
- self.outdoor_events[e['camera_id']][e['time']] = e
- elif e['type'] != 'outdoor':
- if e['camera_id'] not in self.events:
- self.events[e['camera_id']] = dict()
- self.events[e['camera_id']][e['time']] = e
- for c in self.rawData['homes'][i]['cameras']:
- self.cameras[nameHome][c['id']] = c
- if c['type'] == 'NACamera' and 'modules' in c :
- for m in c['modules']:
- self.modules[m['id']] = m
- self.modules[m['id']]['cam_id'] = c['id']
- for t in self.rawData['homes'][i]['cameras']:
- self.types[nameHome][t['type']] = t
- for camera in self.events:
- self.lastEvent[camera] = self.events[camera][
- sorted(self.events[camera])[-1]]
- for camera in self.outdoor_events:
- self.outdoor_lastEvent[camera] = self.outdoor_events[camera][
- sorted(self.outdoor_events[camera])[-1]]
- self.default_home = list(self.homes.values())[0]['name']
- if self.modules != {}:
- self.default_module = list(self.modules.values())[0]['name']
- else:
- self.default_module = None
- self.default_camera = list(self.cameras[self.default_home].values())[0]
-
- def homeById(self, hid):
- return None if hid not in self.homes else self.homes[hid]
-
- def homeByName(self, home=None):
- if not home:
- return self.homeByName(self.default_home)
- for key, value in self.homes.items():
- if value['name'] == home:
- return self.homes[key]
-
- def cameraById(self, cid):
- for home, cam in self.cameras.items():
- if cid in self.cameras[home]:
- return self.cameras[home][cid]
- return None
-
- def cameraByName(self, camera=None, home=None):
- if not camera and not home:
- return self.default_camera
- elif home and camera:
- if home not in self.cameras:
- return None
- for cam_id in self.cameras[home]:
- if self.cameras[home][cam_id]['name'] == camera:
- return self.cameras[home][cam_id]
- elif not home and camera:
- for home, cam_ids in self.cameras.items():
- for cam_id in cam_ids:
- if self.cameras[home][cam_id]['name'] == camera:
- return self.cameras[home][cam_id]
- else:
- return list(self.cameras[home].values())[0]
- return None
-
- def moduleById(self, mid):
- return None if mid not in self.modules else self.modules[mid]
-
- def moduleByName(self, module=None, camera=None, home=None):
- if not module:
- if self.default_module:
- return self.moduleByName(self.default_module)
- else:
- return None
- cam = None
- if camera or home:
- cam = self.cameraByName(camera, home)
- if not cam:
- return None
- for key, value in self.modules.items():
- if value['name'] == module:
- if cam and value['cam_id'] != cam['id']:
- return None
- return self.modules[key]
- return None
-
- def cameraType(self, camera=None, home=None, cid=None):
- """
- Return the type of a given camera.
- """
- cameratype = None
- if cid:
- camera_data = self.cameraById(cid)
- else:
- camera_data = self.cameraByName(camera=camera, home=home)
- if camera_data:
- cameratype = camera_data['type']
- return cameratype
-
- def cameraUrls(self, camera=None, home=None, cid=None):
- """
- Return the vpn_url and the local_url (if available) of a given camera
- in order to access to its live feed
- """
- local_url = None
- vpn_url = None
- if cid:
- camera_data = self.cameraById(cid)
- else:
- camera_data = self.cameraByName(camera=camera, home=home)
- if camera_data:
- vpn_url = camera_data['vpn_url']
- if camera_data['is_local']:
- try:
- resp = postRequest('{0}/command/ping'.format(
- camera_data['vpn_url']), dict())
- temp_local_url = resp['local_url']
- except URLError:
- return None, None
-
- try:
- resp = postRequest('{0}/command/ping'.format(
- temp_local_url), dict())
- if temp_local_url == resp['local_url']:
- local_url = temp_local_url
- except URLError:
- pass
- return vpn_url, local_url
-
- def personsAtHome(self, home=None):
- """
- Return the list of known persons who are currently at home
- """
- if not home:
- home = self.default_home
- home_data = self.homeByName(home)
- atHome = []
- for p in home_data['persons']:
- # Only check known persons
- if 'pseudo' in p:
- if not p["out_of_sight"]:
- atHome.append(p['pseudo'])
- return atHome
-
- def getCameraPicture(self, image_id, key):
- """
- Download a specific image (of an event or user face) from the camera
- """
- postParams = {
- "access_token": self.getAuthToken,
- "image_id": image_id,
- "key": key
- }
- resp = postRequest(_GETCAMERAPICTURE_REQ, postParams)
- image_type = imghdr.what('NONE.FILE', resp)
- return resp, image_type
-
- def getProfileImage(self, name):
- """
- Retrieve the face of a given person
- """
- for p in self.persons:
- if 'pseudo' in self.persons[p]:
- if name == self.persons[p]['pseudo']:
- image_id = self.persons[p]['face']['id']
- key = self.persons[p]['face']['key']
- return self.getCameraPicture(image_id, key)
- return None, None
-
- def updateEvent(self, event=None, home=None, cameratype=None):
- """
- Update the list of event with the latest ones
- """
- if not home:
- home = self.default_home
- if cameratype == 'NACamera':
- # for the Welcome camera
- if not event:
- # If not event is provided we need to retrieve the oldest of
- # the last event seen by each camera
- listEvent = dict()
- for cam_id in self.lastEvent:
- listEvent[self.lastEvent[cam_id]['time']] =\
- self.lastEvent[cam_id]
- event = listEvent[sorted(listEvent)[0]]
- if cameratype == 'NOC':
- # for the Presence camera
- if not event:
- # If not event is provided we need to retrieve the oldest of
- # the last event seen by each camera
- listEvent = dict()
- for cam_id in self.outdoor_lastEvent:
- listEvent[self.outdoor_lastEvent[cam_id]['time']] =\
- self.outdoor_lastEvent[cam_id]
- event = listEvent[sorted(listEvent)[0]]
-
- home_data = self.homeByName(home)
- postParams = {
- "access_token": self.getAuthToken,
- "home_id": home_data['id'],
- "event_id": event['id']
- }
- resp = postRequest(_GETEVENTSUNTIL_REQ, postParams)
- eventList = resp['body']['events_list']
- for e in eventList:
- if e['type'] == 'outdoor':
- self.outdoor_events[e['camera_id']][e['time']] = e
- elif e['type'] != 'outdoor':
- self.events[e['camera_id']][e['time']] = e
- for camera in self.events:
- self.lastEvent[camera] = self.events[camera][
- sorted(self.events[camera])[-1]]
- for camera in self.outdoor_events:
- self.outdoor_lastEvent[camera] = self.outdoor_events[camera][
- sorted(self.outdoor_events[camera])[-1]]
-
- def personSeenByCamera(self, name, home=None, camera=None, exclude=0):
- """
- Return True if a specific person has been seen by a camera
- """
- try:
- cam_id = self.cameraByName(camera=camera, home=home)['id']
- except TypeError:
- print("personSeenByCamera: Camera name or home is unknown")
- return False
- # Check in the last event is someone known has been seen
- if exclude:
- limit = (time.time() - exclude)
- array_time_event = sorted(self.events[cam_id])
- array_time_event.reverse()
- for time_ev in array_time_event:
- if time_ev < limit:
- return False
- elif self.events[cam_id][time_ev]['type'] == 'person':
- person_id = self.events[cam_id][time_ev]['person_id']
- if 'pseudo' in self.persons[person_id]:
- if self.persons[person_id]['pseudo'] == name:
- return True
- elif self.lastEvent[cam_id]['type'] == 'person':
- person_id = self.lastEvent[cam_id]['person_id']
- if 'pseudo' in self.persons[person_id]:
- if self.persons[person_id]['pseudo'] == name:
- return True
- return False
-
- def _knownPersons(self):
- known_persons = dict()
- for p_id, p in self.persons.items():
- if 'pseudo' in p:
- known_persons[p_id] = p
- return known_persons
-
- def knownPersonsNames(self):
- names = []
- for p_id,p in self._knownPersons().items():
- names.append(p['pseudo'])
- return names
-
- def someoneKnownSeen(self, home=None, camera=None, exclude=0):
- """
- Return True if someone known has been seen
- """
- try:
- cam_id = self.cameraByName(camera=camera, home=home)['id']
- except TypeError:
- print("someoneKnownSeen: Camera name or home is unknown")
- return False
-
- if exclude:
- limit = (time.time() - exclude)
- array_time_event = sorted(self.events[cam_id])
- array_time_event.reverse()
- for time_ev in array_time_event:
- if time_ev < limit:
- return False
- elif self.events[cam_id][time_ev]['type'] == 'person':
- if self.events[cam_id][time_ev][
- 'person_id'] in self._knownPersons():
- return True
- # Check in the last event is someone known has been seen
- elif self.lastEvent[cam_id]['type'] == 'person':
- if self.lastEvent[cam_id]['person_id'] in self._knownPersons():
- return True
- return False
-
- def someoneUnknownSeen(self, home=None, camera=None, exclude=0):
- """
- Return True if someone unknown has been seen
- """
- try:
- cam_id = self.cameraByName(camera=camera, home=home)['id']
- except TypeError:
- print("someoneUnknownSeen: Camera name or home is unknown")
- return False
-
- if exclude:
- limit = (time.time() - exclude)
- array_time_event = sorted(self.events[cam_id])
- array_time_event.reverse()
- for time_ev in array_time_event:
- if time_ev < limit:
- return False
- elif self.events[cam_id][time_ev]['type'] == 'person':
- if self.events[cam_id][time_ev][
- 'person_id'] not in self._knownPersons():
- return True
- # Check in the last event is someone known has been seen
- elif self.lastEvent[cam_id]['type'] == 'person':
- if self.lastEvent[cam_id]['person_id'] not in self._knownPersons():
- return True
- return False
-
- def motionDetected(self, home=None, camera=None, exclude=0):
- """
- Return True if movement has been detected
- """
- try:
- cam_id = self.cameraByName(camera=camera, home=home)['id']
- except TypeError:
- print("motionDetected: Camera name or home is unknown")
- return False
-
- if exclude:
- limit = (time.time() - exclude)
- array_time_event = sorted(self.events[cam_id])
- array_time_event.reverse()
- for time_ev in array_time_event:
- if time_ev < limit:
- return False
- elif self.events[cam_id][time_ev]['type'] == 'movement':
- return True
- elif self.lastEvent[cam_id]['type'] == 'movement':
- return True
- return False
-
- def outdoormotionDetected(self, home=None, camera=None, offset=0):
- """
- Return True if outdoor movement has been detected
- """
- try:
- cam_id = self.cameraByName(camera=camera, home=home)['id']
- except TypeError:
- print("outdoormotionDetected: Camera name or home is unknown")
- return False
- if self.lastEvent[cam_id]['type'] == 'movement':
- if self.lastEvent[cam_id]['video_status'] == 'recording' and\
- self.lastEvent[cam_id]['time'] + offset > int(time.time()):
- return True
- return False
-
- def humanDetected(self, home=None, camera=None, offset=0):
- """
- Return True if a human has been detected
- """
- try:
- cam_id = self.cameraByName(camera=camera, home=home)['id']
- except TypeError:
- print("personSeenByCamera: Camera name or home is unknown")
- return False
- if self.outdoor_lastEvent[cam_id]['video_status'] == 'recording':
- for e in self.outdoor_lastEvent[cam_id]['event_list']:
- if e['type'] ==\
- 'human' and e['time'] + offset > int(time.time()):
- return True
- return False
-
- def animalDetected(self, home=None, camera=None, offset=0):
- """
- Return True if an animal has been detected
- """
- try:
- cam_id = self.cameraByName(camera=camera, home=home)['id']
- except TypeError:
- print("animalDetected: Camera name or home is unknown")
- return False
-
- if self.outdoor_lastEvent[cam_id]['video_status'] == 'recording':
- for e in self.outdoor_lastEvent[cam_id]['event_list']:
- if e['type'] ==\
- 'animal' and e['time'] + offset > int(time.time()):
- return True
- return False
-
- def carDetected(self, home=None, camera=None, offset=0):
- """
- Return True if a car has been detected
- """
- try:
- cam_id = self.cameraByName(camera=camera, home=home)['id']
- except TypeError:
- print("carDetected: Camera name or home is unknown")
- return False
-
- if self.outdoor_lastEvent[cam_id]['video_status'] == 'recording':
- for e in self.outdoor_lastEvent[cam_id]['event_list']:
- if e['type'] ==\
- 'vehicle' and e['time'] + offset > int(time.time()):
- return True
- return False
-
- def moduleMotionDetected(self, module=None, home=None,
- camera=None, exclude=0):
- """
- Return True if movement has been detected
- """
- try:
- mod = self.moduleByName(module, camera=camera, home=home)
- mod_id = mod['id']
- cam_id = mod['cam_id']
- except TypeError:
- print("moduleMotionDetected: Module name or"
- "Camera name or home is unknown")
- return False
-
- if exclude:
- limit = (time.time() - exclude)
- array_time_event = sorted(self.events[cam_id])
- array_time_event.reverse()
- for time_ev in array_time_event:
- if time_ev < limit:
- return False
- elif (self.events[cam_id][time_ev]['type'] == 'tag_big_move'
- or self.events[cam_id][time_ev]['type'] ==
- 'tag_small_move') and\
- self.events[cam_id][time_ev]['module_id'] == mod_id:
- return True
- elif (self.lastEvent[cam_id]['type'] == 'tag_big_move' or
- self.lastEvent[cam_id]['type'] == 'tag_small_move') and\
- self.lastEvent[cam_id]['module_id'] == mod_id:
- return True
- return False
-
- def moduleOpened(self, module=None, home=None, camera=None, exclude=0):
- """
- Return True if module status is open
- """
- try:
- mod = self.moduleByName(module, camera=camera, home=home)
- mod_id = mod['id']
- cam_id = mod['cam_id']
- except TypeError:
- print("moduleOpened: Camera name, or home, or module is unknown")
- return False
-
- if exclude:
- limit = (time.time() - exclude)
- array_time_event = sorted(self.events[cam_id])
- array_time_event.reverse()
- for time_ev in array_time_event:
- if time_ev < limit:
- return False
- elif self.events[cam_id][time_ev]['type'] == 'tag_open' and\
- self.events[cam_id][time_ev]['module_id'] == mod_id:
- return True
- elif self.lastEvent[cam_id]['type'] == 'tag_open' and\
- self.lastEvent[cam_id]['module_id'] == mod_id:
- return True
- return False
diff --git a/smart_home/Thermostat.py b/smart_home/Thermostat.py
deleted file mode 100644
index db260fb1c..000000000
--- a/smart_home/Thermostat.py
+++ /dev/null
@@ -1,120 +0,0 @@
-"""
-coding=utf-8
-"""
-import time
-
-from . import NoDevice, postRequest, _BASE_URL
-
-_SETTEMP_REQ = _BASE_URL + "api/setthermpoint"
-_GETTHERMOSTATDATA_REQ = _BASE_URL + "api/getthermostatsdata"
-
-class ThermostatData:
- """
- List the Thermostat devices (relays and thermostat modules)
-
- Args:
- authData (ClientAuth): Authentication information with a working access Token
- """
- def __init__(self, authData):
- self.getAuthToken = authData.accessToken
- postParams = {
- "access_token" : self.getAuthToken
- }
- resp = postRequest(_GETTHERMOSTATDATA_REQ, postParams)
-
- self.rawData = resp['body']
- self.devList = self.rawData['devices']
- if not self.devList : raise NoDevice("No thermostat available")
- self.devId = self.devList[0]['_id']
- self.modList = self.devList[0]['modules']
- self.modId = self.modList[0]['_id']
- self.temp = self.modList[0]['measured']['temperature']
- self.setpoint_temp = self.modList[0]['measured']['setpoint_temp']
- self.setpoint_mode = self.modList[0]['setpoint']['setpoint_mode']
- self.relay_cmd = int(self.modList[0]['therm_relay_cmd'])
- self.devices = { d['_id'] : d for d in self.rawData['devices'] }
- self.modules = dict()
- self.therm_program_list = dict()
- self.zones = dict()
- self.timetable = dict()
- for i in range(len(self.rawData['devices'])):
- nameDevice=self.rawData['devices'][i]['station_name']
- if nameDevice not in self.modules:
- self.modules[nameDevice]=dict()
- for m in self.rawData['devices'][i]['modules']:
- self.modules[nameDevice][ m['_id'] ] = m
- for p in self.rawData['devices'][i]['modules'][0]['therm_program_list']:
- self.therm_program_list[p['program_id']] = p
- for z in self.rawData['devices'][i]['modules'][0]['therm_program_list'][0]['zones']:
- self.zones[z['id']] = z
- for o in self.rawData['devices'][i]['modules'][0]['therm_program_list'][0]['timetable']:
- self.timetable[o['m_offset']] = o
- self.default_device = list(self.devices.values())[0]['station_name']
-
- self.default_module = list(self.modules[self.default_device].values())[0]['module_name']
-
- def lastData(self, device=None, exclude=0):
- s = self.deviceByName(device)
- if not s : return None
- lastD = dict()
- zones = dict()
- # Define oldest acceptable sensor measure event
- limit = (time.time() - exclude) if exclude else 0
- dm = s['modules'][0]['measured']
- ds = s['modules'][0]['setpoint']['setpoint_mode']
- dz = s['modules'][0]['therm_program_list'][0]['zones']
- for module in s['modules']:
- dm = module['measured']
- ds = module['setpoint']['setpoint_mode']
- dz = module['therm_program_list'][0]['zones']
- if dm['time'] > limit :
- lastD[module['module_name']] = dm.copy() # lastD['setpoint_mode'] = ds
- lastD[module['module_name']]['setpoint_mode'] = ds
- # For potential use, add battery and radio coverage information to module data if present
- for i in ('battery_vp', 'rf_status', 'therm_relay_cmd', 'battery_percent') :
- if i in module : lastD[module['module_name']][i] = module[i]
- zones[module['module_name']] = dz.copy()
- return lastD
-
- def deviceById(self, did):
- return None if did not in self.devices else self.devices[did]
-
- def deviceByName(self, device):
- if not device: device = self.default_device
- for key,value in self.devices.items():
- if value['station_name'] == device:
- return self.devices[key]
-
- def moduleById(self, mid):
- for device,mod in self.modules.items():
- if mid in self.modules[device]:
- return self.modules[device][mid]
- return None
-
- def moduleByName(self, module=None, device=None):
- if not module and not device:
- return self.default_module
- elif device and module:
- if device not in self.modules:
- return None
- for mod_id in self.modules[device]:
- if self.modules[device][mod_id]['module_name'] == module:
- return self.modules[device][mod_id]
- elif not device and module:
- for device, mod_ids in self.modules.items():
- for mod_id in mod_ids:
- if self.modules[device][mod_id]['module_name'] == module:
- return self.modules[device][mod_id]
- else:
- return list(self.modules[device].values())[0]
- return None
-
- def setthermpoint(self, mode, temp, endTimeOffset):
- postParams = {"access_token": self.getAuthToken}
- postParams['device_id'] = self.devId
- postParams['module_id'] = self.modId
- postParams['setpoint_mode'] = mode
- if mode == "manual":
- postParams['setpoint_endtime'] = time.time() + endTimeOffset
- postParams['setpoint_temp'] = temp
- return postRequest(_SETTEMP_REQ, postParams)
diff --git a/smart_home/WeatherStation.py b/smart_home/WeatherStation.py
deleted file mode 100644
index 74e61fe9f..000000000
--- a/smart_home/WeatherStation.py
+++ /dev/null
@@ -1,189 +0,0 @@
-"""
-coding=utf-8
-"""
-import warnings, time
-
-from . import NoDevice, postRequest, todayStamps, _BASE_URL
-
-_GETMEASURE_REQ = _BASE_URL + "api/getmeasure"
-_GETSTATIONDATA_REQ = _BASE_URL + "api/getstationsdata"
-
-class WeatherStationData:
- """
- List the Weather Station devices (stations and modules)
-
- Args:
- authData (ClientAuth): Authentication information with a working access Token
- """
- def __init__(self, authData):
- self.getAuthToken = authData.accessToken
- postParams = {
- "access_token" : self.getAuthToken
- }
- resp = postRequest(_GETSTATIONDATA_REQ, postParams)
- self.rawData = resp['body']['devices']
- if not self.rawData : raise NoDevice("No weather station available")
- self.stations = { d['_id'] : d for d in self.rawData }
- self.modules = dict()
- for i in range(len(self.rawData)):
- for m in self.rawData[i]['modules']:
- self.modules[ m['_id'] ] = m
- self.modules[ m['_id'] ][ 'main_device' ] = self.rawData[i]['_id']
- self.default_station = list(self.stations.values())[0]['station_name']
-
- def modulesNamesList(self, station=None):
- res = [m['module_name'] for m in self.modules.values()]
- if station:
- res.append(self.stationByName(station)['module_name'])
- else:
- for id,station in self.stations.items():
- res.append(station['module_name'])
- return res
-
- def stationByName(self, station=None):
- if not station : station = self.default_station
- for i,s in self.stations.items():
- if s['station_name'] == station :
- return self.stations[i]
- return None
-
- def stationById(self, sid):
- return None if sid not in self.stations else self.stations[sid]
-
- def moduleByName(self, module, station=None):
- s = None
- if station :
- s = self.stationByName(station)
- if not s : return None
- elif s['module_name'] == module:
- return s
- else:
- for id, station in self.stations.items():
- if station['module_name'] == module:
- return station
- for m in self.modules:
- mod = self.modules[m]
- if mod['module_name'] == module :
- if not s or mod['main_device'] == s['_id'] : return mod
- return None
-
- def moduleById(self, mid, sid=None):
- s = self.stationById(sid) if sid else None
- if mid in self.modules :
- if s:
- for module in s['modules']:
- if module['_id'] == mid:
- return module
- else:
- return self.modules[mid]
-
- def monitoredConditions(self, module):
- mod = self.moduleByName(module)
- conditions = []
- for cond in mod['data_type']:
- if cond == 'Wind':
- # the Wind meter actually exposes the following conditions
- conditions.extend(['windangle', 'windstrength', 'gustangle', 'guststrength'])
- else:
- conditions.append(cond.lower())
- if mod['type'] == 'NAMain':
- # the main module has wifi_status
- conditions.append('wifi_status')
- else:
- # assume all other modules have rf_status and battery_vp
- conditions.extend(['rf_status', 'battery_vp'])
- return conditions
-
- def lastData(self, station=None, exclude=0):
- s = self.stationByName(station)
- if not s : return None
- lastD = dict()
- # Define oldest acceptable sensor measure event
- limit = (time.time() - exclude) if exclude else 0
- ds = s['dashboard_data']
- if ds['time_utc'] > limit :
- lastD[s['module_name']] = ds.copy()
- lastD[s['module_name']]['When'] = lastD[s['module_name']].pop("time_utc")
- lastD[s['module_name']]['wifi_status'] = s['wifi_status']
- for module in s["modules"]:
- ds = module['dashboard_data']
- if ds['time_utc'] > limit :
- lastD[module['module_name']] = ds.copy()
- lastD[module['module_name']]['When'] = lastD[module['module_name']].pop("time_utc")
- # For potential use, add battery and radio coverage information to module data if present
- for i in ('battery_vp', 'rf_status') :
- if i in module : lastD[module['module_name']][i] = module[i]
- return lastD
-
- def checkNotUpdated(self, station=None, delay=3600):
- res = self.lastData(station)
- ret = []
- for mn,v in res.items():
- if time.time()-v['When'] > delay : ret.append(mn)
- return ret if ret else None
-
- def checkUpdated(self, station=None, delay=3600):
- res = self.lastData(station)
- ret = []
- for mn,v in res.items():
- if time.time()-v['When'] < delay : ret.append(mn)
- return ret if ret else None
-
- def getMeasure(self, device_id, scale, mtype, module_id=None, date_begin=None, date_end=None, limit=None, optimize=False, real_time=False):
- postParams = { "access_token" : self.getAuthToken }
- postParams['device_id'] = device_id
- if module_id : postParams['module_id'] = module_id
- postParams['scale'] = scale
- postParams['type'] = mtype
- if date_begin : postParams['date_begin'] = date_begin
- if date_end : postParams['date_end'] = date_end
- if limit : postParams['limit'] = limit
- postParams['optimize'] = "true" if optimize else "false"
- postParams['real_time'] = "true" if real_time else "false"
- return postRequest(_GETMEASURE_REQ, postParams)
-
- def MinMaxTH(self, station=None, module=None, frame="last24"):
- if not station : station = self.default_station
- s = self.stationByName(station)
- if not s :
- s = self.stationById(station)
- if not s : return None
- if frame == "last24":
- end = time.time()
- start = end - 24*3600 # 24 hours ago
- elif frame == "day":
- start, end = todayStamps()
- if module and module != s['module_name']:
- m = self.moduleByName(module, s['station_name'])
- if not m :
- m = self.moduleById(s['_id'], module)
- if not m : return None
- # retrieve module's data
- resp = self.getMeasure(
- device_id = s['_id'],
- module_id = m['_id'],
- scale = "max",
- mtype = "Temperature,Humidity",
- date_begin = start,
- date_end = end)
- else : # retrieve station's data
- resp = self.getMeasure(
- device_id = s['_id'],
- scale = "max",
- mtype = "Temperature,Humidity",
- date_begin = start,
- date_end = end)
- if resp:
- T = [v[0] for v in resp['body'].values()]
- H = [v[1] for v in resp['body'].values()]
- return min(T), max(T), min(H), max(H)
- else:
- return None
-
-class DeviceList(WeatherStationData):
- """
- This class is now deprecated. Use WeatherStationData directly instead
- """
- warnings.warn("The 'DeviceList' class was renamed 'WeatherStationData'",
- DeprecationWarning )
- pass
\ No newline at end of file
diff --git a/smart_home/__init__.py b/smart_home/__init__.py
deleted file mode 100644
index 612f82164..000000000
--- a/smart_home/__init__.py
+++ /dev/null
@@ -1,82 +0,0 @@
-from sys import version_info
-import json
-
-# Common definitions
-_BASE_URL = "https://api.netatmo.com/"
-
-# HTTP libraries depends upon Python 2 or 3
-if version_info.major == 3 :
- import urllib.parse, urllib.request
-else:
- from urllib import urlencode
- import urllib2
-
-
-class NoDevice( Exception ):
- pass
-
-# Utilities routines
-
-
-def postRequest(url, params=None, timeout=10):
- if version_info.major == 3:
- req = urllib.request.Request(url)
- if params:
- req.add_header("Content-Type","application/x-www-form-urlencoded;charset=utf-8")
- params = urllib.parse.urlencode(params).encode('utf-8')
- try:
- resp = urllib.request.urlopen(req, params, timeout=timeout) if params else urllib.request.urlopen(req, timeout=timeout)
- except urllib.error.URLError:
- return None
- else:
- if params:
- params = urlencode(params)
- headers = {"Content-Type" : "application/x-www-form-urlencoded;charset=utf-8"}
- req = urllib2.Request(url=url, data=params, headers=headers) if params else urllib2.Request(url)
- try:
- resp = urllib2.urlopen(req, timeout=timeout)
- except urllib2.URLError:
- return None
- data = b""
- for buff in iter(lambda: resp.read(65535), b''): data += buff
- # Return values in bytes if not json data to handle properly camera images
- returnedContentType = resp.getheader("Content-Type") if version_info.major == 3 else resp.info()["Content-Type"]
- return json.loads(data.decode("utf-8")) if "application/json" in returnedContentType else data
-
-
-def toTimeString(value):
- return time.strftime("%Y-%m-%d_%H:%M:%S", time.localtime(int(value)))
-
-
-def toEpoch(value):
- return int(time.mktime(time.strptime(value,"%Y-%m-%d_%H:%M:%S")))
-
-
-def todayStamps():
- today = time.strftime("%Y-%m-%d")
- today = int(time.mktime(time.strptime(today,"%Y-%m-%d")))
- return today, today+3600*24
-
-# Global shortcut
-
-
-def getStationMinMaxTH(station=None, module=None):
- authorization = ClientAuth()
- devList = DeviceList(authorization)
- if not station : station = devList.default_station
- if module :
- mname = module
- else :
- mname = devList.stationByName(station)['module_name']
- lastD = devList.lastData(station)
- if mname == "*":
- result = dict()
- for m in lastD.keys():
- if time.time()-lastD[m]['When'] > 3600 : continue
- r = devList.MinMaxTH(module=m)
- result[m] = (r[0], lastD[m]['Temperature'], r[1])
- else:
- if time.time()-lastD[mname]['When'] > 3600 : result = ["-", "-"]
- else : result = [lastD[mname]['Temperature'], lastD[mname]['Humidity']]
- result.extend(devList.MinMaxTH(station, mname))
- return result
diff --git a/src/pyatmo/__init__.py b/src/pyatmo/__init__.py
new file mode 100644
index 000000000..2c0ffb3d3
--- /dev/null
+++ b/src/pyatmo/__init__.py
@@ -0,0 +1,23 @@
+from .auth import ClientAuth, NetatmoOAuth2
+from .camera import CameraData
+from .exceptions import ApiError, InvalidHome, InvalidRoom, NoDevice, NoSchedule
+from .home_coach import HomeCoachData
+from .public_data import PublicData
+from .thermostat import HomeData, HomeStatus
+from .weather_station import WeatherStationData
+
+__all__ = [
+ "CameraData",
+ "ClientAuth",
+ "HomeCoachData",
+ "HomeData",
+ "HomeStatus",
+ "InvalidHome",
+ "InvalidRoom",
+ "ApiError",
+ "NetatmoOAuth2",
+ "NoDevice",
+ "NoSchedule",
+ "PublicData",
+ "WeatherStationData",
+]
diff --git a/src/pyatmo/__main__.py b/src/pyatmo/__main__.py
new file mode 100644
index 000000000..f8c05d41a
--- /dev/null
+++ b/src/pyatmo/__main__.py
@@ -0,0 +1,74 @@
+import os
+import sys
+
+from pyatmo.auth import ALL_SCOPES, ClientAuth
+from pyatmo.camera import CameraData
+from pyatmo.exceptions import NoDevice
+from pyatmo.public_data import PublicData
+from pyatmo.thermostat import HomeData
+from pyatmo.weather_station import WeatherStationData
+
+LON_NE = 6.221652
+LAT_NE = 46.610870
+LON_SW = 6.217828
+LAT_SW = 46.596485
+
+
+def main():
+ try:
+ if (
+ os.environ["CLIENT_ID"]
+ and os.environ["CLIENT_SECRET"]
+ and os.environ["USERNAME"]
+ and os.environ["PASSWORD"]
+ ):
+ client_id = os.environ["CLIENT_ID"]
+ client_secret = os.environ["CLIENT_SECRET"]
+ username = os.environ["USERNAME"]
+ password = os.environ["PASSWORD"]
+ except KeyError:
+ sys.stderr.write(
+ "No credentials passed to pyatmo.py (client_id, client_secret, "
+ "username, password)\n",
+ )
+ sys.exit(1)
+
+ auth = ClientAuth(
+ client_id=client_id,
+ client_secret=client_secret,
+ username=username,
+ password=password,
+ scope=" ".join(ALL_SCOPES),
+ )
+
+ try:
+ WeatherStationData(auth)
+ except NoDevice:
+ if sys.stdout.isatty():
+ print("pyatmo.py : warning, no weather station available for testing")
+
+ try:
+ CameraData(auth)
+ except NoDevice:
+ if sys.stdout.isatty():
+ print("pyatmo.py : warning, no camera available for testing")
+
+ try:
+ HomeData(auth)
+ except NoDevice:
+ if sys.stdout.isatty():
+ print("pyatmo.py : warning, no thermostat available for testing")
+
+ PublicData(auth, LAT_NE, LON_NE, LAT_SW, LON_SW)
+
+ # If we reach this line, all is OK
+
+ # If launched interactively, display OK message
+ if sys.stdout.isatty():
+ print("pyatmo: OK")
+
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/pyatmo/auth.py b/src/pyatmo/auth.py
new file mode 100644
index 000000000..673e7b964
--- /dev/null
+++ b/src/pyatmo/auth.py
@@ -0,0 +1,274 @@
+import logging
+from json import JSONDecodeError
+from time import sleep
+from typing import Any, Callable, Dict, Optional, Tuple, Union
+
+import requests
+from oauthlib.oauth2 import LegacyApplicationClient, TokenExpiredError
+from requests_oauthlib import OAuth2Session
+
+from pyatmo.exceptions import ApiError
+from pyatmo.helpers import _BASE_URL, ERRORS
+
+LOG = logging.getLogger(__name__)
+
+# Common definitions
+AUTH_REQ = _BASE_URL + "oauth2/token"
+AUTH_URL = _BASE_URL + "oauth2/authorize"
+WEBHOOK_URL_ADD = _BASE_URL + "api/addwebhook"
+WEBHOOK_URL_DROP = _BASE_URL + "api/dropwebhook"
+
+
+# Possible scops
+ALL_SCOPES = [
+ "read_station",
+ "read_camera",
+ "access_camera",
+ "write_camera",
+ "read_presence",
+ "access_presence",
+ "write_presence",
+ "read_homecoach",
+ "read_smokedetector",
+ "read_thermostat",
+ "write_thermostat",
+]
+
+
+class NetatmoOAuth2:
+ """
+ Handle authentication with OAuth2
+ """
+
+ def __init__(
+ self,
+ client_id: str = None,
+ client_secret: str = None,
+ redirect_uri: Optional[str] = None,
+ token: Optional[Dict[str, str]] = None,
+ token_updater: Optional[Callable[[str], None]] = None,
+ scope: Optional[str] = "read_station",
+ ) -> None:
+ """Initialize self.
+
+ Keyword Arguments:
+ client_id {str} -- Application client ID delivered by Netatmo on dev.netatmo.com (default: {None})
+ client_secret {str} -- Application client secret delivered by Netatmo on dev.netatmo.com (default: {None})
+ redirect_uri {Optional[str]} -- Redirect URI where to the authorization server will redirect with an authorization code (default: {None})
+ token {Optional[Dict[str, str]]} -- Authorization token (default: {None})
+ token_updater {Optional[Callable[[str], None]]} -- Callback when the token is updated (default: {None})
+ scope {Optional[str]} -- List of scopes (default: {"read_station"})
+ read_station: to retrieve weather station data (Getstationsdata, Getmeasure)
+ read_camera: to retrieve Welcome data (Gethomedata, Getcamerapicture)
+ access_camera: to access the camera, the videos and the live stream
+ write_camera: to set home/away status of persons (Setpersonsaway, Setpersonshome)
+ read_thermostat: to retrieve thermostat data (Getmeasure, Getthermostatsdata)
+ write_thermostat: to set up the thermostat (Syncschedule, Setthermpoint)
+ read_presence: to retrieve Presence data (Gethomedata, Getcamerapicture)
+ access_presence: to access the live stream, any video stored on the SD card and to retrieve Presence's lightflood status
+ read_homecoach: to retrieve Home Coache data (Gethomecoachsdata)
+ read_smokedetector: to retrieve the smoke detector status (Gethomedata)
+ Several values can be used at the same time, ie: 'read_station read_camera'
+ """
+ self.client_id = client_id
+ self.client_secret = client_secret
+ self.redirect_uri = redirect_uri
+ self.token_updater = token_updater
+
+ if token:
+ self.scope = " ".join(token["scope"])
+
+ else:
+ self.scope = " ".join(ALL_SCOPES) if not scope else scope
+
+ self.extra = {"client_id": self.client_id, "client_secret": self.client_secret}
+
+ self._oauth = OAuth2Session(
+ client_id=self.client_id,
+ token=token,
+ token_updater=self.token_updater,
+ redirect_uri=self.redirect_uri,
+ scope=self.scope,
+ )
+
+ def refresh_tokens(self) -> Dict[str, Union[str, int]]:
+ """Refresh and return new tokens."""
+ token = self._oauth.refresh_token(AUTH_REQ, **self.extra)
+
+ if self.token_updater is not None:
+ self.token_updater(token)
+
+ return token
+
+ def post_request(
+ self,
+ url: str,
+ params: Optional[Dict] = None,
+ timeout: int = 5,
+ ) -> Any:
+ """Wrapper for post requests."""
+ resp = None
+ if not params:
+ params = {}
+
+ if "json" in params:
+ json_params: Optional[str] = params.pop("json")
+
+ else:
+ json_params = None
+
+ if "https://" not in url:
+ try:
+ resp = requests.post(url, data=params, timeout=timeout)
+ except requests.exceptions.ChunkedEncodingError:
+ LOG.debug("Encoding error when connecting to '%s'", url)
+ except requests.exceptions.ConnectTimeout:
+ LOG.debug("Connection to %s timed out", url)
+ except requests.exceptions.ConnectionError:
+ LOG.debug("Remote end closed connection without response (%s)", url)
+
+ else:
+
+ def query(url: str, params: Dict, timeout: int, retries: int) -> Any:
+ if retries == 0:
+ LOG.error("Too many retries")
+ return
+
+ try:
+ if json_params:
+ rsp = self._oauth.post(
+ url=url,
+ json=json_params,
+ timeout=timeout,
+ )
+
+ else:
+ rsp = self._oauth.post(url=url, data=params, timeout=timeout)
+
+ return rsp
+
+ except (
+ TokenExpiredError,
+ requests.exceptions.ReadTimeout,
+ requests.exceptions.ConnectionError,
+ ):
+ self._oauth.token = self.refresh_tokens()
+ # Sleep for 1 sec to prevent authentication related
+ # timeouts after a token refresh.
+ sleep(1)
+ return query(url, params, timeout * 2, retries - 1)
+
+ resp = query(url, params, timeout, 3)
+
+ if resp is None:
+ LOG.debug("Resp is None - %s", resp)
+ return None
+
+ if not resp.ok:
+ LOG.debug("The Netatmo API returned %s", resp.status_code)
+ LOG.debug("Netato API error: %s", resp.content)
+ try:
+ raise ApiError(
+ f"{resp.status_code} - "
+ f"{ERRORS.get(resp.status_code, '')} - "
+ f"{resp.json()['error']['message']} "
+ f"({resp.json()['error']['code']}) "
+ f"when accessing '{url}'",
+ )
+
+ except JSONDecodeError as exc:
+ raise ApiError(
+ f"{resp.status_code} - "
+ f"{ERRORS.get(resp.status_code, '')} - "
+ f"when accessing '{url}'",
+ ) from exc
+
+ try:
+ if "application/json" in resp.headers.get("content-type", []):
+ return resp.json()
+
+ if resp.content not in [b"", b"None"]:
+ return resp.content
+
+ except (TypeError, AttributeError):
+ LOG.debug("Invalid response %s", resp)
+
+ return None
+
+ def get_authorization_url(self, state: Optional[str] = None) -> Tuple[str, str]:
+ return self._oauth.authorization_url(AUTH_URL, state)
+
+ def request_token(
+ self,
+ authorization_response: Optional[str] = None,
+ code: Optional[str] = None,
+ ) -> Dict[str, str]:
+ """
+ Generic method for fetching a Netatmo access token.
+ :param authorization_response: Authorization response URL, the callback
+ URL of the request back to you.
+ :param code: Authorization code
+ :return: A token dict
+ """
+ return self._oauth.fetch_token(
+ AUTH_REQ,
+ authorization_response=authorization_response,
+ code=code,
+ client_secret=self.client_secret,
+ include_client_id=True,
+ )
+
+ def addwebhook(self, webhook_url: str) -> None:
+ post_params = {"url": webhook_url}
+ resp = self.post_request(WEBHOOK_URL_ADD, post_params)
+ LOG.debug("addwebhook: %s", resp)
+
+ def dropwebhook(self) -> None:
+ post_params = {"app_types": "app_security"}
+ resp = self.post_request(WEBHOOK_URL_DROP, post_params)
+ LOG.debug("dropwebhook: %s", resp)
+
+
+class ClientAuth(NetatmoOAuth2):
+ """
+ Request authentication and keep access token available through token method. Renew it automatically if necessary
+ Args:
+ clientId (str): Application clientId delivered by Netatmo on dev.netatmo.com
+ clientSecret (str): Application Secret key delivered by Netatmo on dev.netatmo.com
+ username (str)
+ password (str)
+ scope (Optional[str]):
+ read_station: to retrieve weather station data (Getstationsdata, Getmeasure)
+ read_camera: to retrieve Welcome data (Gethomedata, Getcamerapicture)
+ access_camera: to access the camera, the videos and the live stream
+ write_camera: to set home/away status of persons (Setpersonsaway, Setpersonshome)
+ read_thermostat: to retrieve thermostat data (Getmeasure, Getthermostatsdata)
+ write_thermostat: to set up the thermostat (Syncschedule, Setthermpoint)
+ read_presence: to retrieve Presence data (Gethomedata, Getcamerapicture)
+ access_presence: to access the live stream, any video stored on the SD card and to retrieve Presence's lightflood status
+ read_homecoach: to retrieve Home Coache data (Gethomecoachsdata)
+ read_smokedetector: to retrieve the smoke detector status (Gethomedata)
+ Several value can be used at the same time, ie: 'read_station read_camera'
+ """
+
+ def __init__(
+ self,
+ client_id: str,
+ client_secret: str,
+ username: str,
+ password: str,
+ scope="read_station",
+ ):
+ super().__init__(client_id=client_id, client_secret=client_secret, scope=scope)
+
+ self._oauth = OAuth2Session(
+ client=LegacyApplicationClient(client_id=self.client_id),
+ )
+ self._oauth.fetch_token(
+ token_url=AUTH_REQ,
+ username=username,
+ password=password,
+ client_id=self.client_id,
+ client_secret=self.client_secret,
+ scope=self.scope,
+ )
diff --git a/src/pyatmo/camera.py b/src/pyatmo/camera.py
new file mode 100644
index 000000000..5d61a41bf
--- /dev/null
+++ b/src/pyatmo/camera.py
@@ -0,0 +1,578 @@
+import imghdr
+import time
+from collections import defaultdict
+from typing import Any, Dict, List, Optional, Tuple
+
+from requests.exceptions import ReadTimeout
+
+from .auth import NetatmoOAuth2
+from .exceptions import ApiError, NoDevice
+from .helpers import _BASE_URL, LOG
+
+_GETHOMEDATA_REQ = _BASE_URL + "api/gethomedata"
+_GETCAMERAPICTURE_REQ = _BASE_URL + "api/getcamerapicture"
+_GETEVENTSUNTIL_REQ = _BASE_URL + "api/geteventsuntil"
+_SETPERSONSAWAY_REQ = _BASE_URL + "api/setpersonsaway"
+_SETPERSONSHOME_REQ = _BASE_URL + "api/setpersonshome"
+_SETSTATE_REQ = _BASE_URL + "api/setstate"
+
+
+class CameraData:
+ """
+ Class of Netatmo camera informations
+ (Homes, cameras, smoke detectors, modules, events, persons)
+ """
+
+ def __init__(self, auth: NetatmoOAuth2, size: int = 30) -> None:
+ """Initialize self.
+
+ Arguments:
+ auth {NetatmoOAuth2} -- Authentication information with a valid access token
+
+ Keyword Arguments:
+ size {int} -- Number of events to retrieve. (default: {30})
+
+ Raises:
+ NoDevice: No devices found.
+ """
+ self.auth = auth
+
+ post_params = {"size": size}
+ resp = self.auth.post_request(url=_GETHOMEDATA_REQ, params=post_params)
+ if resp is None or "body" not in resp:
+ raise NoDevice("No device data returned by Netatmo server")
+
+ self.raw_data = resp["body"].get("homes")
+ if not self.raw_data:
+ raise NoDevice("No device data available")
+
+ self.homes: Dict = {d["id"]: d for d in self.raw_data}
+
+ self.persons: Dict = {}
+ self.events: Dict = defaultdict(dict)
+ self.outdoor_events: Dict = defaultdict(dict)
+ self.cameras: Dict = defaultdict(dict)
+ self.smokedetectors: Dict = defaultdict(dict)
+ self.modules: Dict = {}
+ self.last_event: Dict = {}
+ self.outdoor_last_event: Dict = {}
+ self.types: Dict = defaultdict(dict)
+
+ for item in self.raw_data:
+ home_id: str = item.get("id", "")
+
+ if not item.get("name"):
+ self.homes[home_id]["name"] = "Unknown"
+
+ for person in item.get("persons", []):
+ self.persons[person["id"]] = person
+
+ for event in item.get("events", []):
+ self._store_events(event)
+
+ for camera in item.get("cameras", []):
+ self.cameras[home_id][camera["id"]] = camera
+ self.types[home_id][camera["type"]] = camera
+
+ self.cameras[home_id][camera["id"]]["home_id"] = home_id
+ if camera["type"] == "NACamera":
+ for module in camera.get("modules", []):
+ self.modules[module["id"]] = module
+ self.modules[module["id"]]["cam_id"] = camera["id"]
+
+ for smoke in item.get("smokedetectors", []):
+ self.smokedetectors[home_id][smoke["id"]] = smoke
+ self.types[home_id][smoke["type"]] = smoke
+
+ self._store_last_event()
+
+ for home_id in self.homes:
+ for camera_id in self.cameras[home_id]:
+ self.update_camera_urls(camera_id)
+
+ def _store_events(self, event):
+ if event["type"] == "outdoor":
+ self.outdoor_events[event["camera_id"]][event["time"]] = event
+
+ else:
+ self.events[event["camera_id"]][event["time"]] = event
+
+ def _store_last_event(self):
+ for camera in self.events:
+ self.last_event[camera] = self.events[camera][
+ sorted(self.events[camera])[-1]
+ ]
+
+ for camera in self.outdoor_events:
+ self.outdoor_last_event[camera] = self.outdoor_events[camera][
+ sorted(self.outdoor_events[camera])[-1]
+ ]
+
+ def get_camera(self, camera_id: str) -> Dict[str, str]:
+ """Get camera data."""
+ for home_id in self.cameras:
+ if camera_id in self.cameras[home_id]:
+ return self.cameras[home_id][camera_id]
+
+ return {}
+
+ def get_module(self, module_id: str) -> Optional[dict]:
+ """Get module data."""
+ return None if module_id not in self.modules else self.modules[module_id]
+
+ def get_smokedetector(self, smoke_id: str) -> Optional[dict]:
+ """Get smoke detector."""
+ for home_id in self.smokedetectors:
+ if smoke_id in self.smokedetectors[home_id]:
+ return self.smokedetectors[home_id][smoke_id]
+
+ return None
+
+ def camera_urls(self, camera_id: str) -> Tuple[Optional[str], Optional[str]]:
+ """
+ Return the vpn_url and the local_url (if available) of a given camera
+ in order to access its live feed.
+ """
+ camera_data = self.get_camera(camera_id)
+ return camera_data.get("vpn_url", None), camera_data.get("local_url", None)
+
+ def update_camera_urls(self, camera_id: str) -> None:
+ """Update and validate the camera urls."""
+ camera_data = self.get_camera(camera_id)
+ home_id = camera_data["home_id"]
+
+ if not camera_data or camera_data.get("status") == "disconnected":
+ self.cameras[home_id][camera_id]["local_url"] = None
+ self.cameras[home_id][camera_id]["vpn_url"] = None
+ return
+
+ vpn_url = camera_data.get("vpn_url")
+ if vpn_url and camera_data.get("is_local"):
+
+ def check_url(url: str) -> Optional[str]:
+ try:
+ resp = self.auth.post_request(url=f"{url}/command/ping")
+ except ReadTimeout:
+ LOG.debug("Timeout validation of camera url %s", url)
+ return None
+ except ApiError:
+ LOG.debug("Api error for camera url %s", url)
+ return None
+ else:
+ return resp.get("local_url")
+
+ temp_local_url = check_url(vpn_url)
+ if temp_local_url:
+ self.cameras[home_id][camera_id]["local_url"] = check_url(
+ temp_local_url,
+ )
+
+ def get_light_state(self, camera_id: str) -> Optional[str]:
+ """Return the current mode of the floodlight of a presence camera."""
+ camera_data = self.get_camera(camera_id)
+ if camera_data is None:
+ raise ValueError("Invalid Camera ID")
+
+ return camera_data.get("light_mode_status")
+
+ def persons_at_home(self, home_id: str = None) -> List:
+ """Return a list of known persons who are currently at home."""
+ home_data = self.homes.get(home_id, {})
+ return [
+ person["pseudo"]
+ for person in home_data.get("persons")
+ if "pseudo" in person and not person["out_of_sight"]
+ ]
+
+ def set_persons_home(self, person_ids: List[str], home_id: str):
+ """Mark persons as home.
+
+ Arguments:
+ person_ids {list} -- IDs of persons
+ home_id {str} -- ID of a home
+ """
+ post_params = {
+ "home_id": home_id,
+ "person_ids[]": person_ids,
+ }
+ return self.auth.post_request(url=_SETPERSONSHOME_REQ, params=post_params)
+
+ def set_persons_away(self, person_id: str, home_id: str):
+ """Mark a person as away or set the whole home to being empty.
+
+ Arguments:
+ person_id {str} -- ID of a person
+ home_id {str} -- ID of a home
+ """
+ post_params = {
+ "home_id": home_id,
+ "person_id": person_id,
+ }
+ return self.auth.post_request(url=_SETPERSONSAWAY_REQ, params=post_params)
+
+ def get_person_id(self, name: str) -> Optional[str]:
+ """Retrieve the ID of a person.
+
+ Arguments:
+ name {str} -- Name of a person
+
+ Returns:
+ str -- ID of a person
+ """
+ for pid, data in self.persons.items():
+ if name == data.get("pseudo"):
+ return pid
+
+ return None
+
+ def get_camera_picture(
+ self,
+ image_id: str,
+ key: str,
+ ) -> Tuple[bytes, Optional[str]]:
+ """Download a specific image (of an event or user face) from the camera."""
+ post_params = {
+ "image_id": image_id,
+ "key": key,
+ }
+ resp = self.auth.post_request(url=_GETCAMERAPICTURE_REQ, params=post_params)
+ image_type = imghdr.what("NONE.FILE", resp)
+ return resp, image_type
+
+ def get_profile_image(self, name: str) -> Tuple[Optional[bytes], Optional[str]]:
+ """Retrieve the face of a given person."""
+ for person in self.persons:
+ if name == self.persons[person].get("pseudo"):
+ image_id = self.persons[person]["face"]["id"]
+ key = self.persons[person]["face"]["key"]
+ return self.get_camera_picture(image_id, key)
+
+ return None, None
+
+ def update_events(
+ self,
+ home_id: str,
+ event_id: str = None,
+ device_type: str = None,
+ ) -> None:
+ """Update the list of events."""
+ # Either event_id or device_type must be given
+ if not (event_id or device_type):
+ raise ApiError
+
+ def get_event_id(data: Dict):
+ events = {e["time"]: e for e in data.values()}
+ return min(events.items())[1].get("id")
+
+ if not event_id:
+ # If no event is provided we need to retrieve the oldest of
+ # the last event seen by each camera
+ if device_type == "NACamera":
+ # for the Welcome camera
+ event_id = get_event_id(self.last_event)
+
+ elif device_type in {"NOC", "NSD"}:
+ # for the Presence camera and for the smoke detector
+ event_id = get_event_id(self.outdoor_last_event)
+
+ post_params = {
+ "home_id": home_id,
+ "event_id": event_id,
+ }
+
+ event_list: List = []
+ resp: Optional[Dict[str, Any]] = None
+ try:
+ resp = self.auth.post_request(url=_GETEVENTSUNTIL_REQ, params=post_params)
+ if resp is not None:
+ event_list = resp["body"]["events_list"]
+ except ApiError:
+ pass
+ except KeyError:
+ if resp is not None:
+ LOG.debug("event_list response: %s", resp)
+ LOG.debug("event_list body: %s", dict(resp)["body"])
+ else:
+ LOG.debug("No resp received")
+
+ for event in event_list:
+ self._store_events(event)
+
+ self._store_last_event()
+
+ def person_seen_by_camera(
+ self,
+ name: str,
+ camera_id: str,
+ exclude: int = 0,
+ ) -> bool:
+ """Evaluate if a specific person has been seen."""
+ # Check in the last event is someone known has been seen
+ def _person_in_event(curr_event, person_name):
+ if curr_event["type"] == "person":
+ person_id = curr_event["person_id"]
+
+ if self.persons[person_id].get("pseudo") == person_name:
+ return True
+ return None
+
+ if exclude:
+ limit = time.time() - exclude
+ array_time_event = sorted(self.events[camera_id], reverse=True)
+
+ for time_ev in array_time_event:
+ if time_ev < limit:
+ return False
+
+ current_event = self.events[camera_id][time_ev]
+ if _person_in_event(current_event, name) is True:
+ return True
+
+ return False
+
+ current_event = self.last_event[camera_id]
+ if _person_in_event(current_event, name) is True:
+ return True
+
+ return False
+
+ def _known_persons(self) -> Dict[str, Dict]:
+ """Return all known persons."""
+ return {pid: p for pid, p in self.persons.items() if "pseudo" in p}
+
+ def known_persons(self) -> Dict[str, str]:
+ """Return a dictionary of known person names."""
+ return {pid: p["pseudo"] for pid, p in self._known_persons().items()}
+
+ def known_persons_names(self) -> List[str]:
+ """Return a list of known person names."""
+ return [person["pseudo"] for person in self._known_persons().values()]
+
+ def someone_known_seen(self, camera_id: str, exclude: int = 0) -> bool:
+ """Evaluate if someone known has been seen."""
+ if camera_id not in self.events:
+ raise NoDevice
+
+ if exclude:
+ limit = time.time() - exclude
+ array_time_event = sorted(self.events[camera_id], reverse=True)
+
+ for time_ev in array_time_event:
+ if time_ev < limit:
+ return False
+
+ curr_event = self.events[camera_id][time_ev]
+ if curr_event["type"] == "person":
+ if curr_event["person_id"] in self._known_persons():
+ return True
+
+ # Check in the last event if someone known has been seen
+ else:
+ curr_event = self.last_event[camera_id]
+ if curr_event["type"] == "person":
+ if curr_event["person_id"] in self._known_persons():
+ return True
+
+ return False
+
+ def someone_unknown_seen(self, camera_id: str, exclude: int = 0) -> bool:
+ """Evaluate if someone known has been seen."""
+ if camera_id not in self.events:
+ raise NoDevice
+
+ if exclude:
+ limit = time.time() - exclude
+ array_time_event = sorted(self.events[camera_id], reverse=True)
+
+ for time_ev in array_time_event:
+ if time_ev < limit:
+ return False
+
+ curr_event = self.events[camera_id][time_ev]
+ if curr_event["type"] == "person":
+ if curr_event["person_id"] not in self._known_persons():
+ return True
+
+ # Check in the last event is noone known has been seen
+ else:
+ curr_event = self.last_event[camera_id]
+ if curr_event["type"] == "person":
+ if curr_event["person_id"] not in self._known_persons():
+ return True
+
+ return False
+
+ def motion_detected(self, camera_id: str, exclude: int = 0) -> bool:
+ """Evaluate if movement has been detected."""
+ if camera_id not in self.events:
+ raise NoDevice
+
+ if exclude:
+ limit = time.time() - exclude
+ array_time_event = sorted(self.events[camera_id], reverse=True)
+
+ for time_ev in array_time_event:
+ if time_ev < limit:
+ return False
+
+ if self.events[camera_id][time_ev]["type"] == "movement":
+ return True
+
+ elif self.last_event[camera_id]["type"] == "movement":
+ return True
+
+ return False
+
+ def outdoor_motion_detected(self, camera_id: str, offset: int = 0) -> bool:
+ """Evaluate if outdoor movement has been detected."""
+ if camera_id not in self.last_event:
+ return False
+
+ last_event = self.last_event[camera_id]
+ return (
+ last_event["type"] == "movement"
+ and last_event["video_status"] == "recording"
+ and last_event["time"] + offset > int(time.time())
+ )
+
+ def _object_detected(self, object_name: str, camera_id: str, offset: int) -> bool:
+ """Evaluate if a human has been detected."""
+ if self.outdoor_last_event[camera_id]["video_status"] == "recording":
+ for event in self.outdoor_last_event[camera_id]["event_list"]:
+ if event["type"] == object_name and (
+ event["time"] + offset > int(time.time())
+ ):
+ return True
+
+ return False
+
+ def human_detected(self, camera_id: str, offset: int = 0) -> bool:
+ """Evaluate if a human has been detected."""
+ return self._object_detected("human", camera_id, offset)
+
+ def animal_detected(self, camera_id: str, offset: int = 0) -> bool:
+ """Evaluate if an animal has been detected."""
+ return self._object_detected("animal", camera_id, offset)
+
+ def car_detected(self, camera_id: str, offset: int = 0) -> bool:
+ """Evaluate if a car has been detected."""
+ return self._object_detected("vehicle", camera_id, offset)
+
+ def module_motion_detected(
+ self,
+ module_id: str,
+ camera_id: str,
+ exclude: int = 0,
+ ) -> bool:
+ """Evaluate if movement has been detected."""
+ if exclude:
+ limit = time.time() - exclude
+ array_time_event = sorted(self.events.get(camera_id, []), reverse=True)
+
+ for time_ev in array_time_event:
+ if time_ev < limit:
+ return False
+
+ curr_event = self.events[camera_id][time_ev]
+ if (
+ curr_event["type"] in {"tag_big_move", "tag_small_move"}
+ and curr_event["module_id"] == module_id
+ ):
+ return True
+
+ else:
+ if camera_id not in self.last_event:
+ return False
+
+ curr_event = self.last_event[camera_id]
+ if (
+ curr_event["type"] in {"tag_big_move", "tag_small_move"}
+ and curr_event["module_id"] == module_id
+ ):
+ return True
+
+ return False
+
+ def module_opened(self, module_id: str, camera_id: str, exclude: int = 0) -> bool:
+ """Evaluate if module status is open."""
+ if exclude:
+ limit = time.time() - exclude
+ array_time_event = sorted(self.events.get(camera_id, []), reverse=True)
+
+ for time_ev in array_time_event:
+ if time_ev < limit:
+ return False
+
+ curr_event = self.events[camera_id][time_ev]
+ if (
+ curr_event["type"] == "tag_open"
+ and curr_event["module_id"] == module_id
+ ):
+ return True
+
+ else:
+ if camera_id not in self.last_event:
+ return False
+
+ curr_event = self.last_event[camera_id]
+ if (
+ curr_event["type"] == "tag_open"
+ and curr_event["module_id"] == module_id
+ ):
+ return True
+
+ return False
+
+ def set_state(
+ self,
+ camera_id: str,
+ home_id: str = None,
+ floodlight: str = None,
+ monitoring: str = None,
+ ) -> bool:
+ """Turn camera (light) on/off.
+
+ Arguments:
+ camera_id {str} -- ID of a camera
+ home_id {str} -- ID of a home
+ floodlight {str} -- Mode for floodlight (on/off/auto)
+ monitoring {str} -- Mode for monitoring (on/off)
+
+ Returns:
+ Boolean -- Success of the request
+ """
+ if home_id is None:
+ home_id = self.get_camera(camera_id)["home_id"]
+
+ module = {"id": camera_id}
+
+ if floodlight:
+ param, val = "floodlight", floodlight.lower()
+ if val not in {"on", "off", "auto"}:
+ LOG.error("Invalid value for floodlight")
+ else:
+ module[param] = val
+
+ if monitoring:
+ param, val = "monitoring", monitoring.lower()
+ if val not in {"on", "off"}:
+ LOG.error("Invalid value for monitoring")
+ else:
+ module[param] = val
+
+ post_params = {
+ "json": {"home": {"id": home_id, "modules": [module]}},
+ }
+
+ try:
+ resp = self.auth.post_request(url=_SETSTATE_REQ, params=post_params)
+ except ApiError as err_msg:
+ LOG.error("%s", err_msg)
+ return False
+
+ if "error" in resp:
+ LOG.debug("%s", resp)
+ return False
+
+ LOG.debug("%s", resp)
+ return True
diff --git a/src/pyatmo/exceptions.py b/src/pyatmo/exceptions.py
new file mode 100644
index 000000000..1e694e98b
--- /dev/null
+++ b/src/pyatmo/exceptions.py
@@ -0,0 +1,18 @@
+class NoSchedule(Exception):
+ pass
+
+
+class InvalidHome(Exception):
+ pass
+
+
+class InvalidRoom(Exception):
+ pass
+
+
+class NoDevice(Exception):
+ pass
+
+
+class ApiError(Exception):
+ pass
diff --git a/src/pyatmo/helpers.py b/src/pyatmo/helpers.py
new file mode 100644
index 000000000..2fcb6b5f7
--- /dev/null
+++ b/src/pyatmo/helpers.py
@@ -0,0 +1,42 @@
+import logging
+import time
+from calendar import timegm
+from datetime import datetime
+from typing import Dict, Tuple
+
+LOG: logging.Logger = logging.getLogger(__name__)
+
+_BASE_URL: str = "https://api.netatmo.com/"
+
+ERRORS: Dict[int, str] = {
+ 400: "Bad request",
+ 401: "Unauthorized",
+ 403: "Forbidden",
+ 404: "Not found",
+ 406: "Not Acceptable",
+ 500: "Internal Server Error",
+ 502: "Bad Gateway",
+ 503: "Service Unavailable",
+}
+
+
+def to_time_string(value: str) -> str:
+ return datetime.utcfromtimestamp(int(value)).isoformat(sep="_")
+
+
+def to_epoch(value: str) -> int:
+ return timegm(time.strptime(value + "GMT", "%Y-%m-%d_%H:%M:%S%Z"))
+
+
+def today_stamps() -> Tuple[int, int]:
+ today: int = timegm(time.strptime(time.strftime("%Y-%m-%d") + "GMT", "%Y-%m-%d%Z"))
+ return today, today + 3600 * 24
+
+
+def fix_id(raw_data: Dict) -> Dict:
+ if raw_data:
+ for station in raw_data:
+ station["_id"] = station["_id"].replace(" ", "")
+ for module in station.get("modules", {}):
+ module["_id"] = module["_id"].replace(" ", "")
+ return raw_data
diff --git a/src/pyatmo/home_coach.py b/src/pyatmo/home_coach.py
new file mode 100644
index 000000000..181d811fd
--- /dev/null
+++ b/src/pyatmo/home_coach.py
@@ -0,0 +1,22 @@
+from .auth import NetatmoOAuth2
+from .helpers import _BASE_URL
+from .weather_station import WeatherStationData
+
+_GETHOMECOACHDATA_REQ = _BASE_URL + "api/gethomecoachsdata"
+
+
+class HomeCoachData(WeatherStationData):
+ """
+ Class of Netatmo Home Couch devices (stations and modules)
+ """
+
+ def __init__(self, auth: NetatmoOAuth2) -> None:
+ """Initialize self.
+
+ Arguments:
+ auth {NetatmoOAuth2} -- Authentication information with a valid access token
+
+ Raises:
+ NoDevice: No devices found.
+ """
+ super().__init__(auth, url_req=_GETHOMECOACHDATA_REQ)
diff --git a/src/pyatmo/public_data.py b/src/pyatmo/public_data.py
new file mode 100644
index 000000000..9c0aa9e70
--- /dev/null
+++ b/src/pyatmo/public_data.py
@@ -0,0 +1,175 @@
+from typing import Any, Dict
+
+from .auth import NetatmoOAuth2
+from .exceptions import NoDevice
+from .helpers import _BASE_URL, to_time_string
+
+_GETPUBLIC_DATA = _BASE_URL + "api/getpublicdata"
+
+_STATION_TEMPERATURE_TYPE = "temperature"
+_STATION_PRESSURE_TYPE = "pressure"
+_STATION_HUMIDITY_TYPE = "humidity"
+
+_ACCESSORY_RAIN_LIVE_TYPE = "rain_live"
+_ACCESSORY_RAIN_60MIN_TYPE = "rain_60min"
+_ACCESSORY_RAIN_24H_TYPE = "rain_24h"
+_ACCESSORY_RAIN_TIME_TYPE = "rain_timeutc"
+_ACCESSORY_WIND_STRENGTH_TYPE = "wind_strength"
+_ACCESSORY_WIND_ANGLE_TYPE = "wind_angle"
+_ACCESSORY_WIND_TIME_TYPE = "wind_timeutc"
+_ACCESSORY_GUST_STRENGTH_TYPE = "gust_strength"
+_ACCESSORY_GUST_ANGLE_TYPE = "gust_angle"
+
+
+class PublicData:
+ """
+ Class of Netatmo public weather data.
+ """
+
+ def __init__(
+ self,
+ auth: NetatmoOAuth2,
+ lat_ne: str,
+ lon_ne: str,
+ lat_sw: str,
+ lon_sw: str,
+ required_data_type: str = None,
+ filtering: bool = False,
+ ) -> None:
+ """Initialize self.
+
+ Arguments:
+ auth {NetatmoOAuth2} -- Authentication information with a valid access token
+ LAT_NE {str} -- Latitude of the north east corner of the requested area. (-85 <= LAT_NE <= 85 and LAT_NE > LAT_SW)
+ LON_NE {str} -- Longitude of the north east corner of the requested area. (-180 <= LON_NE <= 180 and LON_NE > LON_SW)
+ LAT_SW {str} -- latitude of the south west corner of the requested area. (-85 <= LAT_SW <= 85)
+ LON_SW {str} -- Longitude of the south west corner of the requested area. (-180 <= LON_SW <= 180)
+
+ Keyword Arguments:
+ required_data_type {str} -- comma-separated list from above _STATION or _ACCESSORY values (default: {None})
+
+ Raises:
+ NoDevice: No devices found.
+ """
+ self.auth = auth
+ post_params: Dict = {
+ "lat_ne": lat_ne,
+ "lon_ne": lon_ne,
+ "lat_sw": lat_sw,
+ "lon_sw": lon_sw,
+ "filter": filtering,
+ }
+
+ if required_data_type:
+ post_params["required_data"] = required_data_type
+
+ resp = self.auth.post_request(url=_GETPUBLIC_DATA, params=post_params)
+ try:
+ self.raw_data = resp["body"]
+ except (KeyError, TypeError) as exc:
+ raise NoDevice("No public weather data returned by Netatmo server") from exc
+
+ self.status = resp["status"]
+ self.time_exec = to_time_string(resp["time_exec"])
+ self.time_server = to_time_string(resp["time_server"])
+
+ def stations_in_area(self) -> int:
+ return len(self.raw_data)
+
+ def get_latest_rain(self) -> Dict:
+ return self.get_accessory_data(_ACCESSORY_RAIN_LIVE_TYPE)
+
+ def get_average_rain(self) -> float:
+ return average(self.get_latest_rain())
+
+ def get_60_min_rain(self) -> Dict:
+ return self.get_accessory_data(_ACCESSORY_RAIN_60MIN_TYPE)
+
+ def get_average_60_min_rain(self) -> float:
+ return average(self.get_60_min_rain())
+
+ def get_24_h_rain(self) -> Dict:
+ return self.get_accessory_data(_ACCESSORY_RAIN_24H_TYPE)
+
+ def get_average_24_h_rain(self) -> float:
+ return average(self.get_24_h_rain())
+
+ def get_latest_pressures(self) -> Dict:
+ return self.get_latest_station_measures(_STATION_PRESSURE_TYPE)
+
+ def get_average_pressure(self) -> float:
+ return average(self.get_latest_pressures())
+
+ def get_latest_temperatures(self) -> Dict:
+ return self.get_latest_station_measures(_STATION_TEMPERATURE_TYPE)
+
+ def get_average_temperature(self) -> float:
+ return average(self.get_latest_temperatures())
+
+ def get_latest_humidities(self) -> Dict:
+ return self.get_latest_station_measures(_STATION_HUMIDITY_TYPE)
+
+ def get_average_humidity(self) -> float:
+ return average(self.get_latest_humidities())
+
+ def get_latest_wind_strengths(self) -> Dict:
+ return self.get_accessory_data(_ACCESSORY_WIND_STRENGTH_TYPE)
+
+ def get_average_wind_strength(self) -> float:
+ return average(self.get_latest_wind_strengths())
+
+ def get_latest_wind_angles(self) -> Dict:
+ return self.get_accessory_data(_ACCESSORY_WIND_ANGLE_TYPE)
+
+ def get_latest_gust_strengths(self) -> Dict:
+ return self.get_accessory_data(_ACCESSORY_GUST_STRENGTH_TYPE)
+
+ def get_average_gust_strength(self) -> float:
+ return average(self.get_latest_gust_strengths())
+
+ def get_latest_gust_angles(self):
+ return self.get_accessory_data(_ACCESSORY_GUST_ANGLE_TYPE)
+
+ def get_locations(self) -> Dict:
+ locations: Dict = {}
+ for station in self.raw_data:
+ locations[station["_id"]] = station["place"]["location"]
+
+ return locations
+
+ def get_time_for_rain_measures(self) -> Dict:
+ return self.get_accessory_data(_ACCESSORY_RAIN_TIME_TYPE)
+
+ def get_time_for_wind_measures(self) -> Dict:
+ return self.get_accessory_data(_ACCESSORY_WIND_TIME_TYPE)
+
+ def get_latest_station_measures(self, data_type) -> Dict:
+ measures: Dict = {}
+ for station in self.raw_data:
+ for module in station["measures"].values():
+ if (
+ "type" in module
+ and data_type in module["type"]
+ and "res" in module
+ and module["res"]
+ ):
+ measure_index = module["type"].index(data_type)
+ latest_timestamp = sorted(module["res"], reverse=True)[0]
+ measures[station["_id"]] = module["res"][latest_timestamp][
+ measure_index
+ ]
+
+ return measures
+
+ def get_accessory_data(self, data_type: str) -> Dict[str, Any]:
+ data: Dict = {}
+ for station in self.raw_data:
+ for module in station["measures"].values():
+ if data_type in module:
+ data[station["_id"]] = module[data_type]
+
+ return data
+
+
+def average(data: dict) -> float:
+ return sum(data.values()) / len(data) if data else 0.0
diff --git a/src/pyatmo/thermostat.py b/src/pyatmo/thermostat.py
new file mode 100644
index 000000000..0e5180c00
--- /dev/null
+++ b/src/pyatmo/thermostat.py
@@ -0,0 +1,240 @@
+import logging
+from collections import defaultdict
+from typing import Any, Dict, Optional
+
+from .auth import NetatmoOAuth2
+from .exceptions import InvalidRoom, NoDevice, NoSchedule
+from .helpers import _BASE_URL
+
+LOG = logging.getLogger(__name__)
+
+_GETHOMESDATA_REQ = _BASE_URL + "api/homesdata"
+_GETHOMESTATUS_REQ = _BASE_URL + "api/homestatus"
+_SETTHERMMODE_REQ = _BASE_URL + "api/setthermmode"
+_SETROOMTHERMPOINT_REQ = _BASE_URL + "api/setroomthermpoint"
+_GETROOMMEASURE_REQ = _BASE_URL + "api/getroommeasure"
+_SWITCHHOMESCHEDULE_REQ = _BASE_URL + "api/switchhomeschedule"
+
+
+class HomeData:
+ """
+ Class of Netatmo energy devices (relays, thermostat modules and valves)
+ """
+
+ def __init__(self, auth: NetatmoOAuth2) -> None:
+ """Initialize self.
+
+ Arguments:
+ auth {NetatmoOAuth2} -- Authentication information with a valid access token
+
+ Raises:
+ NoDevice: No devices found.
+ """
+ self.auth = auth
+ resp = self.auth.post_request(url=_GETHOMESDATA_REQ)
+ if resp is None or "body" not in resp:
+ raise NoDevice("No thermostat data returned by Netatmo server")
+
+ self.raw_data = resp["body"].get("homes")
+ if not self.raw_data:
+ raise NoDevice("No thermostat data available")
+
+ self.homes: Dict = {d["id"]: d for d in self.raw_data}
+
+ self.modules: Dict = defaultdict(dict)
+ self.rooms: Dict = defaultdict(dict)
+ self.schedules: Dict = defaultdict(dict)
+ self.zones: Dict = defaultdict(dict)
+ self.setpoint_duration: Dict = defaultdict(dict)
+
+ for item in self.raw_data:
+ home_id = item.get("id")
+ home_name = item.get("name")
+
+ if not home_name:
+ home_name = "Unknown"
+ self.homes[home_id]["name"] = home_name
+
+ if "modules" not in item:
+ continue
+
+ for module in item["modules"]:
+ self.modules[home_id][module["id"]] = module
+
+ self.setpoint_duration[home_id] = item.get(
+ "therm_setpoint_default_duration",
+ )
+
+ for room in item.get("rooms", []):
+ self.rooms[home_id][room["id"]] = room
+
+ for schedule in item.get("therm_schedules", []):
+ schedule_id = schedule["id"]
+ self.schedules[home_id][schedule_id] = schedule
+
+ if schedule_id not in self.zones[home_id]:
+ self.zones[home_id][schedule_id] = {}
+
+ for zone in schedule["zones"]:
+ self.zones[home_id][schedule_id][zone["id"]] = zone
+
+ def _get_selected_schedule(self, home_id: str) -> Dict:
+ """Get the selected schedule for a given home ID."""
+ for value in self.schedules.get(home_id, {}).values():
+ if "selected" in value.keys():
+ return value
+
+ return {}
+
+ def switch_home_schedule(self, home_id: str, schedule_id: str) -> Any:
+ """Switch the schedule for a give home ID."""
+ schedules = {
+ self.schedules[home_id][s]["name"]: self.schedules[home_id][s]["id"]
+ for s in self.schedules.get(home_id, {})
+ }
+ if schedule_id not in list(schedules.values()):
+ raise NoSchedule("%s is not a valid schedule id" % schedule_id)
+
+ post_params = {
+ "home_id": home_id,
+ "schedule_id": schedule_id,
+ }
+ resp = self.auth.post_request(url=_SWITCHHOMESCHEDULE_REQ, params=post_params)
+ LOG.debug("Response: %s", resp)
+
+ def get_hg_temp(self, home_id: str) -> Optional[float]:
+ """Return frost guard temperature value."""
+ return self._get_selected_schedule(home_id).get("hg_temp")
+
+ def get_away_temp(self, home_id: str) -> Optional[float]:
+ """Return the configured away temperature value."""
+ return self._get_selected_schedule(home_id).get("away_temp")
+
+ def get_thermostat_type(self, home_id: str, room_id: str) -> Optional[str]:
+ """Return the thermostat type of the room."""
+ for module in self.modules.get(home_id, {}).values():
+ if module.get("room_id") == room_id:
+ return module.get("type")
+
+ return None
+
+
+class HomeStatus:
+ def __init__(self, auth: NetatmoOAuth2, home_id: str):
+ self.auth = auth
+
+ self.home_id = home_id
+ post_params = {"home_id": self.home_id}
+
+ resp = self.auth.post_request(url=_GETHOMESTATUS_REQ, params=post_params)
+ if (
+ "errors" in resp
+ or "body" not in resp
+ or "home" not in resp["body"]
+ or ("errors" in resp["body"] and "modules" not in resp["body"]["home"])
+ ):
+ LOG.error("Errors in response: %s", resp)
+ raise NoDevice("No device found, errors in response")
+
+ self.raw_data = resp["body"]["home"]
+ self.rooms: Dict = {}
+ self.thermostats: Dict = defaultdict(dict)
+ self.valves: Dict = defaultdict(dict)
+ self.relays: Dict = defaultdict(dict)
+
+ for room in self.raw_data.get("rooms", []):
+ self.rooms[room["id"]] = room
+
+ for module in self.raw_data.get("modules", []):
+ if module["type"] == "NATherm1":
+ self.thermostats[module["id"]] = module
+
+ elif module["type"] == "NRV":
+ self.valves[module["id"]] = module
+
+ elif module["type"] == "NAPlug":
+ self.relays[module["id"]] = module
+
+ def get_room(self, room_id: str) -> Dict:
+ for key, value in self.rooms.items():
+ if value["id"] == room_id:
+ return self.rooms[key]
+
+ raise InvalidRoom("No room with ID %s" % room_id)
+
+ def get_thermostat(self, room_id: str) -> Dict:
+ """Return thermostat data for a given room id."""
+ for key, value in self.thermostats.items():
+ if value["id"] == room_id:
+ return self.thermostats[key]
+
+ raise InvalidRoom("No room with ID %s" % room_id)
+
+ def get_relay(self, room_id: str) -> Dict:
+ for key, value in self.relays.items():
+ if value["id"] == room_id:
+ return self.relays[key]
+
+ raise InvalidRoom("No room with ID %s" % room_id)
+
+ def get_valve(self, room_id: str) -> Dict:
+ for key, value in self.valves.items():
+ if value["id"] == room_id:
+ return self.valves[key]
+
+ raise InvalidRoom("No room with ID %s" % room_id)
+
+ def set_point(self, room_id: str) -> Optional[float]:
+ """Return the setpoint of a given room."""
+ return self.get_room(room_id).get("therm_setpoint_temperature")
+
+ def set_point_mode(self, room_id: str) -> Optional[str]:
+ """Return the setpointmode of a given room."""
+ return self.get_room(room_id).get("therm_setpoint_mode")
+
+ def measured_temperature(self, room_id: str) -> Optional[float]:
+ """Return the measured temperature of a given room."""
+ return self.get_room(room_id).get("therm_measured_temperature")
+
+ def boiler_status(self, module_id: str) -> Optional[bool]:
+ return self.get_thermostat(module_id).get("boiler_status")
+
+ def set_thermmode(
+ self,
+ mode: str,
+ end_time: int = None,
+ schedule_id: str = None,
+ ) -> Optional[str]:
+ post_params = {
+ "home_id": self.home_id,
+ "mode": mode,
+ }
+ if end_time is not None and mode in ("hg", "away"):
+ post_params["endtime"] = str(end_time)
+
+ if schedule_id is not None and mode == "schedule":
+ post_params["schedule_id"] = schedule_id
+
+ return self.auth.post_request(url=_SETTHERMMODE_REQ, params=post_params)
+
+ def set_room_thermpoint(
+ self,
+ room_id: str,
+ mode: str,
+ temp: float = None,
+ end_time: int = None,
+ ) -> Optional[str]:
+ post_params = {
+ "home_id": self.home_id,
+ "room_id": room_id,
+ "mode": mode,
+ }
+ # Temp and endtime should only be send when mode=='manual', but netatmo api can
+ # handle that even when mode == 'home' and these settings don't make sense
+ if temp is not None:
+ post_params["temp"] = str(temp)
+
+ if end_time is not None:
+ post_params["endtime"] = str(end_time)
+
+ return self.auth.post_request(url=_SETROOMTHERMPOINT_REQ, params=post_params)
diff --git a/src/pyatmo/weather_station.py b/src/pyatmo/weather_station.py
new file mode 100644
index 000000000..392ebe4a5
--- /dev/null
+++ b/src/pyatmo/weather_station.py
@@ -0,0 +1,297 @@
+import logging
+import time
+from typing import Dict, List, Optional, Tuple
+
+from .auth import NetatmoOAuth2
+from .exceptions import NoDevice
+from .helpers import _BASE_URL, fix_id, today_stamps
+
+LOG = logging.getLogger(__name__)
+
+_GETMEASURE_REQ = _BASE_URL + "api/getmeasure"
+_GETSTATIONDATA_REQ = _BASE_URL + "api/getstationsdata"
+
+
+class WeatherStationData:
+ """Class of Netatmo Weather Station devices (stations and modules)."""
+
+ def __init__(self, auth: NetatmoOAuth2, url_req: str = None) -> None:
+ """Initialize self.
+
+ Arguments:
+ auth {NetatmoOAuth2} -- Authentication information with a valid access token
+
+ Raises:
+ NoDevice: No devices found.
+ """
+ self.url_req = url_req or _GETSTATIONDATA_REQ
+ self.auth = auth
+
+ resp = self.auth.post_request(url=self.url_req)
+
+ if resp is None or "body" not in resp:
+ raise NoDevice("No weather station data returned by Netatmo server")
+
+ try:
+ self.raw_data = fix_id(resp["body"].get("devices"))
+ except KeyError as exc:
+ LOG.debug("No in response %s", resp)
+ raise NoDevice(
+ "No weather station data returned by Netatmo server",
+ ) from exc
+
+ if not self.raw_data:
+ raise NoDevice("No weather station available")
+
+ self.stations = {d["_id"]: d for d in self.raw_data}
+ self.modules = {}
+
+ for item in self.raw_data:
+ # The station name is sometimes not contained in the backend data
+ if "station_name" not in item:
+ item["station_name"] = item.get("home_name", item["type"])
+
+ if "modules" not in item:
+ item["modules"] = [item]
+
+ for module in item["modules"]:
+ if "module_name" not in module and module["type"] == "NHC":
+ module["module_name"] = module["station_name"]
+
+ self.modules[module["_id"]] = module
+ self.modules[module["_id"]]["main_device"] = item["_id"]
+
+ def get_module_names(self, station_id: str) -> List:
+ """Return a list of all module names for a given station."""
+ res = set()
+ station_data = self.get_station(station_id)
+
+ if not station_data:
+ return []
+
+ res.add(station_data.get("module_name", station_data.get("type")))
+ for module in station_data["modules"]:
+ # Add module name, use module type if no name is available
+ res.add(module.get("module_name", module.get("type")))
+
+ return list(res)
+
+ def get_modules(self, station_id: str) -> Dict:
+ """Return a dict of modules per given station."""
+ station_data = self.get_station(station_id)
+
+ if not station_data:
+ return {}
+
+ res = {}
+ for station in [self.stations[station_data["_id"]]]:
+ station_type = station.get("type")
+ station_name = station.get("station_name", station_type)
+ res[station["_id"]] = {
+ "station_name": station_name,
+ "module_name": station.get("module_name", station_type),
+ "id": station["_id"],
+ }
+
+ for module in station["modules"]:
+ res[module["_id"]] = {
+ "station_name": module.get("station_name", station_name),
+ "module_name": module.get("module_name", module.get("type")),
+ "id": module["_id"],
+ }
+
+ return res
+
+ def get_station(self, station_id: str) -> Dict:
+ """Return station by id."""
+ return self.stations.get(station_id, {})
+
+ def get_module(self, module_id: str) -> Dict:
+ """Return module by id."""
+ return self.modules.get(module_id, {})
+
+ def get_monitored_conditions(self, module_id: str) -> List:
+ """Return monitored conditions for given module."""
+ module = self.get_module(module_id)
+ if not module:
+ module = self.get_station(module_id)
+
+ if not module:
+ return []
+
+ conditions = []
+ for condition in module.get("data_type", []):
+ if condition == "Wind":
+ # the Wind meter actually exposes the following conditions
+ conditions.extend(
+ ["WindAngle", "WindStrength", "GustAngle", "GustStrength"],
+ )
+
+ elif condition == "Rain":
+ conditions.extend(["Rain", "sum_rain_24", "sum_rain_1"])
+
+ else:
+ conditions.append(condition)
+
+ if module["type"] in ["NAMain", "NHC"]:
+ # the main module has wifi_status
+ conditions.append("wifi_status")
+
+ else:
+ # assume all other modules have rf_status, battery_vp, and battery_percent
+ conditions.extend(["rf_status", "battery_vp", "battery_percent"])
+
+ if module["type"] in ["NAMain", "NAModule1", "NAModule4"]:
+ conditions.extend(["temp_trend"])
+
+ if module["type"] == "NAMain":
+ conditions.extend(["pressure_trend"])
+
+ if module["type"] in [
+ "NAMain",
+ "NAModule1",
+ "NAModule2",
+ "NAModule3",
+ "NAModule4",
+ "NHC",
+ ]:
+ conditions.append("reachable")
+
+ return conditions
+
+ def get_last_data(self, station_id: str, exclude: int = 0) -> Dict:
+ """Return data for a given station and time frame."""
+ key = "_id"
+
+ # Breaking change from Netatmo : dashboard_data no longer available if station lost
+ last_data: Dict = {}
+ station = self.get_station(station_id)
+
+ if not station or "dashboard_data" not in station:
+ LOG.debug("No dashboard data for station %s", station_id)
+ return last_data
+
+ # Define oldest acceptable sensor measure event
+ limit = (time.time() - exclude) if exclude else 0
+
+ data = station["dashboard_data"]
+ if key in station and data["time_utc"] > limit:
+ last_data[station[key]] = data.copy()
+ last_data[station[key]]["When"] = last_data[station[key]].pop("time_utc")
+ last_data[station[key]]["wifi_status"] = station.get("wifi_status")
+ last_data[station[key]]["reachable"] = station.get("reachable")
+
+ for module in station["modules"]:
+
+ if "dashboard_data" not in module or key not in module:
+ continue
+
+ data = module["dashboard_data"]
+ if "time_utc" in data and data["time_utc"] > limit:
+ last_data[module[key]] = data.copy()
+ last_data[module[key]]["When"] = last_data[module[key]].pop("time_utc")
+
+ # For potential use, add battery and radio coverage information to module data if present
+ for i in (
+ "rf_status",
+ "battery_vp",
+ "battery_percent",
+ "reachable",
+ "wifi_status",
+ ):
+ if i in module:
+ last_data[module[key]][i] = module[i]
+
+ return last_data
+
+ def check_not_updated(self, station_id: str, delay: int = 3600) -> List:
+ """Check if a given station has not been updated."""
+ res = self.get_last_data(station_id)
+ return [
+ key for key, value in res.items() if time.time() - value["When"] > delay
+ ]
+
+ def check_updated(self, station_id: str, delay: int = 3600) -> List:
+ """Check if a given station has been updated."""
+ res = self.get_last_data(station_id)
+ return [
+ key for key, value in res.items() if time.time() - value["When"] < delay
+ ]
+
+ def get_data(
+ self,
+ device_id: str,
+ scale: str,
+ module_type: str,
+ module_id: str = None,
+ date_begin: float = None,
+ date_end: float = None,
+ limit: int = None,
+ optimize: bool = False,
+ real_time: bool = False,
+ ) -> Optional[Dict]:
+ """Retrieve data from a device or module."""
+ post_params = {"device_id": device_id}
+ if module_id:
+ post_params["module_id"] = module_id
+
+ post_params["scale"] = scale
+ post_params["type"] = module_type
+
+ if date_begin:
+ post_params["date_begin"] = f"{date_begin}"
+
+ if date_end:
+ post_params["date_end"] = f"{date_end}"
+
+ if limit:
+ post_params["limit"] = f"{limit}"
+
+ post_params["optimize"] = "true" if optimize else "false"
+ post_params["real_time"] = "true" if real_time else "false"
+
+ return self.auth.post_request(url=_GETMEASURE_REQ, params=post_params)
+
+ def get_min_max_t_h(
+ self,
+ station_id: str,
+ module_id: str = None,
+ frame: str = "last24",
+ ) -> Optional[Tuple[float, float, float, float]]:
+ """Return minimum and maximum temperature and humidity over the given timeframe.
+
+ Arguments:
+ station_id {str} -- Station ID
+
+ Keyword Arguments:
+ module_id {str} -- Module ID (default: {None})
+ frame {str} -- Timeframe can be "last24" or "day" (default: {"last24"})
+
+ Returns:
+ (min_t {float}, max_t {float}, min_h {float}, max_h {float}) -- minimum and maximum for temperature and humidity
+ """
+ if frame == "last24":
+ end = time.time()
+ start = end - 24 * 3600 # 24 hours ago
+
+ elif frame == "day":
+ start, end = today_stamps()
+
+ else:
+ raise ValueError("'frame' value can only be 'last24' or 'day'")
+
+ resp = self.get_data(
+ device_id=station_id,
+ module_id=module_id,
+ scale="max",
+ module_type="Temperature,Humidity",
+ date_begin=start,
+ date_end=end,
+ )
+
+ if resp:
+ temperature = [temp[0] for temp in resp["body"].values()]
+ humidity = [hum[1] for hum in resp["body"].values()]
+ return min(temperature), max(temperature), min(humidity), max(humidity)
+
+ return None
diff --git a/src/version.py b/src/version.py
new file mode 100644
index 000000000..a5cb1585c
--- /dev/null
+++ b/src/version.py
@@ -0,0 +1,4 @@
+MAJOR_VERSION = 4
+MINOR_VERSION = 2
+PATCH_VERSION = 1
+__version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}"
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 000000000..2258385f9
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,130 @@
+"""Define shared fixtures."""
+# pylint: disable=redefined-outer-name, protected-access
+import json
+from contextlib import contextmanager
+
+import pytest
+
+import pyatmo
+
+
+@contextmanager
+def does_not_raise():
+ yield
+
+
+@pytest.fixture(scope="function")
+def auth(requests_mock):
+ with open("fixtures/oauth2_token.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.auth.AUTH_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ return pyatmo.ClientAuth(
+ client_id="CLIENT_ID",
+ client_secret="CLIENT_SECRET",
+ username="USERNAME",
+ password="PASSWORD",
+ scope=" ".join(pyatmo.auth.ALL_SCOPES),
+ )
+
+
+@pytest.fixture(scope="function")
+def home_data(auth, requests_mock):
+ with open("fixtures/home_data_simple.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.thermostat._GETHOMESDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ return pyatmo.HomeData(auth)
+
+
+@pytest.fixture(scope="function")
+def home_status(auth, home_id, requests_mock):
+ with open("fixtures/home_status_simple.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.thermostat._GETHOMESTATUS_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ return pyatmo.HomeStatus(auth, home_id)
+
+
+@pytest.fixture(scope="function")
+def public_data(auth, requests_mock):
+ with open("fixtures/public_data_simple.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.public_data._GETPUBLIC_DATA,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+
+ lon_ne = 6.221652
+ lat_ne = 46.610870
+ lon_sw = 6.217828
+ lat_sw = 46.596485
+
+ return pyatmo.PublicData(auth, lat_ne, lon_ne, lat_sw, lon_sw)
+
+
+@pytest.fixture(scope="function")
+def weather_station_data(auth, requests_mock):
+ with open("fixtures/weatherstation_data_simple.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.weather_station._GETSTATIONDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ return pyatmo.WeatherStationData(auth)
+
+
+@pytest.fixture(scope="function")
+def home_coach_data(auth, requests_mock):
+ with open("fixtures/home_coach_simple.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.home_coach._GETHOMECOACHDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ return pyatmo.HomeCoachData(auth)
+
+
+@pytest.fixture(scope="function")
+def camera_home_data(auth, requests_mock):
+ with open("fixtures/camera_home_data.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.camera._GETHOMEDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ for index in ["w", "z", "g"]:
+ vpn_url = (
+ f"https://prodvpn-eu-2.netatmo.net/restricted/10.255.248.91/"
+ f"6d278460699e56180d47ab47169efb31/"
+ f"MpEylTU2MDYzNjRVD-LJxUnIndumKzLboeAwMDqTT{index},,"
+ )
+ with open("fixtures/camera_ping.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ vpn_url + "/command/ping",
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ local_url = "http://192.168.0.123/678460a0d47e5618699fb31169e2b47d"
+ with open("fixtures/camera_ping.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ local_url + "/command/ping",
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ return pyatmo.CameraData(auth)
diff --git a/tests/test_pyatmo.py b/tests/test_pyatmo.py
new file mode 100644
index 000000000..cb8711da4
--- /dev/null
+++ b/tests/test_pyatmo.py
@@ -0,0 +1,123 @@
+"""Define tests for untility methods."""
+# pylint: disable=protected-access
+import json
+import time
+
+import oauthlib
+import pytest
+
+import pyatmo
+
+
+def test_client_auth(auth):
+ assert auth._oauth.token["access_token"] == (
+ "91763b24c43d3e344f424e8b|880b55a08c758e87ff8755a00c6b8a12"
+ )
+ assert auth._oauth.token["refresh_token"] == (
+ "91763b24c43d3e344f424e8b|87ff8755a00c6b8a120b55a08c758e93"
+ )
+
+
+def test_client_auth_invalid(requests_mock):
+ with open("fixtures/invalid_grant.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.auth.AUTH_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with pytest.raises(oauthlib.oauth2.rfc6749.errors.InvalidGrantError):
+ pyatmo.ClientAuth(
+ client_id="CLIENT_ID",
+ client_secret="CLIENT_SECRET",
+ username="USERNAME",
+ password="PASSWORD",
+ )
+
+
+def test_post_request_json(auth, requests_mock):
+ """Test wrapper for posting requests against the Netatmo API."""
+ requests_mock.post(
+ pyatmo.helpers._BASE_URL,
+ json={"a": "b"},
+ headers={"content-type": "application/json"},
+ )
+ resp = auth.post_request(pyatmo.helpers._BASE_URL, None)
+ assert resp == {"a": "b"}
+
+
+def test_post_request_binary(auth, requests_mock):
+ """Test wrapper for posting requests against the Netatmo API."""
+ requests_mock.post(
+ pyatmo.helpers._BASE_URL,
+ text="Success",
+ headers={"content-type": "application/text"},
+ )
+ resp = auth.post_request(pyatmo.helpers._BASE_URL, None)
+ assert resp == b"Success"
+
+
+@pytest.mark.parametrize(
+ "test_input,expected",
+ [(200, None), (404, None), (401, None)],
+)
+def test_post_request_fail(auth, requests_mock, test_input, expected):
+ """Test failing requests against the Netatmo API."""
+ requests_mock.post(pyatmo.helpers._BASE_URL, status_code=test_input)
+
+ if test_input == 200:
+ resp = auth.post_request(pyatmo.helpers._BASE_URL, None)
+ assert resp is expected
+ else:
+ with pytest.raises(pyatmo.ApiError):
+ resp = auth.post_request(pyatmo.helpers._BASE_URL, None)
+
+
+@pytest.mark.parametrize(
+ "test_input,expected",
+ [
+ (1, "1970-01-01_00:00:01"),
+ (0, "1970-01-01_00:00:00"),
+ (-1, "1969-12-31_23:59:59"),
+ (2000000000, "2033-05-18_03:33:20"),
+ ("1", "1970-01-01_00:00:01"),
+ pytest.param("A", None, marks=pytest.mark.xfail),
+ pytest.param([1], None, marks=pytest.mark.xfail),
+ pytest.param({1}, None, marks=pytest.mark.xfail),
+ ],
+)
+def test_to_time_string(test_input, expected):
+ """Test time to string conversion."""
+ assert pyatmo.helpers.to_time_string(test_input) == expected
+
+
+@pytest.mark.parametrize(
+ "test_input,expected",
+ [
+ ("1970-01-01_00:00:01", 1),
+ ("1970-01-01_00:00:00", 0),
+ ("1969-12-31_23:59:59", -1),
+ ("2033-05-18_03:33:20", 2000000000),
+ ],
+)
+def test_to_epoch(test_input, expected):
+ """Test time to epoch conversion."""
+ assert pyatmo.helpers.to_epoch(test_input) == expected
+
+
+@pytest.mark.parametrize(
+ "test_input,expected",
+ [
+ ("2018-06-21", (1529539200, 1529625600)),
+ ("2000-01-01", (946684800, 946771200)),
+ pytest.param("2000-04-31", None, marks=pytest.mark.xfail),
+ ],
+)
+def test_today_stamps(monkeypatch, test_input, expected):
+ """Test today_stamps function."""
+
+ def mockreturn(_):
+ return test_input
+
+ monkeypatch.setattr(time, "strftime", mockreturn)
+ assert pyatmo.helpers.today_stamps() == expected
diff --git a/tests/test_pyatmo_camera.py b/tests/test_pyatmo_camera.py
new file mode 100644
index 000000000..a21aa850c
--- /dev/null
+++ b/tests/test_pyatmo_camera.py
@@ -0,0 +1,519 @@
+"""Define tests for Camera module."""
+# pylint: disable=protected-access
+import json
+
+import pytest
+from freezegun import freeze_time
+
+import pyatmo
+
+from .conftest import does_not_raise
+
+
+def test_camera_data(camera_home_data):
+ assert camera_home_data.homes is not None
+
+
+def test_home_data_no_body(auth, requests_mock):
+ with open("fixtures/camera_data_empty.json") as fixture_file:
+ json_fixture = json.load(fixture_file)
+ requests_mock.post(
+ pyatmo.camera._GETHOMEDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with pytest.raises(pyatmo.NoDevice):
+ assert pyatmo.CameraData(auth)
+
+
+def test_home_data_no_homes(auth, requests_mock):
+ with open("fixtures/camera_home_data_no_homes.json") as fixture_file:
+ json_fixture = json.load(fixture_file)
+ requests_mock.post(
+ pyatmo.camera._GETHOMEDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with pytest.raises(pyatmo.NoDevice):
+ assert pyatmo.CameraData(auth)
+
+
+@pytest.mark.parametrize(
+ "cid, expected",
+ [
+ ("12:34:56:00:f1:62", "Hall"),
+ ("12:34:56:00:a5:a4", "Garden"),
+ ("None", None),
+ (None, None),
+ ],
+)
+def test_camera_data_get_camera(camera_home_data, cid, expected):
+ camera = camera_home_data.get_camera(cid)
+ assert camera.get("name") == expected
+
+
+def test_camera_data_get_module(camera_home_data):
+ assert camera_home_data.get_module("00:00:00:00:00:00") is None
+
+
+def test_camera_data_camera_urls(camera_home_data, requests_mock):
+ cid = "12:34:56:00:f1:62"
+ vpn_url = (
+ "https://prodvpn-eu-2.netatmo.net/restricted/10.255.248.91/"
+ "6d278460699e56180d47ab47169efb31/"
+ "MpEylTU2MDYzNjRVD-LJxUnIndumKzLboeAwMDqTTg,,"
+ )
+ local_url = "http://192.168.0.123/678460a0d47e5618699fb31169e2b47d"
+ with open("fixtures/camera_ping.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ vpn_url + "/command/ping",
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with open("fixtures/camera_ping.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ local_url + "/command/ping",
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+
+ camera_home_data.update_camera_urls(cid)
+
+ assert camera_home_data.camera_urls(cid) == (vpn_url, local_url)
+
+
+def test_camera_data_update_camera_urls_empty(camera_home_data):
+ camera_id = "12:34:56:00:f1:62"
+ home_id = "91763b24c43d3e344f424e8b"
+ camera_home_data.cameras[home_id][camera_id]["vpn_url"] = None
+ camera_home_data.cameras[home_id][camera_id]["local_url"] = None
+
+ camera_home_data.update_camera_urls(camera_id)
+
+ assert camera_home_data.camera_urls(camera_id) == (None, None)
+
+
+def test_camera_data_camera_urls_disconnected(auth, requests_mock):
+ with open("fixtures/camera_home_data_disconnected.json") as fixture_file:
+ json_fixture = json.load(fixture_file)
+ requests_mock.post(
+ pyatmo.camera._GETHOMEDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ camera_data = pyatmo.CameraData(auth)
+ cid = "12:34:56:00:f1:62"
+
+ camera_data.update_camera_urls(cid)
+
+ assert camera_data.camera_urls(cid) == (None, None)
+
+
+@pytest.mark.parametrize(
+ "home_id, expected",
+ [("91763b24c43d3e344f424e8b", ["Richard Doe"])],
+)
+def test_camera_data_persons_at_home(camera_home_data, home_id, expected):
+ assert camera_home_data.persons_at_home(home_id) == expected
+
+
+@freeze_time("2019-06-16")
+@pytest.mark.parametrize(
+ "name, cid, exclude, expected",
+ [
+ ("John Doe", "12:34:56:00:f1:62", None, True),
+ ("Richard Doe", "12:34:56:00:f1:62", None, False),
+ ("Unknown", "12:34:56:00:f1:62", None, False),
+ ("John Doe", "12:34:56:00:f1:62", 1, False),
+ ("John Doe", "12:34:56:00:f1:62", 50000, True),
+ ("Jack Doe", "12:34:56:00:f1:62", None, False),
+ ],
+)
+def test_camera_data_person_seen_by_camera(
+ camera_home_data,
+ name,
+ cid,
+ exclude,
+ expected,
+):
+ assert (
+ camera_home_data.person_seen_by_camera(name, cid, exclude=exclude) is expected
+ )
+
+
+def test_camera_data__known_persons(camera_home_data):
+ known_persons = camera_home_data._known_persons()
+ print(known_persons)
+ print(known_persons.keys())
+ assert len(known_persons) == 3
+ assert known_persons["91827374-7e04-5298-83ad-a0cb8372dff1"]["pseudo"] == "John Doe"
+
+
+def test_camera_data_known_persons(camera_home_data):
+ known_persons = camera_home_data.known_persons()
+ assert len(known_persons) == 3
+ assert known_persons["91827374-7e04-5298-83ad-a0cb8372dff1"] == "John Doe"
+
+
+def test_camera_data_known_persons_names(camera_home_data):
+ assert sorted(camera_home_data.known_persons_names()) == [
+ "Jane Doe",
+ "John Doe",
+ "Richard Doe",
+ ]
+
+
+@freeze_time("2019-06-16")
+@pytest.mark.parametrize(
+ "name, expected",
+ [
+ ("John Doe", "91827374-7e04-5298-83ad-a0cb8372dff1"),
+ ("Richard Doe", "91827376-7e04-5298-83af-a0cb8372dff3"),
+ ("Dexter Foe", None),
+ ],
+)
+def test_camera_data_get_person_id(camera_home_data, name, expected):
+ assert camera_home_data.get_person_id(name) == expected
+
+
+@pytest.mark.parametrize(
+ "home_id, person_id, json_fixture, expected",
+ [
+ (
+ "91763b24c43d3e344f424e8b",
+ "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "status_ok.json",
+ "ok",
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ "91827376-7e04-5298-83af-a0cb8372dff3",
+ "status_ok.json",
+ "ok",
+ ),
+ ],
+)
+def test_camera_data_set_persons_away(
+ camera_home_data,
+ requests_mock,
+ home_id,
+ person_id,
+ json_fixture,
+ expected,
+):
+ with open("fixtures/%s" % json_fixture) as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.camera._SETPERSONSAWAY_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ assert camera_home_data.set_persons_away(person_id, home_id)["status"] == expected
+
+
+@pytest.mark.parametrize(
+ "home_id, person_ids, json_fixture, expected",
+ [
+ (
+ "91763b24c43d3e344f424e8b",
+ [
+ "91827374-7e04-5298-83ad-a0cb8372dff1",
+ "91827376-7e04-5298-83af-a0cb8372dff3",
+ ],
+ "status_ok.json",
+ "ok",
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ "91827376-7e04-5298-83af-a0cb8372dff3",
+ "status_ok.json",
+ "ok",
+ ),
+ ],
+)
+def test_camera_data_set_persons_home(
+ camera_home_data,
+ requests_mock,
+ home_id,
+ person_ids,
+ json_fixture,
+ expected,
+):
+ with open("fixtures/%s" % json_fixture) as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.camera._SETPERSONSHOME_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ assert camera_home_data.set_persons_home(person_ids, home_id)["status"] == expected
+
+
+@freeze_time("2019-06-16")
+@pytest.mark.parametrize(
+ "camera_id, exclude, expected,expectation",
+ [
+ ("12:34:56:00:f1:62", None, True, does_not_raise()),
+ ("12:34:56:00:f1:62", 5, False, does_not_raise()),
+ (None, None, None, pytest.raises(pyatmo.NoDevice)),
+ ],
+)
+def test_camera_data_someone_known_seen(
+ camera_home_data,
+ camera_id,
+ exclude,
+ expected,
+ expectation,
+):
+ with expectation:
+ assert camera_home_data.someone_known_seen(camera_id, exclude) == expected
+
+
+@freeze_time("2019-06-16")
+@pytest.mark.parametrize(
+ "camera_id, exclude, expected, expectation",
+ [
+ ("12:34:56:00:f1:62", None, False, does_not_raise()),
+ ("12:34:56:00:f1:62", 100, False, does_not_raise()),
+ (None, None, None, pytest.raises(pyatmo.NoDevice)),
+ ],
+)
+def test_camera_data_someone_unknown_seen(
+ camera_home_data,
+ camera_id,
+ exclude,
+ expected,
+ expectation,
+):
+ with expectation:
+ assert camera_home_data.someone_unknown_seen(camera_id, exclude) == expected
+
+
+@freeze_time("2019-06-16")
+@pytest.mark.parametrize(
+ "camera_id, exclude, expected, expectation",
+ [
+ ("12:34:56:00:f1:62", None, False, does_not_raise()),
+ ("12:34:56:00:f1:62", 140000, True, does_not_raise()),
+ ("12:34:56:00:f1:62", 130000, False, does_not_raise()),
+ (None, None, False, pytest.raises(pyatmo.NoDevice)),
+ ],
+)
+def test_camera_data_motion_detected(
+ camera_home_data,
+ camera_id,
+ exclude,
+ expected,
+ expectation,
+):
+ with expectation:
+ assert camera_home_data.motion_detected(camera_id, exclude) == expected
+
+
+@pytest.mark.parametrize(
+ "sid, expected",
+ [
+ ("12:34:56:00:8b:a2", "Hall"),
+ ("12:34:56:00:8b:ac", "Kitchen"),
+ ("None", None),
+ (None, None),
+ ],
+)
+def test_camera_data_get_smokedetector(camera_home_data, sid, expected):
+ smokedetector = camera_home_data.get_smokedetector(sid)
+ if smokedetector:
+ assert smokedetector["name"] == expected
+ else:
+ assert smokedetector is expected
+
+
+@pytest.mark.parametrize(
+ "home_id, camera_id, floodlight, monitoring, json_fixture, expected",
+ [
+ (
+ "91763b24c43d3e344f424e8b",
+ "12:34:56:00:f1:ff",
+ "on",
+ None,
+ "camera_set_state_error.json",
+ False,
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ "12:34:56:00:f1:62",
+ None,
+ "on",
+ "camera_set_state_ok.json",
+ True,
+ ),
+ (
+ None,
+ "12:34:56:00:f1:62",
+ None,
+ "on",
+ "camera_set_state_ok.json",
+ True,
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ "12:34:56:00:f1:62",
+ "auto",
+ "on",
+ "camera_set_state_ok.json",
+ True,
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ "12:34:56:00:f1:62",
+ None,
+ "on",
+ "camera_set_state_error_already_on.json",
+ True,
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ "12:34:56:00:f1:62",
+ "on",
+ None,
+ "camera_set_state_error_wrong_parameter.json",
+ False,
+ ),
+ ],
+)
+def test_camera_data_set_state(
+ camera_home_data,
+ requests_mock,
+ home_id,
+ camera_id,
+ floodlight,
+ monitoring,
+ json_fixture,
+ expected,
+):
+ with open("fixtures/%s" % json_fixture) as fixture_file:
+ json_fixture = json.load(fixture_file)
+ requests_mock.post(
+ pyatmo.camera._SETSTATE_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ assert (
+ camera_home_data.set_state(
+ home_id=home_id,
+ camera_id=camera_id,
+ floodlight=floodlight,
+ monitoring=monitoring,
+ )
+ == expected
+ )
+
+
+def test_camera_data_get_light_state(camera_home_data):
+ camera_id = "12:34:56:00:a5:a4"
+ expected = "auto"
+ assert camera_home_data.get_light_state(camera_id) == expected
+
+
+def test_camera_data_get_camera_picture(camera_home_data, requests_mock):
+ image_id = "5c22739723720a6e278c43bf"
+ key = "276751836a6d1a71447f8d975494c87bc125766a970f7e022e79e001e021d756"
+ with open("fixtures/camera_image_sample.jpg", "rb") as fixture_file:
+ expect = fixture_file.read()
+
+ requests_mock.post(pyatmo.camera._GETCAMERAPICTURE_REQ, content=expect)
+
+ assert camera_home_data.get_camera_picture(image_id, key) == (expect, "jpeg")
+
+
+def test_camera_data_get_profile_image(camera_home_data, requests_mock):
+ with open("fixtures/camera_image_sample.jpg", "rb") as fixture_file:
+ expect = fixture_file.read()
+
+ requests_mock.post(pyatmo.camera._GETCAMERAPICTURE_REQ, content=expect)
+ assert camera_home_data.get_profile_image("John Doe") == (expect, "jpeg")
+ assert camera_home_data.get_profile_image("Jack Foe") == (None, None)
+
+
+@pytest.mark.parametrize(
+ "home_id, event_id, device_type, exception",
+ [
+ ("91763b24c43d3e344f424e8b", None, None, pytest.raises(pyatmo.ApiError)),
+ (
+ "91763b24c43d3e344f424e8b",
+ "a1b2c3d4e5f6abcdef123456",
+ None,
+ does_not_raise(),
+ ),
+ ("91763b24c43d3e344f424e8b", None, "NOC", does_not_raise()),
+ ("91763b24c43d3e344f424e8b", None, "NACamera", does_not_raise()),
+ ("91763b24c43d3e344f424e8b", None, "NSD", does_not_raise()),
+ ],
+)
+def test_camera_data_update_events(
+ camera_home_data,
+ requests_mock,
+ home_id,
+ event_id,
+ device_type,
+ exception,
+):
+ with open("fixtures/camera_data_events_until.json") as fixture_file:
+ json_fixture = json.load(fixture_file)
+ requests_mock.post(
+ pyatmo.camera._GETEVENTSUNTIL_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with exception:
+ before_outdoor = camera_home_data.outdoor_last_event.copy()
+ before = camera_home_data.last_event.copy()
+ assert (
+ camera_home_data.update_events(
+ home_id=home_id,
+ event_id=event_id,
+ device_type=device_type,
+ )
+ is None
+ )
+ assert camera_home_data.outdoor_last_event != before_outdoor
+ assert camera_home_data.last_event != before
+
+
+def test_camera_data_outdoor_motion_detected(camera_home_data):
+ camera_id = "12:34:56:00:a5:a4"
+ assert camera_home_data.outdoor_motion_detected(camera_id) is False
+ assert camera_home_data.outdoor_motion_detected(camera_id, 100) is False
+
+
+def test_camera_data_human_detected(camera_home_data):
+ camera_id = "12:34:56:00:a5:a4"
+ assert camera_home_data.human_detected(camera_id) is False
+ assert camera_home_data.human_detected(camera_id, 100) is False
+
+
+def test_camera_data_animal_detected(camera_home_data):
+ camera_id = "12:34:56:00:a5:a4"
+ assert camera_home_data.animal_detected(camera_id) is False
+ assert camera_home_data.animal_detected(camera_id, 100) is False
+
+
+def test_camera_data_car_detected(camera_home_data):
+ camera_id = "12:34:56:00:a5:a4"
+ assert camera_home_data.car_detected(camera_id) is False
+ assert camera_home_data.car_detected(camera_id, 100) is False
+
+
+def test_camera_data_module_motion_detected(camera_home_data):
+ camera_id = "12:34:56:00:f1:62"
+ module_id = "12:34:56:00:f2:f1"
+ assert camera_home_data.module_motion_detected(camera_id, module_id) is False
+ assert camera_home_data.module_motion_detected(camera_id, module_id, 100) is False
+
+
+def test_camera_data_module_opened(camera_home_data):
+ camera_id = "12:34:56:00:f1:62"
+ module_id = "12:34:56:00:f2:f1"
+ assert camera_home_data.module_opened(camera_id, module_id) is False
+ assert camera_home_data.module_opened(camera_id, module_id, 100) is False
diff --git a/tests/test_pyatmo_homecoach.py b/tests/test_pyatmo_homecoach.py
new file mode 100644
index 000000000..5411df567
--- /dev/null
+++ b/tests/test_pyatmo_homecoach.py
@@ -0,0 +1,67 @@
+"""Define tests for HomeCoach module."""
+# pylint: disable=protected-access
+import json
+
+import pytest
+
+import pyatmo
+
+
+def test_home_coach_data(home_coach_data):
+ assert home_coach_data.stations["12:34:56:26:69:0c"]["station_name"] == "Bedroom"
+
+
+@pytest.mark.parametrize(
+ "station_id, expected",
+ [
+ ("12:34:56:26:69:0c", ["Bedroom"]),
+ pytest.param(
+ "NoValidStation",
+ None,
+ marks=pytest.mark.xfail(
+ reason="Invalid station names are not handled yet.",
+ ),
+ ),
+ ],
+)
+def test_home_coach_data_get_module_names(home_coach_data, station_id, expected):
+ assert sorted(home_coach_data.get_module_names(station_id)) == expected
+
+
+@pytest.mark.parametrize(
+ "station_id, expected",
+ [
+ (None, {}),
+ (
+ "12:34:56:26:69:0c",
+ {
+ "12:34:56:26:69:0c": {
+ "station_name": "Bedroom",
+ "module_name": "Bedroom",
+ "id": "12:34:56:26:69:0c",
+ },
+ },
+ ),
+ pytest.param(
+ "NoValidStation",
+ None,
+ marks=pytest.mark.xfail(
+ reason="Invalid station names are not handled yet.",
+ ),
+ ),
+ ],
+)
+def test_home_coach_data_get_modules(home_coach_data, station_id, expected):
+ assert home_coach_data.get_modules(station_id) == expected
+
+
+def test_home_coach_data_no_devices(auth, requests_mock):
+ with open("fixtures/home_coach_no_devices.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.home_coach._GETHOMECOACHDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with pytest.raises(pyatmo.NoDevice):
+ assert pyatmo.home_coach.HomeCoachData(auth)
diff --git a/tests/test_pyatmo_publicdata.py b/tests/test_pyatmo_publicdata.py
new file mode 100644
index 000000000..dc96a40e8
--- /dev/null
+++ b/tests/test_pyatmo_publicdata.py
@@ -0,0 +1,308 @@
+"""Define tests for Public weather module."""
+# pylint: disable=protected-access
+import json
+
+import pytest
+
+import pyatmo
+
+LON_NE = 6.221652
+LAT_NE = 46.610870
+LON_SW = 6.217828
+LAT_SW = 46.596485
+
+
+def test_public_data(auth, requests_mock):
+ with open("fixtures/public_data_simple.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.public_data._GETPUBLIC_DATA,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+
+ public_data = pyatmo.PublicData(auth, LAT_NE, LON_NE, LAT_SW, LON_SW)
+ assert public_data.status == "ok"
+
+ public_data = pyatmo.PublicData(
+ auth,
+ LAT_NE,
+ LON_NE,
+ LAT_SW,
+ LON_SW,
+ required_data_type="temperature,rain_live",
+ )
+ assert public_data.status == "ok"
+
+
+def test_public_data_unavailable(auth, requests_mock):
+ requests_mock.post(pyatmo.public_data._GETPUBLIC_DATA, status_code=404)
+ with pytest.raises(pyatmo.ApiError):
+ pyatmo.PublicData(auth, LAT_NE, LON_NE, LAT_SW, LON_SW)
+
+
+def test_public_data_error(auth, requests_mock):
+ with open("fixtures/public_data_error_mongo.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.public_data._GETPUBLIC_DATA,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with pytest.raises(pyatmo.NoDevice):
+ pyatmo.PublicData(auth, LAT_NE, LON_NE, LAT_SW, LON_SW)
+
+
+def test_public_data_stations_in_area(public_data):
+ assert public_data.stations_in_area() == 8
+
+
+def test_public_data_get_latest_rain(public_data):
+ expected = {
+ "70:ee:50:1f:68:9e": 0,
+ "70:ee:50:27:25:b0": 0,
+ "70:ee:50:36:94:7c": 0.5,
+ "70:ee:50:36:a9:fc": 0,
+ }
+ assert public_data.get_latest_rain() == expected
+
+
+def test_public_data_get_average_rain(public_data):
+ assert public_data.get_average_rain() == 0.125
+
+
+def test_public_data_get_60_min_rain(public_data):
+ expected = {
+ "70:ee:50:1f:68:9e": 0,
+ "70:ee:50:27:25:b0": 0,
+ "70:ee:50:36:94:7c": 0.2,
+ "70:ee:50:36:a9:fc": 0,
+ }
+ assert public_data.get_60_min_rain() == expected
+
+
+def test_public_data_get_average_60_min_rain(public_data):
+ assert public_data.get_average_60_min_rain() == 0.05
+
+
+def test_public_data_get_24_h_rain(public_data):
+ expected = {
+ "70:ee:50:1f:68:9e": 9.999,
+ "70:ee:50:27:25:b0": 11.716000000000001,
+ "70:ee:50:36:94:7c": 12.322000000000001,
+ "70:ee:50:36:a9:fc": 11.009,
+ }
+ assert public_data.get_24_h_rain() == expected
+
+
+def test_public_data_get_average_24_h_rain(public_data):
+ assert public_data.get_average_24_h_rain() == 11.261500000000002
+
+
+def test_public_data_get_latest_pressures(public_data):
+ expected = {
+ "70:ee:50:1f:68:9e": 1007.3,
+ "70:ee:50:27:25:b0": 1012.8,
+ "70:ee:50:36:94:7c": 1010.6,
+ "70:ee:50:36:a9:fc": 1010,
+ "70:ee:50:01:20:fa": 1014.4,
+ "70:ee:50:04:ed:7a": 1005.4,
+ "70:ee:50:27:9f:2c": 1010.6,
+ "70:ee:50:3c:02:78": 1011.7,
+ }
+ assert public_data.get_latest_pressures() == expected
+
+
+def test_public_data_get_average_pressure(public_data):
+ assert public_data.get_average_pressure() == 1010.3499999999999
+
+
+def test_public_data_get_latest_temperatures(public_data):
+ expected = {
+ "70:ee:50:1f:68:9e": 21.1,
+ "70:ee:50:27:25:b0": 23.2,
+ "70:ee:50:36:94:7c": 21.4,
+ "70:ee:50:36:a9:fc": 20.1,
+ "70:ee:50:01:20:fa": 27.4,
+ "70:ee:50:04:ed:7a": 19.8,
+ "70:ee:50:27:9f:2c": 25.5,
+ "70:ee:50:3c:02:78": 23.3,
+ }
+ assert public_data.get_latest_temperatures() == expected
+
+
+def test_public_data_get_average_temperature(public_data):
+ assert public_data.get_average_temperature() == 22.725
+
+
+def test_public_data_get_latest_humidities(public_data):
+ expected = {
+ "70:ee:50:1f:68:9e": 69,
+ "70:ee:50:27:25:b0": 60,
+ "70:ee:50:36:94:7c": 62,
+ "70:ee:50:36:a9:fc": 67,
+ "70:ee:50:01:20:fa": 58,
+ "70:ee:50:04:ed:7a": 76,
+ "70:ee:50:27:9f:2c": 56,
+ "70:ee:50:3c:02:78": 58,
+ }
+ assert public_data.get_latest_humidities() == expected
+
+
+def test_public_data_get_average_humidity(public_data):
+ assert public_data.get_average_humidity() == 63.25
+
+
+def test_public_data_get_latest_wind_strengths(public_data):
+ expected = {"70:ee:50:36:a9:fc": 15}
+ assert public_data.get_latest_wind_strengths() == expected
+
+
+def test_public_data_get_average_wind_strength(public_data):
+ assert public_data.get_average_wind_strength() == 15
+
+
+def test_public_data_get_latest_wind_angles(public_data):
+ expected = {"70:ee:50:36:a9:fc": 17}
+ assert public_data.get_latest_wind_angles() == expected
+
+
+def test_public_data_get_latest_gust_strengths(public_data):
+ expected = {"70:ee:50:36:a9:fc": 31}
+ assert public_data.get_latest_gust_strengths() == expected
+
+
+def test_public_data_get_average_gust_strength(public_data):
+ assert public_data.get_average_gust_strength() == 31
+
+
+def test_public_data_get_latest_gust_angles(public_data):
+ expected = {"70:ee:50:36:a9:fc": 217}
+ assert public_data.get_latest_gust_angles() == expected
+
+
+def test_public_data_get_locations(public_data):
+ expected = {
+ "70:ee:50:1f:68:9e": [8.795445200000017, 50.2130169],
+ "70:ee:50:27:25:b0": [8.7807159, 50.1946167],
+ "70:ee:50:36:94:7c": [8.791382999999996, 50.2136394],
+ "70:ee:50:36:a9:fc": [8.801164269110814, 50.19596181704958],
+ "70:ee:50:01:20:fa": [8.7953, 50.195241],
+ "70:ee:50:04:ed:7a": [8.785034, 50.192169],
+ "70:ee:50:27:9f:2c": [8.785342, 50.193573],
+ "70:ee:50:3c:02:78": [8.795953681700666, 50.19530139868166],
+ }
+ assert public_data.get_locations() == expected
+
+
+def test_public_data_get_time_for_rain_measures(public_data):
+ expected = {
+ "70:ee:50:36:a9:fc": 1560248184,
+ "70:ee:50:1f:68:9e": 1560248344,
+ "70:ee:50:27:25:b0": 1560247896,
+ "70:ee:50:36:94:7c": 1560248022,
+ }
+ assert public_data.get_time_for_rain_measures() == expected
+
+
+def test_public_data_get_time_for_wind_measures(public_data):
+ expected = {"70:ee:50:36:a9:fc": 1560248190}
+ assert public_data.get_time_for_wind_measures() == expected
+
+
+@pytest.mark.parametrize(
+ "test_input,expected",
+ [
+ (
+ "pressure",
+ {
+ "70:ee:50:01:20:fa": 1014.4,
+ "70:ee:50:04:ed:7a": 1005.4,
+ "70:ee:50:1f:68:9e": 1007.3,
+ "70:ee:50:27:25:b0": 1012.8,
+ "70:ee:50:27:9f:2c": 1010.6,
+ "70:ee:50:36:94:7c": 1010.6,
+ "70:ee:50:36:a9:fc": 1010,
+ "70:ee:50:3c:02:78": 1011.7,
+ },
+ ),
+ (
+ "temperature",
+ {
+ "70:ee:50:01:20:fa": 27.4,
+ "70:ee:50:04:ed:7a": 19.8,
+ "70:ee:50:1f:68:9e": 21.1,
+ "70:ee:50:27:25:b0": 23.2,
+ "70:ee:50:27:9f:2c": 25.5,
+ "70:ee:50:36:94:7c": 21.4,
+ "70:ee:50:36:a9:fc": 20.1,
+ "70:ee:50:3c:02:78": 23.3,
+ },
+ ),
+ (
+ "humidity",
+ {
+ "70:ee:50:01:20:fa": 58,
+ "70:ee:50:04:ed:7a": 76,
+ "70:ee:50:1f:68:9e": 69,
+ "70:ee:50:27:25:b0": 60,
+ "70:ee:50:27:9f:2c": 56,
+ "70:ee:50:36:94:7c": 62,
+ "70:ee:50:36:a9:fc": 67,
+ "70:ee:50:3c:02:78": 58,
+ },
+ ),
+ ],
+)
+def test_public_data_get_latest_station_measures(public_data, test_input, expected):
+ assert public_data.get_latest_station_measures(test_input) == expected
+
+
+@pytest.mark.parametrize(
+ "test_input,expected",
+ [
+ ("wind_strength", {"70:ee:50:36:a9:fc": 15}),
+ ("wind_angle", {"70:ee:50:36:a9:fc": 17}),
+ ("gust_strength", {"70:ee:50:36:a9:fc": 31}),
+ ("gust_angle", {"70:ee:50:36:a9:fc": 217}),
+ ("wind_timeutc", {"70:ee:50:36:a9:fc": 1560248190}),
+ ],
+)
+def test_public_data_get_accessory_data(public_data, test_input, expected):
+ assert public_data.get_accessory_data(test_input) == expected
+
+
+@pytest.mark.parametrize(
+ "test_input,expected",
+ [
+ (
+ {
+ "70:ee:50:01:20:fa": 1014.4,
+ "70:ee:50:04:ed:7a": 1005.4,
+ "70:ee:50:1f:68:9e": 1007.3,
+ "70:ee:50:27:25:b0": 1012.8,
+ "70:ee:50:27:9f:2c": 1010.6,
+ "70:ee:50:36:94:7c": 1010.6,
+ "70:ee:50:36:a9:fc": 1010,
+ "70:ee:50:3c:02:78": 1011.7,
+ },
+ 1010.35,
+ ),
+ (
+ {
+ "70:ee:50:01:20:fa": 27.4,
+ "70:ee:50:04:ed:7a": 19.8,
+ "70:ee:50:1f:68:9e": 21.1,
+ "70:ee:50:27:25:b0": 23.2,
+ "70:ee:50:27:9f:2c": 25.5,
+ "70:ee:50:36:94:7c": 21.4,
+ "70:ee:50:36:a9:fc": 20.1,
+ "70:ee:50:3c:02:78": 23.3,
+ },
+ 22.725,
+ ),
+ ({}, 0),
+ ],
+)
+def test_public_data_average(test_input, expected):
+ assert pyatmo.public_data.average(test_input) == expected
diff --git a/tests/test_pyatmo_thermostat.py b/tests/test_pyatmo_thermostat.py
new file mode 100644
index 000000000..6c28db9d8
--- /dev/null
+++ b/tests/test_pyatmo_thermostat.py
@@ -0,0 +1,574 @@
+"""Define tests for Thermostat module."""
+# pylint: disable=protected-access
+import json
+
+import pytest
+
+import pyatmo
+
+from tests.conftest import does_not_raise
+
+
+def test_home_data(home_data):
+ expected = {
+ "12:34:56:00:fa:d0": {
+ "id": "12:34:56:00:fa:d0",
+ "type": "NAPlug",
+ "name": "Thermostat",
+ "setup_date": 1494963356,
+ "modules_bridged": [
+ "12:34:56:00:01:ae",
+ "12:34:56:03:a0:ac",
+ "12:34:56:03:a5:54",
+ ],
+ },
+ "12:34:56:00:01:ae": {
+ "id": "12:34:56:00:01:ae",
+ "type": "NATherm1",
+ "name": "Livingroom",
+ "setup_date": 1494963356,
+ "room_id": "2746182631",
+ "bridge": "12:34:56:00:fa:d0",
+ },
+ "12:34:56:03:a5:54": {
+ "id": "12:34:56:03:a5:54",
+ "type": "NRV",
+ "name": "Valve1",
+ "setup_date": 1554549767,
+ "room_id": "2833524037",
+ "bridge": "12:34:56:00:fa:d0",
+ },
+ "12:34:56:03:a0:ac": {
+ "id": "12:34:56:03:a0:ac",
+ "type": "NRV",
+ "name": "Valve2",
+ "setup_date": 1554554444,
+ "room_id": "2940411577",
+ "bridge": "12:34:56:00:fa:d0",
+ },
+ "12:34:56:00:f1:62": {
+ "id": "12:34:56:00:f1:62",
+ "type": "NACamera",
+ "name": "Hall",
+ "setup_date": 1544828430,
+ "room_id": "3688132631",
+ },
+ }
+ assert home_data.modules["91763b24c43d3e344f424e8b"] == expected
+
+
+def test_home_data_no_data(auth, requests_mock):
+ requests_mock.post(pyatmo.thermostat._GETHOMESDATA_REQ, text="None")
+ with pytest.raises(pyatmo.NoDevice):
+ assert pyatmo.HomeData(auth)
+
+
+def test_home_data_no_body(auth, requests_mock):
+ with open("fixtures/home_data_empty.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.thermostat._GETHOMESDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with pytest.raises(pyatmo.NoDevice):
+ assert pyatmo.HomeData(auth)
+
+
+def test_home_data_no_homes(auth, requests_mock):
+ with open("fixtures/home_data_no_homes.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.thermostat._GETHOMESDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with pytest.raises(pyatmo.NoDevice):
+ assert pyatmo.HomeData(auth)
+
+
+def test_home_data_no_home_name(auth, requests_mock):
+ with open("fixtures/home_data_nohomename.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.thermostat._GETHOMESDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ home_data = pyatmo.HomeData(auth)
+ home_id = "91763b24c43d3e344f424e8b"
+ assert home_data.homes.get(home_id)["name"] == "Unknown"
+
+
+@pytest.mark.parametrize(
+ "home_id, expected",
+ [("91763b24c43d3e344f424e8b", "MYHOME"), ("91763b24c43d3e344f424e8c", "Unknown")],
+)
+def test_home_data_homes_by_id(home_data, home_id, expected):
+ assert home_data.homes[home_id]["name"] == expected
+
+
+def test_home_data_get_selected_schedule(home_data):
+ assert (
+ home_data._get_selected_schedule("91763b24c43d3e344f424e8b")["name"]
+ == "Default"
+ )
+ assert home_data._get_selected_schedule("Unknown") == {}
+
+
+@pytest.mark.parametrize(
+ "t_home_id, t_sched_id, expected",
+ [
+ ("91763b24c43d3e344f424e8b", "591b54a2764ff4d50d8b5795", does_not_raise()),
+ (
+ "91763b24c43d3e344f424e8b",
+ "123456789abcdefg12345678",
+ pytest.raises(pyatmo.NoSchedule),
+ ),
+ ],
+)
+def test_home_data_switch_home_schedule(
+ home_data,
+ requests_mock,
+ t_home_id,
+ t_sched_id,
+ expected,
+):
+ with open("fixtures/status_ok.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.thermostat._SWITCHHOMESCHEDULE_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with expected:
+ home_data.switch_home_schedule(home_id=t_home_id, schedule_id=t_sched_id)
+
+
+@pytest.mark.parametrize(
+ "home_id, expected",
+ [("91763b24c43d3e344f424e8b", 14), ("00000000000000000000000", None)],
+)
+def test_home_data_get_away_temp(home_data, home_id, expected):
+ assert home_data.get_away_temp(home_id) == expected
+
+
+@pytest.mark.parametrize(
+ "home_id, expected",
+ [("91763b24c43d3e344f424e8b", 7), ("00000000000000000000000", None)],
+)
+def test_home_data_get_hg_temp(home_data, home_id, expected):
+ assert home_data.get_hg_temp(home_id) == expected
+
+
+@pytest.mark.parametrize(
+ "home_id, module_id, expected",
+ [
+ ("91763b24c43d3e344f424e8b", "2746182631", "NATherm1"),
+ ("91763b24c43d3e344f424e8b", "2833524037", "NRV"),
+ ("91763b24c43d3e344f424e8b", "0000000000", None),
+ ],
+)
+def test_home_data_thermostat_type(home_data, home_id, module_id, expected):
+ assert home_data.get_thermostat_type(home_id, module_id) == expected
+
+
+@pytest.mark.parametrize(
+ "home_id, room_id, expected",
+ [
+ (
+ "91763b24c43d3e344f424e8b",
+ "2746182631",
+ {
+ "id": "2746182631",
+ "reachable": True,
+ "therm_measured_temperature": 19.8,
+ "therm_setpoint_temperature": 12,
+ "therm_setpoint_mode": "away",
+ "therm_setpoint_start_time": 1559229567,
+ "therm_setpoint_end_time": 0,
+ },
+ ),
+ ],
+)
+def test_home_status(home_status, room_id, expected):
+ assert len(home_status.rooms) == 3
+ assert home_status.rooms[room_id] == expected
+
+
+def test_home_status_error_and_data(auth, requests_mock):
+ with open("fixtures/home_status_error_and_data.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.thermostat._GETHOMESTATUS_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ home_status = pyatmo.HomeStatus(auth, home_id="91763b24c43d3e344f424e8b")
+ assert len(home_status.rooms) == 3
+
+ expexted = {
+ "id": "2746182631",
+ "reachable": True,
+ "therm_measured_temperature": 19.8,
+ "therm_setpoint_temperature": 12,
+ "therm_setpoint_mode": "away",
+ "therm_setpoint_start_time": 1559229567,
+ "therm_setpoint_end_time": 0,
+ }
+ assert home_status.rooms["2746182631"] == expexted
+
+
+def test_home_status_error(auth, requests_mock):
+ with open("fixtures/home_status_empty.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.thermostat._GETHOMESTATUS_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with open("fixtures/home_data_simple.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.thermostat._GETHOMESDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with pytest.raises(pyatmo.NoDevice):
+ assert pyatmo.HomeStatus(auth, home_id="91763b24c43d3e344f424e8b")
+
+
+@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"])
+def test_home_status_get_room(home_status):
+ expexted = {
+ "id": "2746182631",
+ "reachable": True,
+ "therm_measured_temperature": 19.8,
+ "therm_setpoint_temperature": 12,
+ "therm_setpoint_mode": "away",
+ "therm_setpoint_start_time": 1559229567,
+ "therm_setpoint_end_time": 0,
+ }
+ assert home_status.get_room("2746182631") == expexted
+ with pytest.raises(pyatmo.InvalidRoom):
+ assert home_status.get_room("0000000000")
+
+
+@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"])
+def test_home_status_get_thermostat(home_status):
+ expexted = {
+ "id": "12:34:56:00:01:ae",
+ "reachable": True,
+ "type": "NATherm1",
+ "firmware_revision": 65,
+ "rf_strength": 58,
+ "battery_level": 3793,
+ "boiler_valve_comfort_boost": False,
+ "boiler_status": False,
+ "anticipating": False,
+ "bridge": "12:34:56:00:fa:d0",
+ "battery_state": "high",
+ }
+ assert home_status.get_thermostat("12:34:56:00:01:ae") == expexted
+ with pytest.raises(pyatmo.InvalidRoom):
+ assert home_status.get_thermostat("00:00:00:00:00:00")
+
+
+@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"])
+def test_home_status_get_relay(home_status):
+ expexted = {
+ "id": "12:34:56:00:fa:d0",
+ "type": "NAPlug",
+ "firmware_revision": 174,
+ "rf_strength": 107,
+ "wifi_strength": 42,
+ }
+ assert home_status.get_relay("12:34:56:00:fa:d0") == expexted
+ with pytest.raises(pyatmo.InvalidRoom):
+ assert home_status.get_relay("00:00:00:00:00:00")
+
+
+@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"])
+def test_home_status_get_valve(home_status):
+ expexted = {
+ "id": "12:34:56:03:a5:54",
+ "reachable": True,
+ "type": "NRV",
+ "firmware_revision": 79,
+ "rf_strength": 51,
+ "battery_level": 3025,
+ "bridge": "12:34:56:00:fa:d0",
+ "battery_state": "full",
+ }
+ assert home_status.get_valve("12:34:56:03:a5:54") == expexted
+ with pytest.raises(pyatmo.InvalidRoom):
+ assert home_status.get_valve("00:00:00:00:00:00")
+
+
+@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"])
+def test_home_status_set_point(home_status):
+ assert home_status.set_point("2746182631") == 12
+ with pytest.raises(pyatmo.InvalidRoom):
+ assert home_status.set_point("0000000000")
+
+
+@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"])
+def test_home_status_set_point_mode(home_status):
+ assert home_status.set_point_mode("2746182631") == "away"
+ with pytest.raises(pyatmo.InvalidRoom):
+ assert home_status.set_point_mode("0000000000")
+
+
+@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"])
+def test_home_status_measured_temperature(home_status):
+ assert home_status.measured_temperature("2746182631") == 19.8
+ with pytest.raises(pyatmo.InvalidRoom):
+ assert home_status.measured_temperature("0000000000")
+
+
+@pytest.mark.parametrize("home_id", ["91763b24c43d3e344f424e8b"])
+def test_home_status_boiler_status(home_status):
+ assert home_status.boiler_status("12:34:56:00:01:ae") is False
+
+
+@pytest.mark.parametrize(
+ "home_id, mode, end_time, schedule_id, json_fixture, expected",
+ [
+ (
+ None,
+ None,
+ None,
+ None,
+ "home_status_error_mode_is_missing.json",
+ "mode is missing",
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ None,
+ None,
+ None,
+ "home_status_error_mode_is_missing.json",
+ "mode is missing",
+ ),
+ (
+ "invalidID",
+ "away",
+ None,
+ None,
+ "home_status_error_invalid_id.json",
+ "Invalid id",
+ ),
+ ("91763b24c43d3e344f424e8b", "away", None, None, "status_ok.json", "ok"),
+ ("91763b24c43d3e344f424e8b", "away", 1559162650, None, "status_ok.json", "ok"),
+ (
+ "91763b24c43d3e344f424e8b",
+ "away",
+ 1559162650,
+ 0000000,
+ "status_ok.json",
+ "ok",
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ "schedule",
+ None,
+ "591b54a2764ff4d50d8b5795",
+ "status_ok.json",
+ "ok",
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ "schedule",
+ 1559162650,
+ "591b54a2764ff4d50d8b5795",
+ "status_ok.json",
+ "ok",
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ "schedule",
+ None,
+ "blahblahblah",
+ "home_status_error_invalid_schedule_id.json",
+ "schedule is not therm schedule",
+ ),
+ ],
+)
+def test_home_status_set_thermmode(
+ home_status,
+ requests_mock,
+ mode,
+ end_time,
+ schedule_id,
+ json_fixture,
+ expected,
+):
+ with open("fixtures/%s" % json_fixture) as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.thermostat._SETTHERMMODE_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ res = home_status.set_thermmode(
+ mode=mode,
+ end_time=end_time,
+ schedule_id=schedule_id,
+ )
+ if "error" in res:
+ assert expected in res["error"]["message"]
+ else:
+ assert expected in res["status"]
+
+
+@pytest.mark.parametrize(
+ "home_id, room_id, mode, temp, end_time, json_fixture, expected",
+ [
+ (
+ "91763b24c43d3e344f424e8b",
+ "2746182631",
+ "home",
+ 14,
+ None,
+ "status_ok.json",
+ "ok",
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ "2746182631",
+ "home",
+ 14,
+ 1559162650,
+ "status_ok.json",
+ "ok",
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ "2746182631",
+ "home",
+ None,
+ None,
+ "status_ok.json",
+ "ok",
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ "2746182631",
+ "home",
+ None,
+ 1559162650,
+ "status_ok.json",
+ "ok",
+ ),
+ ],
+)
+def test_home_status_set_room_thermpoint(
+ home_status,
+ requests_mock,
+ room_id,
+ mode,
+ temp,
+ end_time,
+ json_fixture,
+ expected,
+):
+ with open("fixtures/%s" % json_fixture) as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.thermostat._SETROOMTHERMPOINT_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ assert (
+ home_status.set_room_thermpoint(
+ room_id=room_id,
+ mode=mode,
+ temp=temp,
+ end_time=end_time,
+ )["status"]
+ == expected
+ )
+
+
+@pytest.mark.parametrize(
+ "home_id, room_id, mode, temp, json_fixture, expected",
+ [
+ (
+ None,
+ None,
+ None,
+ None,
+ "home_status_error_missing_home_id.json",
+ "Missing home_id",
+ ),
+ (
+ None,
+ None,
+ "home",
+ None,
+ "home_status_error_missing_home_id.json",
+ "Missing home_id",
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ None,
+ "home",
+ None,
+ "home_status_error_missing_parameters.json",
+ "Missing parameters",
+ ),
+ (
+ "91763b24c43d3e344f424e8b",
+ "2746182631",
+ "home",
+ None,
+ "home_status_error_missing_parameters.json",
+ "Missing parameters",
+ ),
+ ],
+)
+def test_home_status_set_room_thermpoint_error(
+ home_status,
+ requests_mock,
+ room_id,
+ mode,
+ temp,
+ json_fixture,
+ expected,
+):
+ with open("fixtures/%s" % json_fixture) as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.thermostat._SETROOMTHERMPOINT_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ assert (
+ home_status.set_room_thermpoint(room_id=room_id, mode=mode, temp=temp)["error"][
+ "message"
+ ]
+ == expected
+ )
+
+
+def test_home_status_error_disconnected(
+ auth,
+ requests_mock,
+ home_id="91763b24c43d3e344f424e8b",
+):
+ with open("fixtures/home_status_error_disconnected.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.thermostat._GETHOMESTATUS_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with open("fixtures/home_data_simple.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.thermostat._GETHOMESDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with pytest.raises(pyatmo.NoDevice):
+ pyatmo.HomeStatus(auth, home_id)
diff --git a/tests/test_pyatmo_weatherstation.py b/tests/test_pyatmo_weatherstation.py
new file mode 100644
index 000000000..78ac1e830
--- /dev/null
+++ b/tests/test_pyatmo_weatherstation.py
@@ -0,0 +1,518 @@
+"""Define tests for WeatherStation module."""
+# pylint: disable=protected-access
+import json
+
+import pytest
+from freezegun import freeze_time
+
+import pyatmo
+
+
+def test_weather_station_data(weather_station_data):
+ assert (
+ weather_station_data.stations["12:34:56:37:11:ca"]["station_name"]
+ == "MyStation"
+ )
+
+
+def test_weather_station_data_no_response(auth, requests_mock):
+ requests_mock.post(pyatmo.weather_station._GETSTATIONDATA_REQ, text="None")
+ with pytest.raises(pyatmo.NoDevice):
+ assert pyatmo.WeatherStationData(auth)
+
+
+def test_weather_station_data_no_body(auth, requests_mock):
+ with open("fixtures/status_ok.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.weather_station._GETSTATIONDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with pytest.raises(pyatmo.NoDevice):
+ assert pyatmo.WeatherStationData(auth)
+
+
+def test_weather_station_data_no_data(auth, requests_mock):
+ with open("fixtures/home_data_empty.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.weather_station._GETSTATIONDATA_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ with pytest.raises(pyatmo.NoDevice):
+ assert pyatmo.WeatherStationData(auth)
+
+
+@pytest.mark.parametrize(
+ "station_id, expected",
+ [
+ (
+ "12:34:56:37:11:ca",
+ [
+ "Garden",
+ "Kitchen",
+ "Livingroom",
+ "NetatmoIndoor",
+ "NetatmoOutdoor",
+ "Yard",
+ ],
+ ),
+ (
+ "12:34:56:36:fd:3c",
+ ["Module", "NAMain", "Rain Gauge"],
+ ),
+ pytest.param(
+ "NoValidStation",
+ None,
+ marks=pytest.mark.xfail(
+ reason="Invalid station names are not handled yet.",
+ ),
+ ),
+ ],
+)
+def test_weather_station_get_module_names(weather_station_data, station_id, expected):
+ assert sorted(weather_station_data.get_module_names(station_id)) == expected
+
+
+@pytest.mark.parametrize(
+ "station_id, expected",
+ [
+ (
+ None,
+ {},
+ ),
+ (
+ "12:34:56:37:11:ca",
+ {
+ "12:34:56:03:1b:e4": {
+ "id": "12:34:56:03:1b:e4",
+ "module_name": "Garden",
+ "station_name": "MyStation",
+ },
+ "12:34:56:05:51:20": {
+ "id": "12:34:56:05:51:20",
+ "module_name": "Yard",
+ "station_name": "MyStation",
+ },
+ "12:34:56:07:bb:0e": {
+ "id": "12:34:56:07:bb:0e",
+ "module_name": "Livingroom",
+ "station_name": "MyStation",
+ },
+ "12:34:56:07:bb:3e": {
+ "id": "12:34:56:07:bb:3e",
+ "module_name": "Kitchen",
+ "station_name": "MyStation",
+ },
+ "12:34:56:36:fc:de": {
+ "id": "12:34:56:36:fc:de",
+ "module_name": "NetatmoOutdoor",
+ "station_name": "MyStation",
+ },
+ "12:34:56:37:11:ca": {
+ "id": "12:34:56:37:11:ca",
+ "module_name": "NetatmoIndoor",
+ "station_name": "MyStation",
+ },
+ },
+ ),
+ (
+ "12:34:56:1d:68:2e",
+ {
+ "12:34:56:1d:68:2e": {
+ "id": "12:34:56:1d:68:2e",
+ "module_name": "Basisstation",
+ "station_name": "NAMain",
+ },
+ },
+ ),
+ (
+ "12:34:56:58:c8:54",
+ {
+ "12:34:56:58:c8:54": {
+ "id": "12:34:56:58:c8:54",
+ "module_name": "NAMain",
+ "station_name": "Njurunda (Indoor)",
+ },
+ "12:34:56:58:e6:38": {
+ "id": "12:34:56:58:e6:38",
+ "module_name": "NAModule1",
+ "station_name": "Njurunda (Indoor)",
+ },
+ },
+ ),
+ pytest.param(
+ "NoValidStation",
+ None,
+ marks=pytest.mark.xfail(
+ reason="Invalid station names are not handled yet.",
+ ),
+ ),
+ ],
+)
+def test_weather_station_get_modules(weather_station_data, station_id, expected):
+ assert weather_station_data.get_modules(station_id) == expected
+
+
+def test_weather_station_get_station(weather_station_data):
+ result = weather_station_data.get_station("12:34:56:37:11:ca")
+
+ assert result["_id"] == "12:34:56:37:11:ca"
+ assert result["station_name"] == "MyStation"
+ assert result["module_name"] == "NetatmoIndoor"
+ assert result["type"] == "NAMain"
+ assert result["data_type"] == [
+ "Temperature",
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure",
+ ]
+
+ assert weather_station_data.get_station("NoValidStation") == {}
+
+
+@pytest.mark.parametrize(
+ "mid, expected",
+ [
+ ("12:34:56:07:bb:3e", "12:34:56:07:bb:3e"),
+ ("12:34:56:07:bb:3e", "12:34:56:07:bb:3e"),
+ ("", {}),
+ (None, {}),
+ ],
+)
+def test_weather_station_get_module(weather_station_data, mid, expected):
+ mod = weather_station_data.get_module(mid)
+
+ assert isinstance(mod, dict) is True
+ assert mod.get("_id", mod) == expected
+
+
+@pytest.mark.parametrize(
+ "module_id, expected",
+ [
+ (
+ "12:34:56:07:bb:3e",
+ [
+ "CO2",
+ "Humidity",
+ "Temperature",
+ "battery_percent",
+ "battery_vp",
+ "reachable",
+ "rf_status",
+ "temp_trend",
+ ],
+ ),
+ (
+ "12:34:56:07:bb:3e",
+ [
+ "CO2",
+ "Humidity",
+ "Temperature",
+ "battery_percent",
+ "battery_vp",
+ "reachable",
+ "rf_status",
+ "temp_trend",
+ ],
+ ),
+ (
+ "12:34:56:03:1b:e4",
+ [
+ "GustAngle",
+ "GustStrength",
+ "WindAngle",
+ "WindStrength",
+ "battery_percent",
+ "battery_vp",
+ "reachable",
+ "rf_status",
+ ],
+ ),
+ (
+ "12:34:56:05:51:20",
+ [
+ "Rain",
+ "battery_percent",
+ "battery_vp",
+ "reachable",
+ "rf_status",
+ "sum_rain_1",
+ "sum_rain_24",
+ ],
+ ),
+ (
+ "12:34:56:37:11:ca",
+ [
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure",
+ "Temperature",
+ "pressure_trend",
+ "reachable",
+ "temp_trend",
+ "wifi_status",
+ ],
+ ),
+ (
+ "12:34:56:58:c8:54",
+ [
+ "CO2",
+ "Humidity",
+ "Noise",
+ "Pressure",
+ "Temperature",
+ "pressure_trend",
+ "reachable",
+ "temp_trend",
+ "wifi_status",
+ ],
+ ),
+ (
+ "12:34:56:58:e6:38",
+ [
+ "Humidity",
+ "Temperature",
+ "battery_percent",
+ "battery_vp",
+ "reachable",
+ "rf_status",
+ "temp_trend",
+ ],
+ ),
+ pytest.param(
+ None,
+ None,
+ marks=pytest.mark.xfail(reason="Invalid module names are not handled yet."),
+ ),
+ ],
+)
+def test_weather_station_get_monitored_conditions(
+ weather_station_data,
+ module_id,
+ expected,
+):
+ assert sorted(weather_station_data.get_monitored_conditions(module_id)) == expected
+
+
+@freeze_time("2019-06-11")
+@pytest.mark.parametrize(
+ "station_id, exclude, expected",
+ [
+ (
+ "12:34:56:05:51:20",
+ None,
+ {},
+ ),
+ (
+ "12:34:56:37:11:ca",
+ None,
+ [
+ "12:34:56:03:1b:e4",
+ "12:34:56:05:51:20",
+ "12:34:56:07:bb:0e",
+ "12:34:56:07:bb:3e",
+ "12:34:56:36:fc:de",
+ "12:34:56:37:11:ca",
+ ],
+ ),
+ ("", None, {}),
+ ("NoValidStation", None, {}),
+ (
+ "12:34:56:37:11:ca",
+ 1000000,
+ [
+ "12:34:56:03:1b:e4",
+ "12:34:56:05:51:20",
+ "12:34:56:07:bb:0e",
+ "12:34:56:07:bb:3e",
+ "12:34:56:36:fc:de",
+ "12:34:56:37:11:ca",
+ ],
+ ),
+ (
+ "12:34:56:37:11:ca",
+ 798103,
+ [
+ "12:34:56:03:1b:e4",
+ "12:34:56:05:51:20",
+ "12:34:56:07:bb:3e",
+ "12:34:56:36:fc:de",
+ "12:34:56:37:11:ca",
+ ],
+ ),
+ ],
+)
+def test_weather_station_get_last_data(
+ weather_station_data,
+ station_id,
+ exclude,
+ expected,
+):
+ mod = weather_station_data.get_last_data(station_id, exclude=exclude)
+ if mod:
+ assert sorted(mod) == expected
+ else:
+ assert mod == expected
+
+
+@freeze_time("2019-06-11")
+@pytest.mark.parametrize(
+ "station_id, delay, expected",
+ [
+ (
+ "12:34:56:37:11:ca",
+ 3600,
+ [
+ "12:34:56:03:1b:e4",
+ "12:34:56:05:51:20",
+ "12:34:56:07:bb:0e",
+ "12:34:56:07:bb:3e",
+ "12:34:56:36:fc:de",
+ "12:34:56:37:11:ca",
+ ],
+ ),
+ (
+ "12:34:56:37:11:ca",
+ 798500,
+ [],
+ ),
+ pytest.param(
+ "NoValidStation",
+ 3600,
+ None,
+ marks=pytest.mark.xfail(reason="Invalid station name not handled yet"),
+ ),
+ ],
+)
+def test_weather_station_check_not_updated(
+ weather_station_data,
+ station_id,
+ delay,
+ expected,
+):
+ mod = weather_station_data.check_not_updated(station_id, delay)
+ assert sorted(mod) == expected
+
+
+@freeze_time("2019-06-11")
+@pytest.mark.parametrize(
+ "station_id, delay, expected",
+ [
+ (
+ "12:34:56:37:11:ca",
+ 798500,
+ [
+ "12:34:56:03:1b:e4",
+ "12:34:56:05:51:20",
+ "12:34:56:07:bb:0e",
+ "12:34:56:07:bb:3e",
+ "12:34:56:36:fc:de",
+ "12:34:56:37:11:ca",
+ ],
+ ),
+ (
+ "12:34:56:37:11:ca",
+ 100,
+ [],
+ ),
+ ],
+)
+def test_weather_station_check_updated(
+ weather_station_data,
+ station_id,
+ delay,
+ expected,
+):
+ mod = weather_station_data.check_updated(station_id, delay)
+ if mod:
+ assert sorted(mod) == expected
+ else:
+ assert mod == expected
+
+
+@freeze_time("2019-06-11")
+@pytest.mark.parametrize(
+ "device_id, scale, module_type, expected",
+ [("MyStation", "scale", "type", [28.1])],
+)
+def test_weather_station_get_data(
+ weather_station_data,
+ requests_mock,
+ device_id,
+ scale,
+ module_type,
+ expected,
+):
+ with open("fixtures/weatherstation_measure.json") as json_file:
+ json_fixture = json.load(json_file)
+ requests_mock.post(
+ pyatmo.weather_station._GETMEASURE_REQ,
+ json=json_fixture,
+ headers={"content-type": "application/json"},
+ )
+ assert (
+ weather_station_data.get_data(device_id, scale, module_type)["body"][
+ "1544558433"
+ ]
+ == expected
+ )
+
+
+def test_weather_station_get_last_data_measurements(weather_station_data):
+ station_id = "12:34:56:37:11:ca"
+ module_id = "12:34:56:03:1b:e4"
+
+ mod = weather_station_data.get_last_data(station_id, None)
+
+ assert mod[station_id]["Temperature"] == 24.6
+ assert mod[station_id]["Pressure"] == 1017.3
+ assert mod[module_id]["WindAngle"] == 217
+ assert mod[module_id]["WindStrength"] == 4
+ assert mod[module_id]["GustAngle"] == 206
+ assert mod[module_id]["GustStrength"] == 9
+
+
+@freeze_time("2019-06-11")
+@pytest.mark.parametrize(
+ "station_id, exclude, expected",
+ [
+ (
+ "12:34:56:37:11:ca",
+ None,
+ [
+ "12:34:56:03:1b:e4",
+ "12:34:56:05:51:20",
+ "12:34:56:07:bb:0e",
+ "12:34:56:07:bb:3e",
+ "12:34:56:36:fc:de",
+ "12:34:56:37:11:ca",
+ ],
+ ),
+ (
+ None,
+ None,
+ {},
+ ),
+ (
+ "12:34:56:00:aa:01",
+ None,
+ {},
+ ),
+ ],
+)
+def test_weather_station_get_last_data_bug_97(
+ weather_station_data,
+ station_id,
+ exclude,
+ expected,
+):
+ mod = weather_station_data.get_last_data(station_id, exclude)
+ if mod:
+ assert sorted(mod) == expected
+ else:
+ assert mod == expected
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 000000000..b0ee38daa
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,35 @@
+[tox]
+envlist = py37,py38,pypy3
+isolated_build = True
+skip_missing_interpreters = True
+
+[gh-actions]
+python =
+ 3.6: py36
+ 3.7: py37
+ 3.8: py38
+
+[testenv]
+deps =
+ pytest
+ pytest-cov
+ pytest-mock
+ requests-mock
+ freezegun
+
+commands =
+ python -m pytest --cov {envsitepackagesdir}/pyatmo
+
+[coverage:paths]
+source =
+ pyatmo
+ .tox/*/lib/python*/site-packages/pyatmo
+
+[coverage:run]
+branch = true
+source =
+ pyatmo
+
+[coverage:report]
+show_missing = true
+precision = 2
diff --git a/usage.md b/usage.md
index 4ee337c8c..92d8a2178 100644
--- a/usage.md
+++ b/usage.md
@@ -12,9 +12,7 @@ Python Netatmo API programmers guide
>2016-12-09 Update documentation for all Netatmo cameras
-No additional library other than standard Python library is required.
-
-Both Python V2.7x and V3.x.x are supported without change.
+No additional library other than standard Python 3 library is required.
More information about the Netatmo REST API can be obtained from http://dev.netatmo.com/doc/
@@ -40,28 +38,19 @@ In the netatmo philosophy, both the application itself and the user have to be r
-Copy the lnetatmo.py file in your work directory (or your platform choice of user libraries or virtualenv or ...).
-
-To ease future uses, I suggest that you hardcode in the library your application and user credentials. This is not mandatory as this parameters can be explicitly passed at authentication phase but will save you parameters each time you write a new tool.
-
-If you want to do it, just edit the source file and hard code required values for :
+Install `pyatmo` as described in the `README.md`.
-
-```python
-_CLIENT_ID = ""
-_CLIENT_SECRET = ""
-_USERNAME = ""
-_PASSWORD = ""
-```
-
-
-If you provide all the values, you can test that everything is working properly by simply running the package as a standalone program.
+If you provide your credentials, you can test if everything is working properly by simply running the package as a standalone program.
This will run a full access test to the account and stations and return 0 as return code if everything works well. If run interactively, it will also display an OK message.
```bash
-$ python3 lnetatmo.py # or python2 as well
-lnetatmo.py : OK
+$ export CLIENT_ID=""
+$ export CLIENT_SECRET=""
+$ export USERNAME=""
+$ export PASSWORD=""
+$ python3 pyatmo.py
+pyatmo.py : OK
$ echo $?
0
```
@@ -82,31 +71,40 @@ Most of the time, the sequence of operations will be :
Example :
```python
-#!/usr/bin/python3
-# encoding=utf-8
-
-import lnetatmo
+import pyatmo
# 1 : Authenticate
-authorization = lnetatmo.ClientAuth()
+CLIENT_ID = '123456789abcd1234'
+CLIENT_SECRET = '123456789abcd1234'
+USERNAME = 'your@account.com'
+PASSWORD = 'abcdef-123456-ghijkl'
+authorization = pyatmo.ClientAuth(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_SECRET,
+ username=USERNAME,
+ password=PASSWORD,
+)
# 2 : Get devices list
-weatherData = lnetatmo.WeatherStationData(authorization)
+weatherData = pyatmo.WeatherStationData(authorization)
# 3 : Access most fresh data directly
-print ("Current temperature (inside/outside): %s / %s °C" %
- ( weatherData.lastData()['indoor']['Temperature'],
- weatherData.lastData()['outdoor']['Temperature'])
+print(
+ "Current temperature (inside/outside): %s / %s °C"
+ % (
+ weatherData.last_data()["indoor"]["Temperature"],
+ weatherData.last_data()["outdoor"]["Temperature"],
+ )
)
```
-In this example, no init parameters are supplied to ClientAuth, the library is supposed to have been customized with the required values (see §2). The user must have named the sensors indoor and outdoor through the Web interface (or any other name as long as the program is requesting the same name).
+The user must have named the sensors indoor and outdoor through the Web interface (or any other name as long as the program is requesting the same name).
The Netatmo design is based on stations (usually the in-house module) and modules (radio sensors reporting to a station, usually an outdoor sensor).
Sensor design is not exactly the same for station and external modules and they are not addressed the same way wether in the station or an external module. This is a design issue of the API that restrict the ability to write generic code that could work for station sensor the same way than other modules sensors. The station role (the reporting device) and module role (getting environmental data) should not have been mixed. The fact that a sensor is physically built in the station should not interfere with this two distincts objects.
-The consequence is that, for the API, we will use terms of station data (for the sensors inside the station) and module data (for external(s) module). Lookup methods like moduleByName look for external modules and **NOT station
+The consequence is that, for the API, we will use terms of station data (for the sensors inside the station) and module data (for external(s) module). Lookup methods like module_by_name look for external modules and **NOT station
modules**.
Having two roles, the station has a 'station_name' property as well as a 'module_name' for its internal sensor.
@@ -129,15 +127,10 @@ The results are Python data structures, mostly dictionaries as they mirror easil
```python
-_CLIENT_ID, _CLIENT_SECRET = Application ID and secret provided by Netatmo
-application registration in your user account
-
-_USERNAME, _PASSWORD : Username and password of your netatmo account
-
_BASE_URL and _*_REQ : Various URL to access Netatmo web services. They are
documented in http://dev.netatmo.com/doc/ They should not be changed unless
Netatmo API changes.
-```
+```
@@ -148,16 +141,17 @@ Netatmo API changes.
Constructor
```python
- authorization = lnetatmo.ClientAuth( clientId = _CLIENT_ID,
- clientSecret = _CLIENT_SECRET,
- username = _USERNAME,
- password = _PASSWORD,
- scope = "read_station"
- )
+authorization = pyatmo.ClientAuth(
+ client_id=CLIENT_ID,
+ client_secret=CLIENT_SECRET,
+ username=USERNAME,
+ password=PASSWORD,
+ scope="read_station",
+)
```
-Requires : Application and User credentials to access Netatmo API. if all this parameters are put in global variables they are not required (in library source code or in the main program through lnetatmo._CLIENT_ID = …)
+Requires : Application and User credentials to access Netatmo API.
Return : an authorization object that will supply the access token required by other web services. This class will handle the renewal of the access token if expiration is reached.
@@ -183,43 +177,14 @@ Several value can be used at the same time, ie: 'read_station read_camera'
-#### 4-3 User class ####
-
-
-
-Constructor
-
-```python
- user = lnetatmo.User( authorization )
-```
-
-
-Requires : an authorization object (ClientAuth instance)
-
-
-Return : a User object. This object provides multiple informations on the user account such as the mail address of the user, the preferred language, …
-
-
-Properties, all properties are read-only unless specified :
-
-
- * **rawData** : Full dictionary of the returned JSON GETUSER Netatmo API service
- * **ownerMail** : eMail address associated to the user account
- * **devList** : List of Station's id accessible to the user account
-
-
-In most cases, you will not need to use this class that is oriented toward an application that would use the other authentication method to an unknown user and then get information about him.
-
-
-
-#### 4-4 WeatherStationData class ####
+#### 4-3 WeatherStationData class ####
Constructor
```python
- weatherData = lnetatmo.WeatherStationData( authorization )
+weatherData = pyatmo.WeatherStationData(authorization)
```
@@ -228,7 +193,7 @@ Requires : an authorization object (ClientAuth instance)
Return : a WeatherStationData object. This object contains most administration properties of stations and modules accessible to the user and the last data pushed by the station to the Netatmo servers.
-Raise a lnetatmo.NoDevice exception if no weather station is available for the given account.
+Raise a pyatmo.NoDevice exception if no weather station is available for the given account.
Properties, all properties are read-only unless specified:
@@ -242,27 +207,27 @@ Properties, all properties are read-only unless specified:
Methods :
- * **stationByName** (station=None) : Find a station by it's station name
+ * **station_by_name** (station=None) : Find a station by it's station name
* Input : Station name to lookup (str)
* Output : station dictionary or None
- * **stationById** (sid) : Find a station by it's Netatmo ID (mac address)
+ * **station_by_id** (sid) : Find a station by it's Netatmo ID (mac address)
* Input : Station ID
* Output : station dictionary or None
- * **moduleByName** (module, station=None) : Find a module by it's module name
+ * **module_by_name** (module, station=None) : Find a module by it's module name
* Input : module name and optional station name
* Output : module dictionary or None
The station name parameter, if provided, is used to check wether the module belongs to the appropriate station (in case multiple stations would have same module name).
- * **moduleById** (mid, sid=None) : Find a module by it's ID and belonging station's ID
+ * **module_by_id** (mid, sid=None) : Find a module by it's ID and belonging station's ID
* Input : module ID and optional Station ID
* Output : module dictionary or None
- * **modulesNamesList** (station=None) : Get the list of modules names, including the station module name. Each of them should have a corresponding entry in lastData. It is an equivalent (at lower cost) for lastData.keys()
+ * **modules_names_list** (station=None) : Get the list of modules names, including the station module name. Each of them should have a corresponding entry in last_data. It is an equivalent (at lower cost) for last_data.keys()
- * **lastData** (station=None, exclude=0) : Get the last data uploaded by the station, exclude sensors with measurement older than given value (default return all)
+ * **last_data** (station=None, exclude=0) : Get the last data uploaded by the station, exclude sensors with measurement older than given value (default return all)
* Input : station name OR id. If not provided default_station is used. Exclude is the delay in seconds from now to filter sensor readings.
* Output : Sensors data dictionary (Key is sensor name)
@@ -278,37 +243,37 @@ Methods :
```python
# Last data access example
-theData = weatherData.lastData()
-print('Available modules : ', theData.keys() )
-print('In-house CO2 level : ', theData['indoor']['Co2'] )
-print('Outside temperature : ', theData['outdoor']['Temperature'] )
+theData = weatherData.last_data()
+print('Available modules : ', theData.keys())
+print('In-house CO2 level : ', theData['indoor']['Co2'])
+print('Outside temperature : ', theData['outdoor']['Temperature'])
print('External module battery : ', "OK" if int(theData['outdoor']['battery_vp']) > 5000 \
- else "NEEDS TO BE REPLACED" )
+ else "NEEDS TO BE REPLACED")
```
- * **checkNotUpdated** (station=None, delay=3600) :
+ * **check_not_updated** (station=None, delay=3600) :
* Input : optional station name (else default_station is used)
- * Output : list of modules name for which last data update is older than specified delay (default 1 hour). If the station itself is lost, the module_name of the station will be returned (the key item of lastData information).
+ * Output : list of modules name for which last data update is older than specified delay (default 1 hour). If the station itself is lost, the module_name of the station will be returned (the key item of last_data information).
For example (following the previous one)
```python
# Ensure data sanity
-for m in weatherData.checkNotUpdated(""):
+for m in weatherData.check_not_updated(""):
print("Warning, sensor %s information is obsolete" % m)
- if moduleByName(m) == None : # Sensor is not an external module
+ if module_by_name(m) == None : # Sensor is not an external module
print("The station is lost")
```
- * **checkUpdated** (station=None, delay=3600) :
+ * **check_updated** (station=None, delay=3600) :
* Input : optional station name (else default_station is used)
* Output : list of modules name for which last data update is newer than specified delay (default 1 hour).
Complement of the previous service
- * **getMeasure** (device_id, scale, mtype, module_id=None, date_begin=None, date_end=None, limit=None, optimize=False) :
+ * **get_measure** (device_id, scale, mtype, module_id=None, date_begin=None, date_end=None, limit=None, optimize=False) :
* Input : All parameters specified in the Netatmo API service GETMEASURE (type being a python reserved word as been replaced by mtype).
* Output : A python dictionary reflecting the full service response. No transformation is applied.
- * **MinMaxTH** (station=None, module=None, frame="last24") : Return min and max temperature and humidity for the given station/module in the given timeframe
+ * **min_max_th** (station=None, module=None, frame="last24") : Return min and max temperature and humidity for the given station/module in the given timeframe
* Input :
* An optional station Name or ID, default_station is used if not supplied,
* An optional module name or ID, default : station sensor data is used
@@ -322,14 +287,14 @@ for m in weatherData.checkNotUpdated(""):
at all if you slip over two days as required in a shifting 24 hours window.
-#### 4-5 CameraData class ####
+#### 4-4 CameraData class ####
Constructor
```python
- cameraData = lnetatmo.CameraData( authorization )
+cameraData = pyatmo.CameraData( authorization )
```
@@ -338,7 +303,7 @@ Requires : an authorization object (ClientAuth instance)
Return : a CameraData object. This object contains most administration properties of Netatmo cameras accessible to the user and the last data pushed by the cameras to the Netatmo servers.
-Raise a lnetatmo.NoDevice exception if no camera is available for the given account.
+Raise a pyatmo.NoDevice exception if no camera is available for the given account.
Properties, all properties are read-only unless specified:
@@ -355,54 +320,54 @@ Properties, all properties are read-only unless specified:
Methods :
- * **homeById** (hid) : Find a home by its Netatmo ID
+ * **home_by_id** (hid) : Find a home by its Netatmo ID
* Input : Home ID
* Output : home dictionary or None
- * **homeByName** (home=None) : Find a home by it's home name
+ * **home_by_name** (home=None) : Find a home by it's home name
* Input : home name to lookup (str)
* Output : home dictionary or None
- * **cameraById** (hid) : Find a camera by its Netatmo ID
+ * **camera_by_id** (hid) : Find a camera by its Netatmo ID
* Input : camera ID
* Output : camera dictionary or None
- * **cameraByName** (camera=None, home=None) : Find a camera by it's camera name
+ * **camera_by_name** (camera=None, home=None) : Find a camera by it's camera name
* Input : camera name and home name to lookup (str)
* Output : camera dictionary or None
- * **cameraType** (camera=None, home=None, cid=None) : Return the type of a given camera.
+ * **camera_type** (camera=None, home=None, cid=None) : Return the type of a given camera.
* Input : camera name and home name or cameraID to lookup (str)
* Output : Return the type of a given camera
- * **cameraUrls** (camera=None, home=None, cid=None) : return Urls to access camera live feed
+ * **camera_urls_by_name** (camera=None, home=None, cid=None) : return Urls to access camera live feed
* Input : camera name and home name or cameraID to lookup (str)
* Output : tuple with the vpn_url (for remote access) and local url to access the camera live feed
- * **personsAtHome** (home=None) : return the list of known persons who are at home
+ * **persons_at_home_by_name** (home=None) : return the list of known persons who are at home
* Input : home name to lookup (str)
* Output : list of persons seen
- * **getCameraPicture** (image_id, key): Download a specific image (of an event or user face) from the camera
+ * **get_camera_picture** (image_id, key): Download a specific image (of an event or user face) from the camera
* Input : image_id and key of an events or person face
* Output: Tuple with image data (to be stored in a file) and image type (jpg, png...)
- * **getProfileImage** (name) : Retrieve the face of a given person
+ * **get_profile_image** (name) : Retrieve the face of a given person
* Input : person name (str)
- * Output: **getCameraPicture** data
+ * Output: **get_camera_picture** data
- * **updateEvent** (event=None, home=None, cameratype=None): Update the list of events
+ * **update_event** (event=None, home=None, cameratype=None): Update the list of events
* Input: Id of the latest event, home name and cameratype to update event list
- * **personSeenByCamera** (name, home=None, camera=None): Return true is a specific person has been seen by the camera in the last event
+ * **person_seen_by_camera** (name, home=None, camera=None): Return true is a specific person has been seen by the camera in the last event
- * **someoneKnownSeen** (home=None, camera=None) : Return true is a known person has been in the last event
+ * **someone_known_seen** (home=None, camera=None) : Return true is a known person has been in the last event
- * **someoneUnknownSeen** (home=None, camera=None) : Return true is an unknown person has been seen in the last event
+ * **someone_unknown_seen** (home=None, camera=None) : Return true is an unknown person has been seen in the last event
- * **motionDetected** (home=None, camera=None) : Return true is a movement has been detected in the last event
+ * **motion_detected** (home=None, camera=None) : Return true is a movement has been detected in the last event
- * **outdoormotionDetected** (home=None, camera=None) : Return true is a outdoor movement has been detected in the last event
+ * **outdoormotion_detected** (home=None, camera=None) : Return true is a outdoor movement has been detected in the last event
* **humanDetected** (home=None, camera=None) : Return True if a human has been detected in the last outdoor events
@@ -410,14 +375,16 @@ Methods :
* **carDetected** (home=None, camera=None) : Return True if a car has been detected in the last outdoor events
- #### 4-6 ThermostatData class ####
+
+
+ #### 4-5 ThermostatData class ####
Constructor
```python
- thermostatData = lnetatmo.ThermostatData( authorization )
+ thermostatData = pyatmo.ThermostatData(authorization)
```
@@ -426,7 +393,7 @@ Methods :
Return : a ThermostatData object. This object contains most administration properties of Netatmo thermostats accessible to the user and the last data pushed by the thermostats to the Netatmo servers.
- Raise a lnetatmo.NoDevice exception if no thermostat is available for the given account.
+ Raise a pyatmo.NoDevice exception if no thermostat is available for the given account.
Properties, all properties are read-only unless specified:
@@ -452,11 +419,11 @@ Methods :
* Input : device name to lookup (str)
* Output : device dictionary or None
- * **moduleById** (hid) : Find a module by its Netatmo ID
+ * **module_by_id** (hid) : Find a module by its Netatmo ID
* Input : module ID
* Output : module dictionary or None
- * **moduleByName** (module=None, device=None) : Find a module by it's module name
+ * **module_by_name** (module=None, device=None) : Find a module by it's module name
* Input : module name and device name to lookup (str)
* Output : module dictionary or None
@@ -464,28 +431,11 @@ Methods :
* Input : device_id and module_id and setpoint_mode
-#### 4-7 Utilities functions ####
-
- * **toTimeString** (timestamp) : Convert a Netatmo time stamp to a readable date/time format.
- * **toEpoch**( dateString) : Convert a date string (form YYYY-MM-DD_HH:MM:SS) to timestamp
- * **todayStamps**() : Return a couple of epoch time (start, end) for the current day
+#### 4-6 Utilities functions ####
-#### 4-8 All-in-One function ####
-
-If you just need the current temperature and humidity reported by a sensor with associated min and max values on the last 24 hours, you can get it all with only one call that handle all required steps including authentication :
-
-
-**getStationMinMaxTH**(station=None, module=None) :
- * Input : optional station name and/or module name (if no station name is provided, default_station will be used, if no module name is provided, station sensor will be reported).
- * Output : A tuple of 6 values (Temperature, Humidity, minT, MaxT, minH, maxH)
-
-```python
->>> import lnetatmo
->>> print(lnetatmo.getStationMinMaxTH())
-[20, 33, 18.1, 20, 30, 34]
->>>
->>> print(lnetatmo.getStationMinMaxTH(module='outdoor'))
-[2, 53, 1.2, 5.4, 51, 74]
+ * **to_time_string** (timestamp) : Convert a Netatmo time stamp to a readable date/time format.
+ * **to_epoch**( dateString) : Convert a date string (form YYYY-MM-DD_HH:MM:SS) to timestamp
+ * **today_stamps**() : Return a couple of epoch time (start, end) for the current day