diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..22f6e17
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,29 @@
+---
+name: Bug Report
+about: '"Something''s wrong..."'
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+## Description
+*A clear description of the bug*
+
+
+
+
+## Expected Behavior
+*What did you expect to happen instead?*
+
+
+
+
+## Reproduction
+*A minimal example that exhibits the behavior.*
+
+
+
+
+## Environment
+*Any additional information about your environment*
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..3b04215
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,23 @@
+---
+name: Feature Request
+about: '"It would be really cool if x did y..."'
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+## Use Case
+*Please provide a use case to help us understand your request in context*
+
+
+
+
+## Solution
+*Please describe your ideal solution*
+
+
+
+
+## Alternatives
+*Please describe any alternatives you've considered, even if you've dismissed them*
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..3467dd4
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,8 @@
+**Pull request recommendations:**
+- [ ] Name your pull request _your-development-type/short-description_. Ex: _feature/read-tiff-files_
+- [ ] Link to any relevant issue in the PR description. Ex: _Resolves [gh-12], adds tiff file format support_
+- [ ] Provide context of changes.
+- [ ] Provide relevant tests for your feature or bug fix.
+- [ ] Provide or update documentation for any feature added by your pull request.
+
+Thanks for contributing!
diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml
new file mode 100644
index 0000000..f7932bf
--- /dev/null
+++ b/.github/workflows/build-docs.yml
@@ -0,0 +1,34 @@
+name: Documentation
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ docs:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2.3.1
+ with:
+ persist-credentials: false
+ - name: Set up Python
+ uses: actions/setup-python@v1
+ with:
+ python-version: 3.9
+ - name: Install Dependencies
+ run: |
+ pip install --upgrade pip
+ pip install .[dev]
+ - name: Generate Docs
+ run: |
+ make gen-docs
+ touch docs/_build/html/.nojekyll
+ - name: Publish Docs
+ uses: JamesIves/github-pages-deploy-action@3.7.1
+ with:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ BASE_BRANCH: main # The branch the action should deploy from.
+ BRANCH: gh-pages # The branch the action should deploy to.
+ FOLDER: docs/_build/html/ # The folder the action should deploy.
+
diff --git a/.github/workflows/build-main.yml b/.github/workflows/build-main.yml
new file mode 100644
index 0000000..fb42f29
--- /dev/null
+++ b/.github/workflows/build-main.yml
@@ -0,0 +1,74 @@
+name: Build Main
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ test:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ python-version: [3.8, 3.9]
+ os: [ubuntu-latest, windows-latest, macOS-latest]
+
+ steps:
+ - uses: actions/checkout@v1
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v1
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install Dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install .[test]
+ - name: Test with pytest
+ run: |
+ pytest --cov-report xml --cov=envoy_reader tests/
+ - name: Upload codecov
+ uses: codecov/codecov-action@v1
+
+ lint:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v1
+ - name: Set up Python
+ uses: actions/setup-python@v1
+ with:
+ python-version: 3.9
+ - name: Install Dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install .[test]
+ - name: Lint with flake8
+ run: |
+ flake8 envoy_reader --count --verbose --show-source --statistics
+ - name: Check with black
+ run: |
+ black --check envoy_reader
+
+ publish:
+ if: "contains(github.event.head_commit.message, 'Bump version')"
+ needs: [test, lint]
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v1
+ - name: Set up Python
+ uses: actions/setup-python@v1
+ with:
+ python-version: 3.9
+ - name: Install Dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install setuptools wheel
+ - name: Build Package
+ run: |
+ python setup.py sdist bdist_wheel
+ - name: Publish to PyPI
+ uses: pypa/gh-action-pypi-publish@master
+ with:
+ user: bdraco
+ password: ${{ secrets.PYPI_TOKEN }}
diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml
new file mode 100644
index 0000000..c15c643
--- /dev/null
+++ b/.github/workflows/pythonapp.yml
@@ -0,0 +1,33 @@
+name: Python application
+
+on:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v1
+ - name: Set up Python 3.7
+ uses: actions/setup-python@v1
+ with:
+ python-version: 3.7
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+ - name: Lint with flake8
+ run: |
+ pip install flake8
+ # 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.
+ flake8 . --count --exit-zero --max-complexity=10 --statistics
+# - name: Test with pytest
+# run: |
+# pip install pytest
+# pytest
diff --git a/.github/workflows/test-and-lint.yml b/.github/workflows/test-and-lint.yml
new file mode 100644
index 0000000..d772dc7
--- /dev/null
+++ b/.github/workflows/test-and-lint.yml
@@ -0,0 +1,47 @@
+name: Test and Lint
+
+on: pull_request
+
+jobs:
+ test:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ python-version: [3.8, 3.9]
+ os: [ubuntu-latest, windows-latest, macOS-latest]
+
+ steps:
+ - uses: actions/checkout@v1
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v1
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install Dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install .[test]
+ - name: Test with pytest
+ run: |
+ pytest tests/
+ - name: Upload codecov
+ uses: codecov/codecov-action@v1
+
+ lint:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v1
+ - name: Set up Python
+ uses: actions/setup-python@v1
+ with:
+ python-version: 3.9
+ - name: Install Dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install .[test]
+ - name: Lint with flake8
+ run: |
+ flake8 envoy_reader --count --verbose --show-source --statistics
+ - name: Check with black
+ run: |
+ black --check envoy_reader
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..181282c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,135 @@
+# Created by https://www.gitignore.io/api/python
+
+### 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/
+*.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
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+### Python Patch ###
+.venv/
+
+### Python.VirtualEnv Stack ###
+# Virtualenv
+# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
+[Bb]in
+[Ii]nclude
+[Ll]ib
+[Ll]ib64
+[Ll]ocal
+[Ss]cripts
+pyvenv.cfg
+pip-selfcheck.json
+
+
+.vscode
+
+
+# End of https://www.gitignore.io/api/python
\ No newline at end of file
diff --git a/README.md b/README.md
index 5f28d67..e92e80b 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,13 @@
-A program to read from an Enphase Envoy on the local network. Reads electricity consumed and produced instantaneously, that day, the last seven days, and the lifetime of the envoy.
+[](https://github.com/jesserizzo/envoy_reader/actions/workflows/build-main.yml)
+[](https://img.shields.io/github/v/release/jesserizzo/envoy_reader)
-Only tested on the Envoy S
+A program to read from an Enphase Envoy on the local network. Reads electricity production and consumption (if available) for the current moment, current day, the last seven days, and the lifetime of the Envoy.
+Also reads production from individual inverters if supported.
+
+Tested on the original Envoy (production data only) and the Envoy S (production and consumption data).
+
+This reader uses a JSON endpoint on the Envoy gateway:
+- Original Envoy: http://envoy/api/v1/production (available on software level R3.9 and later)
+- Envoy S: http://envoy/production.json
+
+For older Envoy with software before R3.9, data is collected from html at http://envoy/production
diff --git a/envoy_reader/__init__.py b/envoy_reader/__init__.py
index 6e413ce..e69de29 100644
--- a/envoy_reader/__init__.py
+++ b/envoy_reader/__init__.py
@@ -1,8 +0,0 @@
-from envoy_reader.envoy_reader import production
-from envoy_reader.envoy_reader import consumption
-from envoy_reader.envoy_reader import daily_production
-from envoy_reader.envoy_reader import daily_consumption
-from envoy_reader.envoy_reader import seven_days_production
-from envoy_reader.envoy_reader import seven_days_consumption
-from envoy_reader.envoy_reader import lifetime_production
-from envoy_reader.envoy_reader import lifetime_consumption
diff --git a/envoy_reader/envoy_reader.py b/envoy_reader/envoy_reader.py
index 5604aa2..2509127 100644
--- a/envoy_reader/envoy_reader.py
+++ b/envoy_reader/envoy_reader.py
@@ -1,158 +1,573 @@
-import requests
-import sys
-import json
-
-envoy_model = "S"
-def call_api(ip_address):
- url = "http://envoy/production.json".format(ip_address)
- response = requests.get(url, timeout=5)
- if response.status_code == 301:
- global envoy_model
- envoy_model = "Original"
- url = "http://envoy/api/v1/production"
- response = requests.get(url, timeout=5)
- return response.json()
-
- return response.json()
-
-
-def production(ip_address):
- try:
- raw_json = call_api(ip_address)
- if envoy_model == "S":
- production = raw_json["production"][1]["wNow"]
+"""Module to read production and consumption values from an Enphase Envoy on the local network."""
+import asyncio
+import logging
+import re
+import time
+from json.decoder import JSONDecodeError
+
+import httpx
+
+#
+# Legacy parser is only used on ancient firmwares
+#
+PRODUCTION_REGEX = r"
Currentl.* | \s+\s*(\d+|\d+\.\d+)\s*(W|kW|MW) | "
+DAY_PRODUCTION_REGEX = r"Today | \s+\s*(\d+|\d+\.\d+)\s*(Wh|kWh|MWh) | "
+WEEK_PRODUCTION_REGEX = (
+ r"Past Week | \s+\s*(\d+|\d+\.\d+)\s*(Wh|kWh|MWh) | "
+)
+LIFE_PRODUCTION_REGEX = (
+ r"Since Installation | \s+\s*(\d+|\d+\.\d+)\s*(Wh|kWh|MWh) | "
+)
+SERIAL_REGEX = re.compile(r"Envoy\s*Serial\s*Number:\s*([0-9]+)")
+
+ENDPOINT_URL_HOME_JSON = "http://{}/home.json"
+ENDPOINT_URL_PRODUCTION_JSON = "http://{}/production.json"
+ENDPOINT_URL_PRODUCTION_V1 = "http://{}/api/v1/production"
+ENDPOINT_URL_PRODUCTION_INVERTERS = "http://{}/api/v1/production/inverters"
+ENDPOINT_URL_PRODUCTION = "http://{}/production"
+
+# pylint: disable=pointless-string-statement
+
+ENVOY_MODEL_S = "PC"
+ENVOY_MODEL_C = "P"
+ENVOY_MODEL_LEGACY = "P0"
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def has_production_and_consumption(json):
+ """Check if json has keys for both production and consumption."""
+ return "production" in json and "consumption" in json
+
+
+def has_metering_setup(json):
+ """Check if Active Count of Production CTs (eim) installed is greater than one."""
+ return json["production"][1]["activeCount"] > 0
+
+
+class EnvoyReader: # pylint: disable=too-many-instance-attributes
+ """Instance of EnvoyReader"""
+
+ # P0 for older Envoy model C, s/w < R3.9 no json pages
+ # P for production data only (ie. Envoy model C, s/w >= R3.9)
+ # PC for production and consumption data (ie. Envoy model S)
+
+ message_battery_not_available = (
+ "Battery storage data not available for your Envoy device."
+ )
+
+ message_consumption_not_available = (
+ "Consumption data not available for your Envoy device."
+ )
+
+ message_enpower_not_available = (
+ "Enpower Smart Switch status is not available for your Envoy device."
+ )
+
+ def __init__( # pylint: disable=too-many-arguments
+ self, host, username="envoy", password="", inverters=False, async_client=None
+ ):
+ """Init the EnvoyReader."""
+ self.host = host.lower()
+ self.username = username
+ self.password = password
+ self.get_inverters = inverters
+ self.endpoint_type = None
+ self.serial_number_last_six = None
+ self.endpoint_home_json_results = None
+ self.endpoint_production_json_results = None
+ self.endpoint_production_v1_results = None
+ self.endpoint_production_inverters = None
+ self.endpoint_production_results = None
+ self.isMeteringEnabled = False # pylint: disable=invalid-name
+ self._async_client = async_client
+
+ @property
+ def async_client(self):
+ """Return the httpx client."""
+ return self._async_client or httpx.AsyncClient()
+
+ async def _update(self):
+ """Update the data."""
+ if self.endpoint_type == ENVOY_MODEL_S:
+ await self._update_from_pc_endpoint()
+ if self.endpoint_type == ENVOY_MODEL_C or (
+ self.endpoint_type == ENVOY_MODEL_S and not self.isMeteringEnabled
+ ):
+ await self._update_from_p_endpoint()
+ if self.endpoint_type == ENVOY_MODEL_LEGACY:
+ await self._update_from_p0_endpoint()
+
+ async def _update_from_pc_endpoint(self):
+ """Update from PC endpoint."""
+ await self._update_endpoint(
+ "endpoint_production_json_results", ENDPOINT_URL_PRODUCTION_JSON
+ )
+ await self._update_endpoint(
+ "endpoint_home_json_results", ENDPOINT_URL_HOME_JSON
+ )
+
+ async def _update_from_p_endpoint(self):
+ """Update from P endpoint."""
+ await self._update_endpoint(
+ "endpoint_production_v1_results", ENDPOINT_URL_PRODUCTION_V1
+ )
+
+ async def _update_from_p0_endpoint(self):
+ """Update from P0 endpoint."""
+ await self._update_endpoint(
+ "endpoint_production_results", ENDPOINT_URL_PRODUCTION
+ )
+
+ async def _update_endpoint(self, attr, url):
+ """Update a property from an endpoint."""
+ formatted_url = url.format(self.host)
+ response = await self._async_fetch_with_retry(
+ formatted_url, allow_redirects=False
+ )
+ setattr(self, attr, response)
+ _LOGGER.debug("Fetched from %s: %s: %s", formatted_url, response, response.text)
+
+ async def _async_fetch_with_retry(self, url, **kwargs):
+ """Retry 3 times to fetch the url if there is a transport error."""
+ for attempt in range(3):
+ try:
+ async with self.async_client as client:
+ return await client.get(url, timeout=30, **kwargs)
+ except httpx.TransportError:
+ if attempt == 2:
+ raise
+
+ async def getData(self, getInverters=True): # pylint: disable=invalid-name
+ """Fetch data from the endpoint and if inverters selected default to fetching inverter data."""
+ if not self.endpoint_type:
+ await self.detect_model()
else:
+ await self._update()
+
+ if not self.get_inverters or not getInverters:
+ return
+
+ inverters_url = ENDPOINT_URL_PRODUCTION_INVERTERS.format(self.host)
+ inverters_auth = httpx.DigestAuth(self.username, self.password)
+
+ response = await self._async_fetch_with_retry(
+ inverters_url, auth=inverters_auth
+ )
+ _LOGGER.debug(
+ "Fetched from %s: %s: %s",
+ inverters_url,
+ response,
+ response.text,
+ )
+ if response.status_code == 401:
+ response.raise_for_status()
+ self.endpoint_production_inverters = response
+ return
+
+ async def detect_model(self):
+ """Method to determine if the Envoy supports consumption values or only production."""
+ # If a password was not given as an argument when instantiating
+ # the EnvoyReader object than use the last six numbers of the serial
+ # number as the password. Otherwise use the password argument value.
+ if self.password == "" and not self.serial_number_last_six:
+ await self.get_serial_number()
+ self.password = self.serial_number_last_six
+
+ try:
+ await self._update_from_pc_endpoint()
+ except httpx.HTTPError:
+ pass
+ if (
+ self.endpoint_production_json_results
+ and self.endpoint_production_json_results.status_code == 200
+ and has_production_and_consumption(
+ self.endpoint_production_json_results.json()
+ )
+ ):
+ self.isMeteringEnabled = has_metering_setup(
+ self.endpoint_production_json_results.json()
+ )
+ if not self.isMeteringEnabled:
+ await self._update_from_p_endpoint()
+ self.endpoint_type = ENVOY_MODEL_S
+ return
+
+ try:
+ await self._update_from_p_endpoint()
+ except httpx.HTTPError:
+ pass
+ if (
+ self.endpoint_production_v1_results
+ and self.endpoint_production_v1_results.status_code == 200
+ ):
+ self.endpoint_type = ENVOY_MODEL_C # Envoy-C, production only
+ return
+
+ try:
+ await self._update_from_p0_endpoint()
+ except httpx.HTTPError:
+ pass
+ if (
+ self.endpoint_production_results
+ and self.endpoint_production_results.status_code == 200
+ ):
+ self.endpoint_type = ENVOY_MODEL_LEGACY # older Envoy-C
+ return
+
+ raise RuntimeError(
+ "Could not connect or determine Envoy model. "
+ + "Check that the device is up at 'http://"
+ + self.host
+ + "'."
+ )
+
+ async def get_serial_number(self):
+ """Method to get last six digits of Envoy serial number for auth"""
+ full_serial = await self.get_full_serial_number()
+ if full_serial:
+ self.serial_number_last_six = full_serial[-6:]
+
+ async def get_full_serial_number(self):
+ """Method to get the Envoy serial number."""
+ response = await self._async_fetch_with_retry(
+ "http://{}/info.xml".format(self.host),
+ allow_redirects=True,
+ )
+ if not response.text:
+ return None
+ if "" in response.text:
+ return response.text.split("")[1].split("")[0]
+ match = SERIAL_REGEX.search(response.text)
+ if match:
+ return match.group(1)
+
+ def create_connect_errormessage(self):
+ """Create error message if unable to connect to Envoy"""
+ return (
+ "Unable to connect to Envoy. "
+ + "Check that the device is up at 'http://"
+ + self.host
+ + "'."
+ )
+
+ def create_json_errormessage(self):
+ """Create error message if unable to parse JSON response"""
+ return (
+ "Got a response from '"
+ + self.host
+ + "', but metric could not be found. "
+ + "Maybe your model of Envoy doesn't "
+ + "support the requested metric."
+ )
+
+ async def production(self):
+ """Running getData() beforehand will set self.enpoint_type and self.isDataRetrieved"""
+ """so that this method will only read data from stored variables"""
+
+ if self.endpoint_type == ENVOY_MODEL_S:
+ raw_json = self.endpoint_production_json_results.json()
+ idx = 1 if self.isMeteringEnabled else 0
+ production = raw_json["production"][idx]["wNow"]
+ elif self.endpoint_type == ENVOY_MODEL_C:
+ raw_json = self.endpoint_production_v1_results.json()
production = raw_json["wattsNow"]
+ elif self.endpoint_type == ENVOY_MODEL_LEGACY:
+ text = self.endpoint_production_results.text
+ match = re.search(PRODUCTION_REGEX, text, re.MULTILINE)
+ if match:
+ if match.group(2) == "kW":
+ production = float(match.group(1)) * 1000
+ else:
+ if match.group(2) == "mW":
+ production = float(match.group(1)) * 1000000
+ else:
+ production = float(match.group(1))
+ else:
+ raise RuntimeError("No match for production, check REGEX " + text)
return int(production)
- except requests.exceptions.ConnectionError:
- return "Unable to connect to Envoy. Check the IP address"
- except (json.decoder.JSONDecodeError, KeyError):
- return ("Got a response, but it doesn't look right. " +
- "Check the IP address")
-
-
-def consumption(ip_address):
- try:
- raw_json = call_api(ip_address)
- if envoy_model == "S":
- consumption = raw_json["consumption"][0]["wNow"]
- return int(consumption)
- else:
- return "Unavailable"
- except requests.exceptions.ConnectionError:
- return "Unable to connect to Envoy. Check the IP address"
- except (json.decoder.JSONDecodeError, KeyError):
- return ("Got a response, but it doesn't look right. " +
- "Check the IP address")
-
-
-def daily_production(ip_address):
- try:
- raw_json = call_api(ip_address)
- if envoy_model == "S":
+
+ async def consumption(self):
+ """Running getData() beforehand will set self.enpoint_type and self.isDataRetrieved"""
+ """so that this method will only read data from stored variables"""
+
+ """Only return data if Envoy supports Consumption"""
+ if (
+ self.endpoint_type == ENVOY_MODEL_C
+ or self.endpoint_type == ENVOY_MODEL_LEGACY
+ ):
+ return self.message_consumption_not_available
+
+ raw_json = self.endpoint_production_json_results.json()
+ consumption = raw_json["consumption"][0]["wNow"]
+ return int(consumption)
+
+ async def daily_production(self):
+ """Running getData() beforehand will set self.enpoint_type and self.isDataRetrieved"""
+ """so that this method will only read data from stored variables"""
+
+ if self.endpoint_type == ENVOY_MODEL_S and self.isMeteringEnabled:
+ raw_json = self.endpoint_production_json_results.json()
daily_production = raw_json["production"][1]["whToday"]
- else:
+ elif self.endpoint_type == ENVOY_MODEL_C or (
+ self.endpoint_type == ENVOY_MODEL_S and not self.isMeteringEnabled
+ ):
+ raw_json = self.endpoint_production_v1_results.json()
daily_production = raw_json["wattHoursToday"]
+ elif self.endpoint_type == ENVOY_MODEL_LEGACY:
+ text = self.endpoint_production_results.text
+ match = re.search(DAY_PRODUCTION_REGEX, text, re.MULTILINE)
+ if match:
+ if match.group(2) == "kWh":
+ daily_production = float(match.group(1)) * 1000
+ else:
+ if match.group(2) == "MWh":
+ daily_production = float(match.group(1)) * 1000000
+ else:
+ daily_production = float(match.group(1))
+ else:
+ raise RuntimeError(
+ "No match for Day production, " "check REGEX " + text
+ )
return int(daily_production)
- except requests.exceptions.ConnectionError:
- return "Unable to connect to Envoy. Check the IP address"
- except (json.decoder.JSONDecodeError, KeyError):
- return ("Got a response, but it doesn't look right. " +
- "Check the IP address")
-
-
-def daily_consumption(ip_address):
- try:
- raw_json = call_api(ip_address)
- if envoy_model == "S":
- daily_consumption = raw_json["consumption"][0]["whToday"]
- return int(daily_consumption)
- else:
- return "Unavailable"
-
- except requests.exceptions.ConnectionError:
- return "Unable to connect to Envoy. Check the IP address"
- except (json.decoder.JSONDecodeError, KeyError):
- return ("Got a response, but it doesn't look right. " +
- "Check the IP address")
-
-
-def seven_days_production(ip_address):
- try:
- raw_json = call_api(ip_address)
- if envoy_model == "S":
- seven_days_production = raw_json["production"][1][
- "whLastSevenDays"
- ]
- else:
+
+ async def daily_consumption(self):
+ """Running getData() beforehand will set self.enpoint_type and self.isDataRetrieved"""
+ """so that this method will only read data from stored variables"""
+
+ """Only return data if Envoy supports Consumption"""
+ if (
+ self.endpoint_type == ENVOY_MODEL_C
+ or self.endpoint_type == ENVOY_MODEL_LEGACY
+ ):
+ return self.message_consumption_not_available
+
+ raw_json = self.endpoint_production_json_results.json()
+ daily_consumption = raw_json["consumption"][0]["whToday"]
+ return int(daily_consumption)
+
+ async def seven_days_production(self):
+ """Running getData() beforehand will set self.enpoint_type and self.isDataRetrieved"""
+ """so that this method will only read data from stored variables"""
+
+ if self.endpoint_type == ENVOY_MODEL_S and self.isMeteringEnabled:
+ raw_json = self.endpoint_production_json_results.json()
+ seven_days_production = raw_json["production"][1]["whLastSevenDays"]
+ elif self.endpoint_type == ENVOY_MODEL_C or (
+ self.endpoint_type == ENVOY_MODEL_S and not self.isMeteringEnabled
+ ):
+ raw_json = self.endpoint_production_v1_results.json()
seven_days_production = raw_json["wattHoursSevenDays"]
+ elif self.endpoint_type == ENVOY_MODEL_LEGACY:
+ text = self.endpoint_production_results.text
+ match = re.search(WEEK_PRODUCTION_REGEX, text, re.MULTILINE)
+ if match:
+ if match.group(2) == "kWh":
+ seven_days_production = float(match.group(1)) * 1000
+ else:
+ if match.group(2) == "MWh":
+ seven_days_production = float(match.group(1)) * 1000000
+ else:
+ seven_days_production = float(match.group(1))
+ else:
+ raise RuntimeError(
+ "No match for 7 Day production, " "check REGEX " + text
+ )
return int(seven_days_production)
- except requests.exceptions.ConnectionError:
- return "Unable to connect to Envoy. Check the IP address"
- except (json.decoder.JSONDecodeError, KeyError):
- return ("Got a response, but it doesn't look right. " +
- "Check the IP address")
-
-
-def seven_days_consumption(ip_address):
- try:
- raw_json = call_api(ip_address)
- if envoy_model == "S":
- seven_days_consumption = raw_json["consumption"][0][
- "whLastSevenDays"
- ]
- return int(seven_days_consumption)
- else:
- return "Unavailable"
- except requests.exceptions.ConnectionError:
- return "Unable to connect to Envoy. Check the IP address"
- except (json.decoder.JSONDecodeError, KeyError):
- return ("Got a response, but it doesn't look right. " +
- "Check the IP address")
-
-
-def lifetime_production(ip_address):
- try:
- raw_json = call_api(ip_address)
- if envoy_model == "S":
- lifetime_production = raw_json["production"][1][
- "whLifetime"
- ]
- else:
+
+ async def seven_days_consumption(self):
+ """Running getData() beforehand will set self.enpoint_type and self.isDataRetrieved"""
+ """so that this method will only read data from stored variables"""
+
+ """Only return data if Envoy supports Consumption"""
+ if (
+ self.endpoint_type == ENVOY_MODEL_C
+ or self.endpoint_type == ENVOY_MODEL_LEGACY
+ ):
+ return self.message_consumption_not_available
+
+ raw_json = self.endpoint_production_json_results.json()
+ seven_days_consumption = raw_json["consumption"][0]["whLastSevenDays"]
+ return int(seven_days_consumption)
+
+ async def lifetime_production(self):
+ """Running getData() beforehand will set self.enpoint_type and self.isDataRetrieved"""
+ """so that this method will only read data from stored variables"""
+
+ if self.endpoint_type == ENVOY_MODEL_S and self.isMeteringEnabled:
+ raw_json = self.endpoint_production_json_results.json()
+ lifetime_production = raw_json["production"][1]["whLifetime"]
+ elif self.endpoint_type == ENVOY_MODEL_C or (
+ self.endpoint_type == ENVOY_MODEL_S and not self.isMeteringEnabled
+ ):
+ raw_json = self.endpoint_production_v1_results.json()
lifetime_production = raw_json["wattHoursLifetime"]
+ elif self.endpoint_type == ENVOY_MODEL_LEGACY:
+ text = self.endpoint_production_results.text
+ match = re.search(LIFE_PRODUCTION_REGEX, text, re.MULTILINE)
+ if match:
+ if match.group(2) == "kWh":
+ lifetime_production = float(match.group(1)) * 1000
+ else:
+ if match.group(2) == "MWh":
+ lifetime_production = float(match.group(1)) * 1000000
+ else:
+ lifetime_production = float(match.group(1))
+ else:
+ raise RuntimeError(
+ "No match for Lifetime production, " "check REGEX " + text
+ )
return int(lifetime_production)
- except requests.exceptions.ConnectionError:
- return "Unable to connect to Envoy. Check the IP address"
- except (json.decoder.JSONDecodeError, KeyError):
- return ("Got a response, but it doesn't look right. " +
- "Check the IP address")
-
-
-def lifetime_consumption(ip_address):
- try:
- raw_json = call_api(ip_address)
- if envoy_model == "S":
- lifetime_consumption = raw_json["consumption"][0][
- "whLifetime"
- ]
- return int(lifetime_consumption)
- else:
- return "Unavailable"
- except requests.exceptions.ConnectionError:
- return "Unable to connect to Envoy. Check the IP address"
- except (json.decoder.JSONDecodeError, KeyError):
- return ("Got a response, but it doesn't look right. " +
- "Check the IP address")
+ async def lifetime_consumption(self):
+ """Running getData() beforehand will set self.enpoint_type and self.isDataRetrieved"""
+ """so that this method will only read data from stored variables"""
+
+ """Only return data if Envoy supports Consumption"""
+ if (
+ self.endpoint_type == ENVOY_MODEL_C
+ or self.endpoint_type == ENVOY_MODEL_LEGACY
+ ):
+ return self.message_consumption_not_available
+
+ raw_json = self.endpoint_production_json_results.json()
+ lifetime_consumption = raw_json["consumption"][0]["whLifetime"]
+ return int(lifetime_consumption)
+
+ async def inverters_production(self):
+ """Running getData() beforehand will set self.enpoint_type and self.isDataRetrieved"""
+ """so that this method will only read data from stored variables"""
+
+ """Only return data if Envoy supports retrieving Inverter data"""
+ if self.endpoint_type == ENVOY_MODEL_LEGACY:
+ return None
+
+ response_dict = {}
+ try:
+ for item in self.endpoint_production_inverters.json():
+ response_dict[item["serialNumber"]] = [
+ item["lastReportWatts"],
+ time.strftime(
+ "%Y-%m-%d %H:%M:%S", time.localtime(item["lastReportDate"])
+ ),
+ ]
+ except (JSONDecodeError, KeyError, IndexError, TypeError, AttributeError):
+ return None
+
+ return response_dict
+
+ async def battery_storage(self):
+ """Return battery data from Envoys that support and have batteries installed"""
+ if (
+ self.endpoint_type == ENVOY_MODEL_LEGACY
+ or self.endpoint_type == ENVOY_MODEL_C
+ ):
+ return self.message_battery_not_available
+
+ try:
+ raw_json = self.endpoint_production_json_results.json()
+ except (JSONDecodeError):
+ return None
+
+ """For Envoys that support batteries but do not have them installed the"""
+ """percentFull will not be available in the JSON results. The API will"""
+ """only return battery data if batteries are installed."""
+ if "percentFull" not in raw_json["storage"][0].keys():
+ return self.message_battery_not_available
+
+ return raw_json["storage"][0]
+
+ async def enpower_status(self):
+ """Return Enpower Smart Swirch data from Envoys that support it."""
+ if (
+ self.endpoint_type == ENVOY_MODEL_LEGACY
+ or self.endpoint_type == ENVOY_MODEL_C
+ ):
+ return self.message_enpower_not_available
+
+ try:
+ raw_json = self.endpoint_home_json_results.json()
+ except (JSONDecodeError):
+ return None
+
+ if "enpower" not in raw_json.keys():
+ return self.message_enpower_not_available
+
+ return raw_json["enpower"]
+
+ def run_in_console(self):
+ """If running this module directly, print all the values in the console."""
+ print("Reading...")
+ loop = asyncio.get_event_loop()
+ data_results = loop.run_until_complete(
+ asyncio.gather(self.getData(), return_exceptions=True)
+ )
+
+ loop = asyncio.get_event_loop()
+ results = loop.run_until_complete(
+ asyncio.gather(
+ self.production(),
+ self.consumption(),
+ self.daily_production(),
+ self.daily_consumption(),
+ self.seven_days_production(),
+ self.seven_days_consumption(),
+ self.lifetime_production(),
+ self.lifetime_consumption(),
+ self.inverters_production(),
+ self.battery_storage(),
+ self.enpower_status(),
+ return_exceptions=True,
+ )
+ )
+
+ print("production: {}".format(results[0]))
+ print("consumption: {}".format(results[1]))
+ print("daily_production: {}".format(results[2]))
+ print("daily_consumption: {}".format(results[3]))
+ print("seven_days_production: {}".format(results[4]))
+ print("seven_days_consumption: {}".format(results[5]))
+ print("lifetime_production: {}".format(results[6]))
+ print("lifetime_consumption: {}".format(results[7]))
+ if "401" in str(data_results):
+ print(
+ "inverters_production: Unable to retrieve inverter data - Authentication failure"
+ )
+ elif results[8] is None:
+ print(
+ "inverters_production: Inverter data not available for your Envoy device."
+ )
+ else:
+ print("inverters_production: {}".format(results[8]))
+ print("battery_storage: {}".format(results[9]))
+ print("enpower_status: {}".format(results[10]))
if __name__ == "__main__":
- url = ""
- print("production {}".format(production(url)))
- print("consumption {}".format(consumption(url)))
- print("daily_production {}".format(daily_production(url)))
- print("daily_consumption {}".format(daily_consumption(url)))
- print("seven_days_production {}".format(seven_days_production(url)))
- print("seven_days_consumption {}".format(seven_days_consumption(url)))
- print("lifetime_production {}".format(lifetime_production(url)))
- print("lifetime_consumption {}".format(lifetime_consumption(url)))
+ HOST = input(
+ "Enter the Envoy IP address or host name, "
+ + "or press enter to use 'envoy' as default: "
+ )
+
+ USERNAME = input(
+ "Enter the Username for Inverter data authentication, "
+ + "or press enter to use 'envoy' as default: "
+ )
+
+ PASSWORD = input(
+ "Enter the Password for Inverter data authentication, "
+ + "or press enter to use the default password: "
+ )
+
+ if HOST == "":
+ HOST = "envoy"
+
+ if USERNAME == "":
+ USERNAME = "envoy"
+
+ if PASSWORD == "":
+ TESTREADER = EnvoyReader(HOST, USERNAME, inverters=True)
+ else:
+ TESTREADER = EnvoyReader(HOST, USERNAME, PASSWORD, inverters=True)
+
+ TESTREADER.run_in_console()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..bbc208f
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+requests_async >= 0.6.0
+httpx >= 0.12.1
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..a5cf1a2
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,34 @@
+[bumpversion]
+current_version = 0.18.3
+commit = True
+tag = True
+
+[bumpversion:file:setup.py]
+search = version="{current_version}"
+replace = version="{new_version}"
+
+[bumpversion:file:envoy_reader/__init__.py]
+search = {current_version}
+replace = {new_version}
+
+[bdist_wheel]
+universal = 1
+
+[aliases]
+test = pytest
+
+[tool:pytest]
+collect_ignore = ['setup.py']
+
+[flake8]
+exclude =
+ docs/
+ignore =
+ E203
+ E402
+ W291
+ W503
+ D400
+ D401
+max-line-length = 120
+
diff --git a/setup.py b/setup.py
index f2d5ff2..fc4add1 100644
--- a/setup.py
+++ b/setup.py
@@ -3,9 +3,54 @@
with open("README.md", "r") as fh:
long_description = fh.read()
+
+setup_requirements = [
+ "pytest-runner>=5.2",
+]
+
+test_requirements = [
+ "pytest-asyncio",
+ "black>=19.10b0",
+ "codecov>=2.1.4",
+ "flake8>=3.8.3",
+ "flake8-debugger>=3.2.1",
+ "pytest>=5.4.3",
+ "pytest-cov>=2.9.0",
+ "pytest-raises>=0.11",
+ "respx>=0.16.3",
+]
+
+dev_requirements = [
+ *setup_requirements,
+ *test_requirements,
+ "bump2version>=1.0.1",
+ "coverage>=5.1",
+ "ipython>=7.15.0",
+ "m2r2>=0.2.7",
+ "pytest-runner>=5.2",
+ "Sphinx>=3.4.3",
+ "sphinx_rtd_theme>=0.5.1",
+ "tox>=3.15.2",
+ "twine>=3.1.1",
+ "wheel>=0.34.2",
+]
+
+requirements = ["httpx>=0.12.1"]
+
+extra_requirements = {
+ "setup": setup_requirements,
+ "test": test_requirements,
+ "dev": dev_requirements,
+ "all": [
+ *requirements,
+ *dev_requirements,
+ ],
+}
+
+
setuptools.setup(
name="envoy_reader",
- version="0.1",
+ version="0.19.0",
author="Jesse Rizzo",
author_email="jesse.rizzo@gmail.com",
description="A program to read from an Enphase Envoy on the local network",
@@ -13,9 +58,14 @@
long_description_content_type="text/markdown",
url="https://github.com/jesserizzo/envoy_reader",
packages=setuptools.find_packages(),
- classifiers=(
+ install_requires=requirements,
+ setup_requires=setup_requirements,
+ test_suite="tests",
+ tests_require=test_requirements,
+ extras_require=extra_requirements,
+ classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
- ),
+ ],
)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..6bbbeaa
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+"""Unit test package for envoy_reader."""
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..faa18be
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,2 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
diff --git a/tests/fixtures/3.17.3/api_v1_production b/tests/fixtures/3.17.3/api_v1_production
new file mode 100644
index 0000000..71a98df
--- /dev/null
+++ b/tests/fixtures/3.17.3/api_v1_production
@@ -0,0 +1,6 @@
+{
+ "wattHoursToday": 5481,
+ "wattHoursSevenDays": 389581,
+ "wattHoursLifetime": 93706280,
+ "wattsNow": 5463
+}
diff --git a/tests/fixtures/3.17.3/api_v1_production_inverters b/tests/fixtures/3.17.3/api_v1_production_inverters
new file mode 100644
index 0000000..9817faf
--- /dev/null
+++ b/tests/fixtures/3.17.3/api_v1_production_inverters
@@ -0,0 +1,248 @@
+[
+ {
+ "serialNumber": "121512041640",
+ "lastReportDate": 1618082927,
+ "lastReportWatts": 200,
+ "maxReportWatts": 249
+ },
+ {
+ "serialNumber": "121512036336",
+ "lastReportDate": 1618082932,
+ "lastReportWatts": 199,
+ "maxReportWatts": 247
+ },
+ {
+ "serialNumber": "121512043093",
+ "lastReportDate": 1618082928,
+ "lastReportWatts": 208,
+ "maxReportWatts": 255
+ },
+ {
+ "serialNumber": "121512039005",
+ "lastReportDate": 1618082933,
+ "lastReportWatts": 55,
+ "maxReportWatts": 254
+ },
+ {
+ "serialNumber": "121512041456",
+ "lastReportDate": 1618082937,
+ "lastReportWatts": 13,
+ "maxReportWatts": 79
+ },
+ {
+ "serialNumber": "121512043153",
+ "lastReportDate": 1618082935,
+ "lastReportWatts": 18,
+ "maxReportWatts": 146
+ },
+ {
+ "serialNumber": "121512038691",
+ "lastReportDate": 1618082942,
+ "lastReportWatts": 26,
+ "maxReportWatts": 247
+ },
+ {
+ "serialNumber": "121512039090",
+ "lastReportDate": 1618082946,
+ "lastReportWatts": 32,
+ "maxReportWatts": 194
+ },
+ {
+ "serialNumber": "121512038982",
+ "lastReportDate": 1618082950,
+ "lastReportWatts": 203,
+ "maxReportWatts": 253
+ },
+ {
+ "serialNumber": "121512042344",
+ "lastReportDate": 1618082952,
+ "lastReportWatts": 205,
+ "maxReportWatts": 253
+ },
+ {
+ "serialNumber": "121512039143",
+ "lastReportDate": 1618082956,
+ "lastReportWatts": 104,
+ "maxReportWatts": 245
+ },
+ {
+ "serialNumber": "121512009183",
+ "lastReportDate": 1618082961,
+ "lastReportWatts": 204,
+ "maxReportWatts": 253
+ },
+ {
+ "serialNumber": "121512039093",
+ "lastReportDate": 1618082966,
+ "lastReportWatts": 209,
+ "maxReportWatts": 256
+ },
+ {
+ "serialNumber": "121512042132",
+ "lastReportDate": 1618082924,
+ "lastReportWatts": 200,
+ "maxReportWatts": 250
+ },
+ {
+ "serialNumber": "121512043135",
+ "lastReportDate": 1618082923,
+ "lastReportWatts": 205,
+ "maxReportWatts": 254
+ },
+ {
+ "serialNumber": "121512043173",
+ "lastReportDate": 1618082966,
+ "lastReportWatts": 200,
+ "maxReportWatts": 247
+ },
+ {
+ "serialNumber": "121512039018",
+ "lastReportDate": 1618082964,
+ "lastReportWatts": 27,
+ "maxReportWatts": 252
+ },
+ {
+ "serialNumber": "121512036221",
+ "lastReportDate": 1618082963,
+ "lastReportWatts": 8,
+ "maxReportWatts": 116
+ },
+ {
+ "serialNumber": "121512038619",
+ "lastReportDate": 1618082962,
+ "lastReportWatts": 203,
+ "maxReportWatts": 252
+ },
+ {
+ "serialNumber": "121512038919",
+ "lastReportDate": 1618082959,
+ "lastReportWatts": 102,
+ "maxReportWatts": 238
+ },
+ {
+ "serialNumber": "121512006273",
+ "lastReportDate": 1618082959,
+ "lastReportWatts": 206,
+ "maxReportWatts": 254
+ },
+ {
+ "serialNumber": "121512043222",
+ "lastReportDate": 1618082957,
+ "lastReportWatts": 207,
+ "maxReportWatts": 254
+ },
+ {
+ "serialNumber": "121512038416",
+ "lastReportDate": 1618082953,
+ "lastReportWatts": 151,
+ "maxReportWatts": 251
+ },
+ {
+ "serialNumber": "121512043200",
+ "lastReportDate": 1618082955,
+ "lastReportWatts": 203,
+ "maxReportWatts": 253
+ },
+ {
+ "serialNumber": "121512044424",
+ "lastReportDate": 1618082954,
+ "lastReportWatts": 106,
+ "maxReportWatts": 239
+ },
+ {
+ "serialNumber": "121512041747",
+ "lastReportDate": 1618082925,
+ "lastReportWatts": 64,
+ "maxReportWatts": 248
+ },
+ {
+ "serialNumber": "121512039075",
+ "lastReportDate": 1618082930,
+ "lastReportWatts": 102,
+ "maxReportWatts": 237
+ },
+ {
+ "serialNumber": "121512043587",
+ "lastReportDate": 1618082934,
+ "lastReportWatts": 202,
+ "maxReportWatts": 253
+ },
+ {
+ "serialNumber": "121512043086",
+ "lastReportDate": 1618082942,
+ "lastReportWatts": 202,
+ "maxReportWatts": 250
+ },
+ {
+ "serialNumber": "121512039091",
+ "lastReportDate": 1618082939,
+ "lastReportWatts": 27,
+ "maxReportWatts": 252
+ },
+ {
+ "serialNumber": "121512039181",
+ "lastReportDate": 1618082943,
+ "lastReportWatts": 101,
+ "maxReportWatts": 238
+ },
+ {
+ "serialNumber": "121512033008",
+ "lastReportDate": 1618082947,
+ "lastReportWatts": 101,
+ "maxReportWatts": 243
+ },
+ {
+ "serialNumber": "121512037453",
+ "lastReportDate": 1618082949,
+ "lastReportWatts": 205,
+ "maxReportWatts": 255
+ },
+ {
+ "serialNumber": "121512038421",
+ "lastReportDate": 1618082949,
+ "lastReportWatts": 14,
+ "maxReportWatts": 233
+ },
+ {
+ "serialNumber": "121512038845",
+ "lastReportDate": 1618082945,
+ "lastReportWatts": 203,
+ "maxReportWatts": 253
+ },
+ {
+ "serialNumber": "121512038901",
+ "lastReportDate": 1618082944,
+ "lastReportWatts": 102,
+ "maxReportWatts": 245
+ },
+ {
+ "serialNumber": "121512039124",
+ "lastReportDate": 1618082938,
+ "lastReportWatts": 205,
+ "maxReportWatts": 254
+ },
+ {
+ "serialNumber": "121512036220",
+ "lastReportDate": 1618082927,
+ "lastReportWatts": 198,
+ "maxReportWatts": 245
+ },
+ {
+ "serialNumber": "121512038762",
+ "lastReportDate": 1618082930,
+ "lastReportWatts": 203,
+ "maxReportWatts": 253
+ },
+ {
+ "serialNumber": "121512043574",
+ "lastReportDate": 1618082936,
+ "lastReportWatts": 203,
+ "maxReportWatts": 253
+ },
+ {
+ "serialNumber": "121512036250",
+ "lastReportDate": 1618082940,
+ "lastReportWatts": 20,
+ "maxReportWatts": 190
+ }
+]
diff --git a/tests/fixtures/3.9.36/api_v1_production b/tests/fixtures/3.9.36/api_v1_production
new file mode 100644
index 0000000..7e6d856
--- /dev/null
+++ b/tests/fixtures/3.9.36/api_v1_production
@@ -0,0 +1,6 @@
+{
+ "wattHoursToday": 1460,
+ "wattHoursSevenDays": 130349,
+ "wattHoursLifetime": 6012540,
+ "wattsNow": 1271
+}
diff --git a/tests/fixtures/3.9.36/api_v1_production_inverters b/tests/fixtures/3.9.36/api_v1_production_inverters
new file mode 100644
index 0000000..33e0159
--- /dev/null
+++ b/tests/fixtures/3.9.36/api_v1_production_inverters
@@ -0,0 +1,74 @@
+[
+ {
+ "serialNumber": "121547058993",
+ "lastReportDate": 1618083961,
+ "lastReportWatts": 138,
+ "maxReportWatts": 231
+ },
+ {
+ "serialNumber": "121547060394",
+ "lastReportDate": 1618083966,
+ "lastReportWatts": 138,
+ "maxReportWatts": 238
+ },
+ {
+ "serialNumber": "121603034267",
+ "lastReportDate": 1618083956,
+ "lastReportWatts": 138,
+ "maxReportWatts": 244
+ },
+ {
+ "serialNumber": "121547060402",
+ "lastReportDate": 1618083962,
+ "lastReportWatts": 138,
+ "maxReportWatts": 240
+ },
+ {
+ "serialNumber": "121547060638",
+ "lastReportDate": 1618083966,
+ "lastReportWatts": 139,
+ "maxReportWatts": 241
+ },
+ {
+ "serialNumber": "121547060646",
+ "lastReportDate": 1618083957,
+ "lastReportWatts": 139,
+ "maxReportWatts": 240
+ },
+ {
+ "serialNumber": "121603025842",
+ "lastReportDate": 1618083963,
+ "lastReportWatts": 139,
+ "maxReportWatts": 260
+ },
+ {
+ "serialNumber": "121603039216",
+ "lastReportDate": 1618083968,
+ "lastReportWatts": 139,
+ "maxReportWatts": 273
+ },
+ {
+ "serialNumber": "121547060652",
+ "lastReportDate": 1618083959,
+ "lastReportWatts": 140,
+ "maxReportWatts": 245
+ },
+ {
+ "serialNumber": "121547060495",
+ "lastReportDate": 1618083959,
+ "lastReportWatts": 135,
+ "maxReportWatts": 228
+ },
+ {
+ "serialNumber": "121603038867",
+ "lastReportDate": 1618083964,
+ "lastReportWatts": 138,
+ "maxReportWatts": 242
+ },
+ {
+ "serialNumber": "121547058983",
+ "lastReportDate": 1618083969,
+ "lastReportWatts": 137,
+ "maxReportWatts": 238
+ }
+]
diff --git a/tests/fixtures/4.2.27/api_v1_production b/tests/fixtures/4.2.27/api_v1_production
new file mode 100644
index 0000000..f1468f7
--- /dev/null
+++ b/tests/fixtures/4.2.27/api_v1_production
@@ -0,0 +1,6 @@
+{
+ "wattHoursToday": 17920,
+ "wattHoursSevenDays": 276614,
+ "wattHoursLifetime": 10279087,
+ "wattsNow": 5894
+}
diff --git a/tests/fixtures/4.2.27/production.json b/tests/fixtures/4.2.27/production.json
new file mode 100644
index 0000000..b1f633b
--- /dev/null
+++ b/tests/fixtures/4.2.27/production.json
@@ -0,0 +1 @@
+{"production":[{"type":"inverters","activeCount":34,"readingTime":1618005986,"wNow":5891,"whLifetime":10279087},{"type":"eim","activeCount":0,"measurementType":"production","readingTime":1618006624,"wNow":5814.869,"whLifetime":0.0,"varhLeadLifetime":0.0,"varhLagLifetime":0.0,"vahLifetime":0.0,"rmsCurrent":47.811,"rmsVoltage":243.277,"reactPwr":465.213,"apprntPwr":5816.157,"pwrFactor":1.0,"whToday":0.0,"whLastSevenDays":0.0,"vahToday":0.0,"varhLeadToday":0.0,"varhLagToday":0.0}],"consumption":[{"type":"eim","activeCount":0,"measurementType":"total-consumption","readingTime":1618006624,"wNow":5811.099,"whLifetime":0.0,"varhLeadLifetime":0.0,"varhLagLifetime":0.0,"vahLifetime":0.0,"rmsCurrent":47.534,"rmsVoltage":243.146,"reactPwr":-465.213,"apprntPwr":11557.595,"pwrFactor":0.5,"whToday":0.0,"whLastSevenDays":0.0,"vahToday":0.0,"varhLeadToday":0.0,"varhLagToday":0.0},{"type":"eim","activeCount":0,"measurementType":"net-consumption","readingTime":1618006624,"wNow":-3.769,"whLifetime":0.0,"varhLeadLifetime":0.0,"varhLagLifetime":0.0,"vahLifetime":0.0,"rmsCurrent":0.278,"rmsVoltage":243.015,"reactPwr":-0.0,"apprntPwr":33.697,"pwrFactor":0.0,"whToday":0,"whLastSevenDays":0,"vahToday":0,"varhLeadToday":0,"varhLagToday":0}],"storage":[{"type":"acb","activeCount":0,"readingTime":0,"wNow":0,"whNow":0,"state":"idle"}]}
diff --git a/tests/fixtures/5.0.49/api_v1_production b/tests/fixtures/5.0.49/api_v1_production
new file mode 100644
index 0000000..4aa61ea
--- /dev/null
+++ b/tests/fixtures/5.0.49/api_v1_production
@@ -0,0 +1,6 @@
+{
+ "wattHoursToday": 5046,
+ "wattHoursSevenDays": 445686,
+ "wattHoursLifetime": 88742152,
+ "wattsNow": 4859
+}
diff --git a/tests/fixtures/5.0.49/api_v1_production_inverters b/tests/fixtures/5.0.49/api_v1_production_inverters
new file mode 100644
index 0000000..79a84e9
--- /dev/null
+++ b/tests/fixtures/5.0.49/api_v1_production_inverters
@@ -0,0 +1,282 @@
+[
+ {
+ "serialNumber": "121547059079",
+ "lastReportDate": 1618083244,
+ "devType": 1,
+ "lastReportWatts": 130,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059193",
+ "lastReportDate": 1618083250,
+ "devType": 1,
+ "lastReportWatts": 131,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059360",
+ "lastReportDate": 1618083245,
+ "devType": 1,
+ "lastReportWatts": 131,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060384",
+ "lastReportDate": 1618083250,
+ "devType": 1,
+ "lastReportWatts": 134,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059363",
+ "lastReportDate": 1618083255,
+ "devType": 1,
+ "lastReportWatts": 131,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060650",
+ "lastReportDate": 1618083253,
+ "devType": 1,
+ "lastReportWatts": 131,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059128",
+ "lastReportDate": 1618083262,
+ "devType": 1,
+ "lastReportWatts": 135,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059108",
+ "lastReportDate": 1618083266,
+ "devType": 1,
+ "lastReportWatts": 131,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060593",
+ "lastReportDate": 1618083271,
+ "devType": 1,
+ "lastReportWatts": 133,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059102",
+ "lastReportDate": 1618083273,
+ "devType": 1,
+ "lastReportWatts": 134,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060590",
+ "lastReportDate": 1618083277,
+ "devType": 1,
+ "lastReportWatts": 133,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060671",
+ "lastReportDate": 1618083283,
+ "devType": 1,
+ "lastReportWatts": 135,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059354",
+ "lastReportDate": 1618083287,
+ "devType": 1,
+ "lastReportWatts": 132,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059253",
+ "lastReportDate": 1618083289,
+ "devType": 1,
+ "lastReportWatts": 131,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060392",
+ "lastReportDate": 1618083288,
+ "devType": 1,
+ "lastReportWatts": 133,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059112",
+ "lastReportDate": 1618083286,
+ "devType": 1,
+ "lastReportWatts": 132,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060647",
+ "lastReportDate": 1618083285,
+ "devType": 1,
+ "lastReportWatts": 134,
+ "maxReportWatts": 258
+ },
+ {
+ "serialNumber": "121547060643",
+ "lastReportDate": 1618083284,
+ "devType": 1,
+ "lastReportWatts": 131,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059217",
+ "lastReportDate": 1618083281,
+ "devType": 1,
+ "lastReportWatts": 137,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547055830",
+ "lastReportDate": 1618083280,
+ "devType": 1,
+ "lastReportWatts": 131,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060592",
+ "lastReportDate": 1618083279,
+ "devType": 1,
+ "lastReportWatts": 133,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060758",
+ "lastReportDate": 1618083274,
+ "devType": 1,
+ "lastReportWatts": 130,
+ "maxReportWatts": 255
+ },
+ {
+ "serialNumber": "121547059333",
+ "lastReportDate": 1618083277,
+ "devType": 1,
+ "lastReportWatts": 134,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060727",
+ "lastReportDate": 1618083275,
+ "devType": 1,
+ "lastReportWatts": 134,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059008",
+ "lastReportDate": 1618083240,
+ "devType": 1,
+ "lastReportWatts": 133,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060773",
+ "lastReportDate": 1618083247,
+ "devType": 1,
+ "lastReportWatts": 132,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059202",
+ "lastReportDate": 1618083251,
+ "devType": 1,
+ "lastReportWatts": 133,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060761",
+ "lastReportDate": 1618083260,
+ "devType": 1,
+ "lastReportWatts": 133,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060412",
+ "lastReportDate": 1618083258,
+ "devType": 1,
+ "lastReportWatts": 131,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059355",
+ "lastReportDate": 1618083263,
+ "devType": 1,
+ "lastReportWatts": 131,
+ "maxReportWatts": 258
+ },
+ {
+ "serialNumber": "121547060415",
+ "lastReportDate": 1618083267,
+ "devType": 1,
+ "lastReportWatts": 133,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060670",
+ "lastReportDate": 1618083270,
+ "devType": 1,
+ "lastReportWatts": 134,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060396",
+ "lastReportDate": 1618083269,
+ "devType": 1,
+ "lastReportWatts": 133,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059107",
+ "lastReportDate": 1618083265,
+ "devType": 1,
+ "lastReportWatts": 132,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059889",
+ "lastReportDate": 1618083264,
+ "devType": 1,
+ "lastReportWatts": 133,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547060383",
+ "lastReportDate": 1618083257,
+ "devType": 1,
+ "lastReportWatts": 135,
+ "maxReportWatts": 258
+ },
+ {
+ "serialNumber": "121547060766",
+ "lastReportDate": 1618083242,
+ "devType": 1,
+ "lastReportWatts": 132,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059359",
+ "lastReportDate": 1618083247,
+ "devType": 1,
+ "lastReportWatts": 134,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059357",
+ "lastReportDate": 1618083254,
+ "devType": 1,
+ "lastReportWatts": 133,
+ "maxReportWatts": 257
+ },
+ {
+ "serialNumber": "121547059381",
+ "lastReportDate": 1618083259,
+ "devType": 1,
+ "lastReportWatts": 132,
+ "maxReportWatts": 257
+ }
+]
diff --git a/tests/fixtures/5.0.49/production.json b/tests/fixtures/5.0.49/production.json
new file mode 100644
index 0000000..704875f
--- /dev/null
+++ b/tests/fixtures/5.0.49/production.json
@@ -0,0 +1 @@
+{"production":[{"type":"inverters","activeCount":40,"readingTime":1618084193,"wNow":6335,"whLifetime":88745376}],"storage":[{"type":"acb","activeCount":0,"readingTime":0,"wNow":0,"whNow":0,"state":"idle"}]}
diff --git a/tests/test_envoy_reader.py b/tests/test_envoy_reader.py
new file mode 100644
index 0000000..07a6a3f
--- /dev/null
+++ b/tests/test_envoy_reader.py
@@ -0,0 +1,183 @@
+#!/usr/bin/env python
+"""Tests for envoy_reader.py."""
+# -*- coding: utf-8 -*-
+import json
+from pathlib import Path
+
+import pytest
+import respx
+from httpx import Response
+
+from envoy_reader.envoy_reader import EnvoyReader
+
+
+def _fixtures_dir() -> Path:
+ return Path(__file__).parent / "fixtures"
+
+
+def _load_json_fixture(version, name) -> dict:
+ with open(_fixtures_dir() / version / name, "r") as read_in:
+ return json.load(read_in)
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_with_4_2_27_firmware():
+ """Verify with 4.2.27 firmware."""
+ version = "4.2.27"
+ respx.get("/info.xml").mock(return_value=Response(200, text=""))
+ respx.get("/production.json").mock(
+ return_value=Response(200, json=_load_json_fixture(version, "production.json"))
+ )
+ respx.get("/api/v1/production").mock(
+ return_value=Response(
+ 200, json=_load_json_fixture(version, "api_v1_production")
+ )
+ )
+
+ reader = EnvoyReader("127.0.0.1", inverters=False)
+ await reader.getData()
+
+ assert await reader.consumption() == 5811
+ assert await reader.production() == 5891
+ assert await reader.daily_consumption() == 0
+ assert await reader.daily_production() == 17920
+ assert await reader.seven_days_consumption() == 0
+ assert await reader.seven_days_production() == 276614
+ assert await reader.lifetime_consumption() == 0
+ assert await reader.lifetime_production() == 10279087
+ assert await reader.inverters_production() is None
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_with_5_0_49_firmware():
+ """Verify with 5.0.49 firmware."""
+ version = "5.0.49"
+
+ respx.get("/info.xml").mock(return_value=Response(200, text=""))
+ respx.get("/production.json").mock(
+ return_value=Response(200, json=_load_json_fixture(version, "production.json"))
+ )
+ respx.get("/api/v1/production").mock(
+ return_value=Response(
+ 200, json=_load_json_fixture(version, "api_v1_production")
+ )
+ )
+ respx.get("/api/v1/production/inverters").mock(
+ return_value=Response(
+ 200, json=_load_json_fixture(version, "api_v1_production_inverters")
+ )
+ )
+ reader = EnvoyReader("127.0.0.1", inverters=True)
+ await reader.getData()
+
+ assert (
+ await reader.consumption()
+ == "Consumption data not available for your Envoy device."
+ )
+ assert await reader.production() == 4859
+ assert (
+ await reader.daily_consumption()
+ == "Consumption data not available for your Envoy device."
+ )
+ assert await reader.daily_production() == 5046
+ assert (
+ await reader.seven_days_consumption()
+ == "Consumption data not available for your Envoy device."
+ )
+ assert await reader.seven_days_production() == 445686
+ assert (
+ await reader.lifetime_consumption()
+ == "Consumption data not available for your Envoy device."
+ )
+ assert await reader.lifetime_production() == 88742152
+ assert isinstance(await reader.inverters_production(), dict)
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_with_3_9_36_firmware():
+ """Verify with 3.9.36 firmware."""
+ version = "3.9.36"
+
+ respx.get("/info.xml").mock(return_value=Response(200, text=""))
+ respx.get("/production.json").mock(return_value=Response(404))
+ respx.get("/api/v1/production").mock(
+ return_value=Response(
+ 200, json=_load_json_fixture(version, "api_v1_production")
+ )
+ )
+ respx.get("/api/v1/production/inverters").mock(
+ return_value=Response(
+ 200, json=_load_json_fixture(version, "api_v1_production_inverters")
+ )
+ )
+ reader = EnvoyReader("127.0.0.1", inverters=True)
+ await reader.getData()
+
+ assert (
+ await reader.consumption()
+ == "Consumption data not available for your Envoy device."
+ )
+ assert await reader.production() == 1271
+ assert (
+ await reader.daily_consumption()
+ == "Consumption data not available for your Envoy device."
+ )
+ assert await reader.daily_production() == 1460
+ assert (
+ await reader.seven_days_consumption()
+ == "Consumption data not available for your Envoy device."
+ )
+ assert await reader.seven_days_production() == 130349
+ assert (
+ await reader.lifetime_consumption()
+ == "Consumption data not available for your Envoy device."
+ )
+ assert await reader.lifetime_production() == 6012540
+ assert isinstance(await reader.inverters_production(), dict)
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_with_3_17_3_firmware():
+ """Verify with 3.17.3 firmware."""
+ version = "3.17.3"
+
+ respx.get("/info.xml").mock(return_value=Response(200, text=""))
+ respx.get("/production.json").mock(return_value=Response(404))
+ respx.get("/api/v1/production").mock(
+ return_value=Response(
+ 200, json=_load_json_fixture(version, "api_v1_production")
+ )
+ )
+ respx.get("/api/v1/production/inverters").mock(
+ return_value=Response(
+ 200, json=_load_json_fixture(version, "api_v1_production_inverters")
+ )
+ )
+ reader = EnvoyReader("127.0.0.1", inverters=True)
+ await reader.getData()
+
+ assert (
+ await reader.consumption()
+ == "Consumption data not available for your Envoy device."
+ )
+ assert await reader.production() == 5463
+ assert (
+ await reader.daily_consumption()
+ == "Consumption data not available for your Envoy device."
+ )
+ assert await reader.daily_production() == 5481
+ assert (
+ await reader.seven_days_consumption()
+ == "Consumption data not available for your Envoy device."
+ )
+ assert await reader.seven_days_production() == 389581
+ assert (
+ await reader.lifetime_consumption()
+ == "Consumption data not available for your Envoy device."
+ )
+ assert await reader.lifetime_production() == 93706280
+ assert isinstance(await reader.inverters_production(), dict)