├── .coveragerc ├── .vscode ├── requirements.dev.txt ├── settings.json ├── tasks.json ├── launch.json └── CONTRIBUTING.md ├── doc ├── wnsm1.png ├── wnsm2.png ├── wnsm3.png ├── wnsm4.png ├── wnsm5.png └── wnsm6.png ├── .gitignore ├── hacs.json ├── example ├── secrets.yaml └── configuration.yaml ├── .devcontainer ├── configuration.yaml └── devcontainer.json ├── tests ├── bandit.yaml ├── requirements.txt ├── test_init.py ├── test_resources │ ├── __init__.py │ ├── app-config.json │ └── smartmeter-web.wienernetze.at.html ├── setup.cfg └── it │ ├── test_api.py │ └── __init__.py ├── custom_components └── wnsm │ ├── api │ ├── __init__.py │ ├── errors.py │ ├── constants.py │ └── client.py │ ├── translations │ └── en.json │ ├── manifest.json │ ├── __init__.py │ ├── statistics_sensor.py │ ├── sensor.py │ ├── utils.py │ ├── config_flow.py │ ├── const.py │ ├── wnsm_sensor.py │ ├── AsyncSmartmeter.py │ └── importer.py ├── .github ├── workflows │ ├── hassfest.yml │ ├── validate.yml │ ├── release-drafter.yml │ ├── release.yml │ └── test.yml ├── release-drafter.yml ├── CONTRIBUTORS.md └── codecov.yml ├── manage └── update_manifest.py ├── utils └── purge_last_x_days.py ├── .pre-commit-config.yaml └── README.md /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | tests/* -------------------------------------------------------------------------------- /.vscode/requirements.dev.txt: -------------------------------------------------------------------------------- 1 | black>=22.12.0 2 | pylint>=2.15.9 -------------------------------------------------------------------------------- /doc/wnsm1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarwinsBuddy/WienerNetzeSmartmeter/HEAD/doc/wnsm1.png -------------------------------------------------------------------------------- /doc/wnsm2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarwinsBuddy/WienerNetzeSmartmeter/HEAD/doc/wnsm2.png -------------------------------------------------------------------------------- /doc/wnsm3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarwinsBuddy/WienerNetzeSmartmeter/HEAD/doc/wnsm3.png -------------------------------------------------------------------------------- /doc/wnsm4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarwinsBuddy/WienerNetzeSmartmeter/HEAD/doc/wnsm4.png -------------------------------------------------------------------------------- /doc/wnsm5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarwinsBuddy/WienerNetzeSmartmeter/HEAD/doc/wnsm5.png -------------------------------------------------------------------------------- /doc/wnsm6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarwinsBuddy/WienerNetzeSmartmeter/HEAD/doc/wnsm6.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | **/__pycache__ 3 | venv/ 4 | .idea/ 5 | .coverage 6 | .pytest_cache 7 | htmlcov 8 | coverage.lcov 9 | coverage.xml -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wnsm", 3 | "render_readme": true, 4 | "zip_release": true, 5 | "filename": "wnsm.zip", 6 | "country": "AT" 7 | } 8 | -------------------------------------------------------------------------------- /example/secrets.yaml: -------------------------------------------------------------------------------- 1 | vsm_username: example@mail.com 2 | vsm_password: 12345 3 | vsm_zaehlpunkt_1: AT0010000000000000001000000000001 4 | vsm_zaehlpunkt_2: AT0010000000000000001000000000002 5 | vsm_zaehlpunkt_3: AT0010000000000000001000000000003 -------------------------------------------------------------------------------- /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | 3 | logger: 4 | default: info 5 | logs: 6 | custom_components.integration_blueprint: debug 7 | 8 | # If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) 9 | # debugpy: -------------------------------------------------------------------------------- /tests/bandit.yaml: -------------------------------------------------------------------------------- 1 | # https://bandit.readthedocs.io/en/latest/config.html 2 | 3 | tests: 4 | - B108 5 | - B306 6 | - B307 7 | - B313 8 | - B314 9 | - B315 10 | - B316 11 | - B317 12 | - B318 13 | - B319 14 | - B320 15 | - B325 16 | - B602 17 | - B604 18 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest==8.0.2 2 | pytest_mock==3.14.0 3 | coverage==7.4.3 4 | requests-mock==1.11.0 5 | requests 6 | homeassistant==2024.3.3 7 | pytest-homeassistant-custom-component==0.13.109 8 | black>=22.12.0 9 | pylint==3.0.2 10 | apipkg==3.0.2 11 | importlib-resources==6.1.0 12 | lxml==5.2.2 13 | flake8==6.1.0 14 | -------------------------------------------------------------------------------- /custom_components/wnsm/api/__init__.py: -------------------------------------------------------------------------------- 1 | """Unofficial Python wrapper for the Wiener Netze Smart Meter private API.""" 2 | from importlib.metadata import version 3 | 4 | from .client import Smartmeter 5 | 6 | try: 7 | __version__ = version(__name__) 8 | except Exception: # pylint: disable=broad-except 9 | pass 10 | 11 | __all__ = ["Smartmeter"] 12 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | if: github.repository_owner == 'DarwinsBuddy' 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v4" 15 | - uses: "home-assistant/actions/hassfest@master" -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Test component setup.""" 2 | # from homeassistant.tests.setup import async_setup_component 3 | 4 | # from ..custom_components.wnsm.const import DOMAIN # pylint: disable=relative-beyond-top-level 5 | 6 | 7 | # async def test_async_setup(hass): 8 | # """Test the component gets setup.""" 9 | # assert await async_setup_component(hass, DOMAIN, {}) is True 10 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hacs 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" -------------------------------------------------------------------------------- /example/configuration.yaml: -------------------------------------------------------------------------------- 1 | # Example configuration.yaml entry 2 | sensor: 3 | - platform: wnsm 4 | username: !secret vsm_username 5 | password: !secret vsm_password 6 | device_id: !secret vsm_zaehlpunkt_1 7 | - platform: wnsm 8 | username: !secret vsm_username 9 | password: !secret vsm_password 10 | device_id: !secret vsm_zaehlpunkt_2 11 | - platform: wnsm 12 | username: !secret vsm_username 13 | password: !secret vsm_password 14 | device_id: !secret vsm_zaehlpunkt_3 15 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - main 8 | 9 | jobs: 10 | update_release_draft: 11 | permissions: 12 | contents: write 13 | pull-requests: write 14 | runs-on: ubuntu-latest 15 | steps: 16 | # Drafts your next Release notes as Pull Requests are merged into "master" 17 | - uses: release-drafter/release-drafter@v6 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /custom_components/wnsm/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "auth": "Username/password invalid", 5 | "connection_error": "Unable to connect" 6 | }, 7 | "flow_title": "Wiener Netze Smartmeter", 8 | "step": { 9 | "user": { 10 | "title": "Wiener Netze Smartmeter Authentication", 11 | "description": "Enter your Wiener Netze Smartmeter credentials", 12 | "data": { 13 | "username": "Username", 14 | "password": "Password" 15 | } 16 | } 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /custom_components/wnsm/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "wnsm", 3 | "name": "WienerNetzeSmartmeter", 4 | "codeowners": [ 5 | "@DarwinsBuddy" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [ 9 | "recorder" 10 | ], 11 | "documentation": "https://github.com/DarwinsBuddy/WienerNetzeSmartmeter", 12 | "iot_class": "calculated", 13 | "issue_tracker": "https://github.com/DarwinsBuddy/WienerNetzeSmartmeter/issues", 14 | "requirements": [ 15 | "lxml", 16 | "requests" 17 | ], 18 | "version": "1.0.1" 19 | } 20 | -------------------------------------------------------------------------------- /custom_components/wnsm/__init__.py: -------------------------------------------------------------------------------- 1 | """Set up the Wiener Netze SmartMeter Integration component.""" 2 | from homeassistant import core, config_entries 3 | from homeassistant.core import DOMAIN 4 | 5 | 6 | async def async_setup_entry( 7 | hass: core.HomeAssistant, 8 | entry: config_entries.ConfigEntry 9 | ) -> bool: 10 | """Set up platform from a ConfigEntry.""" 11 | hass.data.setdefault(DOMAIN, {}) 12 | hass.data[DOMAIN][entry.entry_id] = entry.data 13 | 14 | # Forward the setup to the sensor platform. 15 | await hass.config_entries.async_forward_entry_setups(entry, ["sensor"]) 16 | 17 | return True 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": true, 3 | "python.linting.enabled": true, 4 | "python.linting.pylintArgs": [ 5 | "--disable=W0107" 6 | ], 7 | "files.associations": { 8 | "*.yaml": "home-assistant" 9 | }, 10 | "python.analysis.typeCheckingMode": "off", 11 | "python.testing.unittestEnabled": false, 12 | "python.testing.pytestEnabled": true, 13 | "python.testing.pytestArgs": [ 14 | "-v", 15 | "--cov=wnsm", 16 | "--cov-report=html", 17 | "--cov-report=xml", 18 | "--cov-report=term", 19 | "--pdb", 20 | "tests/" 21 | ] 22 | } -------------------------------------------------------------------------------- /tests/test_resources/__init__.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from urllib import parse 3 | 4 | 5 | def post_data_matcher(expected: dict = None): 6 | if expected is None: 7 | expected = dict() 8 | 9 | def match(request: requests.PreparedRequest): 10 | flag = dict(parse.parse_qsl(request.body)) == expected 11 | if not flag: 12 | print(f'ACTUAL: {dict(parse.parse_qsl(request.body))}') 13 | print(f'EXPECTED: {expected}') 14 | return flag 15 | 16 | return match 17 | 18 | 19 | def json_matcher(expected: dict = None): 20 | if expected is None: 21 | expected = dict() 22 | 23 | def match(request: requests.Request): 24 | return request.json() == expected 25 | 26 | return match 27 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: v$NEXT_MINOR_VERSION 2 | tag-template: v$NEXT_MINOR_VERSION 3 | categories: 4 | - title: ⬆️Dependencies 5 | label: dependencies 6 | - title: 🚀 Features 7 | labels: 8 | - 'feature' 9 | - 'enhancement' 10 | - title: 🐛 Bug Fixes 11 | labels: 12 | - 'patch' 13 | - 'fix' 14 | - 'bug' 15 | - 'bugfix' 16 | - title: 🧰 Maintenance 17 | label: chore 18 | 19 | version-resolver: 20 | major: 21 | labels: 22 | - 'major' 23 | minor: 24 | labels: 25 | - 'minor' 26 | - 'feature' 27 | patch: 28 | labels: 29 | - 'patch' 30 | - 'dependencies' 31 | - 'chore' 32 | - 'bug' 33 | - 'fix' 34 | - 'bugfix' 35 | default: patch 36 | 37 | template: | 38 | ## Changes 39 | $CHANGES -------------------------------------------------------------------------------- /manage/update_manifest.py: -------------------------------------------------------------------------------- 1 | """Update the manifest file.""" 2 | import sys 3 | import json 4 | import os 5 | 6 | 7 | def update_manifest(): 8 | """Update the manifest file.""" 9 | version = "0.0.0" 10 | for index, value in enumerate(sys.argv): 11 | if value in ["--version", "-V"]: 12 | version = sys.argv[index + 1] 13 | 14 | with open( 15 | f"{os.getcwd()}/custom_components/wnsm/manifest.json", encoding="utf-8" 16 | ) as manifestfile: 17 | manifest = json.load(manifestfile) 18 | 19 | manifest["version"] = version 20 | 21 | with open( 22 | f"{os.getcwd()}/custom_components/wnsm/manifest.json", "w", encoding="utf-8" 23 | ) as manifestfile: 24 | manifestfile.write(json.dumps(manifest, indent=4, sort_keys=True)) 25 | 26 | 27 | update_manifest() 28 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "ludeeus/container:integration-debian", 3 | "name": "wnsm integration development", 4 | "context": "..", 5 | "appPort": [ 6 | "9123:8123" 7 | ], 8 | "postCreateCommand": "container install", 9 | "extensions": [ 10 | "ms-python.python", 11 | "github.vscode-pull-request-github", 12 | "ryanluker.vscode-coverage-gutters", 13 | "ms-python.vscode-pylance" 14 | ], 15 | "settings": { 16 | "files.eol": "\n", 17 | "editor.tabSize": 4, 18 | "terminal.integrated.shell.linux": "/bin/bash", 19 | "python.pythonPath": "/usr/bin/python3", 20 | "python.analysis.autoSearchPaths": false, 21 | "python.linting.pylintEnabled": true, 22 | "python.linting.enabled": true, 23 | "python.formatting.provider": "black", 24 | "editor.formatOnPaste": false, 25 | "editor.formatOnSave": true, 26 | "editor.formatOnType": true, 27 | "files.trimTrailingWhitespace": true 28 | } 29 | } -------------------------------------------------------------------------------- /custom_components/wnsm/api/errors.py: -------------------------------------------------------------------------------- 1 | """Smartmeter Errors.""" 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | class SmartmeterError(Exception): 8 | """Generic Error for Smartmeter.""" 9 | 10 | def __init__(self, msg, code=None, error_response=""): 11 | """Creates a Smartmeter error with msg, code and error_response.""" 12 | self.code = code or 0 13 | self.error_response = error_response 14 | super().__init__(msg) 15 | 16 | @property 17 | def msg(self): 18 | """Return msg.""" 19 | return self.args[0] 20 | 21 | 22 | class SmartmeterLoginError(SmartmeterError): 23 | """Raised when login fails.""" 24 | 25 | 26 | class SmartmeterConnectionError(SmartmeterError): 27 | """Raised due to network connectivity-related issues.""" 28 | 29 | 30 | class SmartmeterQueryError(SmartmeterError): 31 | """Raised if query went not as expected.""" 32 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 9123", 6 | "type": "shell", 7 | "command": "container start", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Run Home Assistant configuration against /config", 12 | "type": "shell", 13 | "command": "container check", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Upgrade Home Assistant to latest dev", 18 | "type": "shell", 19 | "command": "container install", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Install a specific version of Home Assistant", 24 | "type": "shell", 25 | "command": "container set-version", 26 | "problemMatcher": [] 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /.github/CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | ## Special thanks for all the people who had helped this project so far and spent their valuable time on this matter 4 | 5 | ### Active contributors 6 | * [DarwinsBuddy](https://github.com/DarwinsBuddy) 7 | * [reox](https://github.com/reox) 8 | * [TheRealVira](https://github.com/TheRealVira) 9 | * [tschoerk](https://github.com/tschoerk) 10 | * [W-M-B](https://github.com/W-M-B) 11 | 12 | ### Former contributors 13 | * [platysma](https://github.com/platysma) 14 | * [florianL21](https://github.com/florianL21) 15 | 16 | ## I would like to join this list. How can I help the project? 17 | 18 | > Here you could make a call to people who would like to contribute to the project. Below, expose what you expect people to do. 19 | 20 | We're currently looking for contributions for the following: 21 | 22 | - Bug fixes 23 | - Features 24 | - etc... 25 | 26 | For more information, please refer to our [CONTRIBUTING](.vscode/CONTRIBUTING.md) guide. 27 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: true 3 | max_report_age: false 4 | coverage: 5 | status: 6 | project: 7 | default: # This can be anything, but it needs to exist as the name 8 | # basic settings 9 | target: 80% 10 | threshold: 1% 11 | base: auto 12 | ignore: 13 | - "custom_components/wnsm/translations" 14 | component_management: 15 | default_rules: # default rules that will be inherited by all components 16 | statuses: 17 | - type: project # in this case every component that doesn't have a status defined will have a project type one 18 | target: auto 19 | individual_components: 20 | - component_id: module_api 21 | name: api # this is a display name, and can be changed freely 22 | paths: 23 | - "custom_components/wnsm/api/**" 24 | - component_id: module_integration 25 | name: integration # this is a display name, and can be changed freely 26 | paths: 27 | - "custom_components/wnsm/[^/api/].*" 28 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug tests", 6 | "type": "python", 7 | "request": "launch", 8 | "program": "${file}", 9 | "purpose": ["debug-test"], 10 | "console": "integratedTerminal", 11 | "env": { 12 | "PYTEST_ADDOPTS": "--no-cov" 13 | } 14 | }, 15 | { 16 | "name": "Python: Attach Local", 17 | "type": "python", 18 | "request": "attach", 19 | "port": 5678, 20 | "host": "localhost", 21 | "pathMappings": [ 22 | { 23 | "localRoot": "${workspaceFolder}", 24 | "remoteRoot": "." 25 | } 26 | ] 27 | }, 28 | { 29 | "name": "Python: Attach Remote", 30 | "type": "python", 31 | "request": "attach", 32 | "port": 5678, 33 | "host": "homeassistant.local", 34 | "pathMappings": [ 35 | { 36 | "localRoot": "${workspaceFolder}", 37 | "remoteRoot": "/usr/src/homeassistant" 38 | } 39 | ] 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /utils/purge_last_x_days.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | 4 | def purge(database:str, days: int, sensor_id: str): 5 | conn = sqlite3.connect(database) 6 | cursor = conn.cursor() 7 | 8 | query = f""" 9 | DELETE FROM statistics 10 | WHERE metadata_id IN ( 11 | SELECT id FROM statistics_meta WHERE statistic_id = '{sensor_id}' 12 | ) 13 | AND start_ts >= strftime('%s', 'now', '-{days} days'); 14 | """ 15 | 16 | # Execute the query 17 | cursor.execute(query) 18 | 19 | # Commit the changes and close the connection 20 | conn.commit() 21 | conn.close() 22 | 23 | if __name__ == "__main__": 24 | import argparse 25 | parser = argparse.ArgumentParser(description='Purge the last x days of data from the statistics table') 26 | parser.add_argument('-db', '--database', type=str, help='Path to the SQLite database', default="home-assistant_v2.db") 27 | parser.add_argument('-d', '--days', type=int, help='Number of days to keep', default=2) 28 | parser.add_argument('-s', '--sensor', type=str, help='Name in the statistics_meta table', default="sensor.at00100000000000000010000XXXXXXX_statistics") 29 | args = parser.parse_args() 30 | purge(args.database, args.days, args.sensor) 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release_zip_file: 9 | name: Prepare release asset 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: 3.11 19 | 20 | - name: Get version 21 | id: version 22 | uses: home-assistant/actions/helpers/version@master 23 | 24 | - name: "Set version number" 25 | run: | 26 | python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} 27 | # Pack the wnsm dir as a zip and upload to the release 28 | - name: ZIP wnsm Dir 29 | run: | 30 | cd ${{ github.workspace }}/custom_components/wnsm 31 | zip wnsm.zip -r ./ 32 | - name: Upload zip to release 33 | uses: svenstaro/upload-release-action@v1-release 34 | 35 | with: 36 | repo_token: ${{ secrets.GITHUB_TOKEN }} 37 | file: ${{ github.workspace }}/custom_components/wnsm/wnsm.zip 38 | asset_name: wnsm.zip 39 | tag: ${{ github.ref }} 40 | overwrite: true -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.11"] 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | cache: 'pip' 19 | - name: Install dependencies (if changed) 20 | run: pip install -r tests/requirements.txt 21 | - name: Lint with flake8 22 | run: | 23 | # stop the build if there are Python syntax errors or undefined names 24 | flake8 custom_components/wnsm --count --select=E9,F63,F7,F82 --show-source --statistics --config tests/setup.cfg 25 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 26 | flake8 custom_components/wnsm --count --exit-zero --max-complexity=10 --statistics --config tests/setup.cfg 27 | - name: Test with pytest 28 | run: | 29 | coverage run -m pytest && coverage report -m && coverage xml 30 | - name: Upload coverage reports to Codecov 31 | uses: codecov/codecov-action@v5 32 | with: 33 | fail_ci_if_error: false 34 | files: coverage.xml 35 | token: ${{ secrets.CODECOV_TOKEN }} 36 | -------------------------------------------------------------------------------- /custom_components/wnsm/statistics_sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from warnings import deprecated 3 | 4 | from homeassistant.components.sensor import SensorEntity, SensorStateClass 5 | from homeassistant.const import UnitOfEnergy 6 | 7 | from .wnsm_sensor import WNSMSensor 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | @deprecated("Remove this sensor from your configuration.") 13 | class StatisticsSensor(WNSMSensor, SensorEntity): 14 | 15 | def __init__(self, username: str, password: str, zaehlpunkt: str) -> None: 16 | super().__init__(username, password, zaehlpunkt) 17 | self._attr_state_class = SensorStateClass.MEASUREMENT 18 | self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR 19 | 20 | @staticmethod 21 | def statistics(s: str) -> str: 22 | return f'{s}_statistics' 23 | 24 | @property 25 | def icon(self) -> str: 26 | return "mdi:meter-electric-outline" 27 | 28 | @property 29 | def _id(self) -> str: 30 | return StatisticsSensor.statistics(super()._id) 31 | 32 | @property 33 | def name(self) -> str: 34 | return StatisticsSensor.statistics(super().name) 35 | 36 | @property 37 | def unique_id(self) -> str: 38 | """Return the unique ID of the sensor.""" 39 | return StatisticsSensor.statistics(super().unique_id) 40 | 41 | async def async_update(self): 42 | """ 43 | disable sensor 44 | """ 45 | self._available = False 46 | _LOGGER.warning("StatisticsSensor disabled. Please remove it from your configuration.") -------------------------------------------------------------------------------- /tests/setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | source = 3 | custom_components 4 | testpaths = 5 | tests 6 | 7 | [coverage:report] 8 | exclude_lines = 9 | pragma: no cover 10 | raise NotImplemented() 11 | if __name__ == '__main__': 12 | main() 13 | show_missing = true 14 | 15 | [tool:pytest] 16 | testpaths = tests 17 | norecursedirs = .git 18 | addopts = 19 | --import-mode=prepend 20 | --strict-markers 21 | --cov=wnsm 22 | --cov-branch 23 | --cov-report=html 24 | 25 | [flake8] 26 | # https://github.com/ambv/black#line-length 27 | max-line-length = 127 28 | # E501: line too long 29 | # W503: Line break occurred before a binary operator 30 | # E203: Whitespace before ':' 31 | # D202 No blank lines allowed after function docstring 32 | # W504 line break after binary operator 33 | ignore = 34 | E501, 35 | W503, 36 | E203, 37 | D202, 38 | W504 39 | 40 | [isort] 41 | # https://github.com/timothycrosley/isort 42 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 43 | # splits long import on multiple lines indented by 4 spaces 44 | multi_line_output = 3 45 | include_trailing_comma=True 46 | force_grid_wrap=0 47 | use_parentheses=True 48 | line_length=88 49 | indent = " " 50 | # by default isort don't check module indexes 51 | not_skip = __init__.py 52 | # will group `import x` and `from x import` of the same module. 53 | force_sort_within_sections = true 54 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 55 | default_section = THIRDPARTY 56 | known_first_party = custom_components,tests 57 | forced_separate = tests 58 | combine_as_imports = true 59 | 60 | [mypy] 61 | python_version = 3.10 62 | ignore_errors = true 63 | follow_imports = silent 64 | ignore_missing_imports = true 65 | warn_incomplete_stub = true 66 | warn_redundant_casts = true 67 | warn_unused_configs = true 68 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v2.3.0 4 | hooks: 5 | - id: pyupgrade 6 | args: [--py37-plus] 7 | - repo: https://github.com/psf/black 8 | rev: 19.10b0 9 | hooks: 10 | - id: black 11 | args: 12 | - --safe 13 | - --quiet 14 | files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ 15 | - repo: https://github.com/codespell-project/codespell 16 | rev: v1.16.0 17 | hooks: 18 | - id: codespell 19 | args: 20 | - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing 21 | - --skip="./.*,*.csv,*.json" 22 | - --quiet-level=2 23 | exclude_types: [csv, json] 24 | - repo: https://gitlab.com/pycqa/flake8 25 | rev: 3.8.1 26 | hooks: 27 | - id: flake8 28 | additional_dependencies: 29 | - flake8-docstrings==1.5.0 30 | - pydocstyle==5.0.2 31 | files: ^(homeassistant|script|tests)/.+\.py$ 32 | - repo: https://github.com/PyCQA/bandit 33 | rev: 1.6.2 34 | hooks: 35 | - id: bandit 36 | args: 37 | - --quiet 38 | - --format=custom 39 | - --configfile=tests/bandit.yaml 40 | files: ^(homeassistant|script|tests)/.+\.py$ 41 | - repo: https://github.com/pre-commit/mirrors-isort 42 | rev: v4.3.21 43 | hooks: 44 | - id: isort 45 | - repo: https://github.com/pre-commit/pre-commit-hooks 46 | rev: v2.4.0 47 | hooks: 48 | - id: check-executables-have-shebangs 49 | stages: [manual] 50 | - id: check-json 51 | - repo: https://github.com/pre-commit/mirrors-mypy 52 | rev: v0.770 53 | hooks: 54 | - id: mypy 55 | args: 56 | - --pretty 57 | - --show-error-codes 58 | - --show-error-context 59 | -------------------------------------------------------------------------------- /tests/test_resources/app-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": "PROD", 3 | "production": true, 4 | "logWienUrl": "https://log.wien/", 5 | "logwienprofileurl": "https://log.wien/profile", 6 | "onlineServiceurl": "https://service.wienernetze.at", 7 | "showBreadcrumbs": true, 8 | "wnsmApiUrl": "https://service.wienernetze.at/sm/api", 9 | "redirecturl": "https://www.wienernetze.at/", 10 | "smurl": "https://smartmeter-web.wienernetze.at/", 11 | "smb2bUrl": "https://smartmeter-business.wienernetze.at/", 12 | "infoUrls": { 13 | "faq": "https://www.wienernetze.at/smart-meter-faq", 14 | "impressum": "https://www.wienernetze.at/impressum", 15 | "datenschutz": "https://www.wienernetze.at/datenschutz", 16 | "barrierefreiheit": "https://www.wienernetze.at/erkl%C3%A4rung-zur-barrierefreiheit", 17 | "energiespartipps": "https://www.wienernetze.at/energiespartipps", 18 | "kontakt": "https://www.wienernetze.at/kontakt" 19 | }, 20 | "keycloak": { 21 | "url": "https://log.wien/auth", 22 | "realm": "logwien", 23 | "clientId": "wn-smartmeter" 24 | }, 25 | "b2bApiUrl": "https://api.wstw.at/gateway/WN_SMART_METER_PORTAL_API_B2B/1.0", 26 | "b2bApiKey": "93d5d520-7cc8-11eb-99bc-ba811041b5f6", 27 | "b2cApiUrl": "https://api.wstw.at/gateway/WN_SMART_METER_PORTAL_API_B2C/1.0", 28 | "b2cApiKey": "afb0be74-6455-44f5-a34d-6994223020ba", 29 | "legalWebSrcUrl": "https://cdn1.legalweb.io/afa8624d-98aa-4d51-ac05-4fd65398af46.js", 30 | "legalWebCssUrl": "https://cdn1.legalweb.io/afa8624d-98aa-4d51-ac05-4fd65398af46.css", 31 | "disableChatbot": true, 32 | "alertMissingTranslations": false, 33 | "survey": { 34 | "enabled": false, 35 | "surveyBaseUrl": "https://log.wien/survey/index.php/" 36 | }, 37 | "logwienApiUrl": "https://log.wien/rest/logwien", 38 | "wnos": { 39 | "apiUrl": "https://service.wienernetze.at/api" 40 | }, 41 | "customerDataUrl": "https://service.wienernetze.at/#/kundendaten" 42 | } -------------------------------------------------------------------------------- /custom_components/wnsm/sensor.py: -------------------------------------------------------------------------------- 1 | """ 2 | WienerNetze Smartmeter sensor platform 3 | """ 4 | import collections.abc 5 | from datetime import timedelta 6 | from typing import Optional 7 | 8 | import homeassistant.helpers.config_validation as cv 9 | import voluptuous as vol 10 | from homeassistant import core, config_entries 11 | from homeassistant.components.sensor import ( 12 | PLATFORM_SCHEMA 13 | ) 14 | from homeassistant.const import ( 15 | CONF_USERNAME, 16 | CONF_PASSWORD, 17 | CONF_DEVICE_ID 18 | ) 19 | from homeassistant.core import DOMAIN 20 | from homeassistant.helpers.typing import ( 21 | ConfigType, 22 | DiscoveryInfoType, 23 | ) 24 | from .const import CONF_ZAEHLPUNKTE 25 | from .wnsm_sensor import WNSMSensor 26 | # Time between updating data from Wiener Netze 27 | SCAN_INTERVAL = timedelta(minutes=60 * 6) 28 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 29 | { 30 | vol.Required(CONF_USERNAME): cv.string, 31 | vol.Required(CONF_PASSWORD): cv.string, 32 | vol.Required(CONF_DEVICE_ID): cv.string, 33 | } 34 | ) 35 | 36 | 37 | async def async_setup_entry( 38 | hass: core.HomeAssistant, 39 | config_entry: config_entries.ConfigEntry, 40 | async_add_entities, 41 | ): 42 | """Setup sensors from a config entry created in the integrations UI.""" 43 | config = hass.data[DOMAIN][config_entry.entry_id] 44 | wnsm_sensors = [ 45 | WNSMSensor(config[CONF_USERNAME], config[CONF_PASSWORD], zp["zaehlpunktnummer"]) 46 | for zp in config[CONF_ZAEHLPUNKTE] 47 | ] 48 | async_add_entities(wnsm_sensors, update_before_add=True) 49 | 50 | 51 | async def async_setup_platform( 52 | hass: core.HomeAssistant, # pylint: disable=unused-argument 53 | config: ConfigType, 54 | async_add_entities: collections.abc.Callable, 55 | discovery_info: Optional[ 56 | DiscoveryInfoType 57 | ] = None, # pylint: disable=unused-argument 58 | ) -> None: 59 | """Set up the sensor platform by adding it into configuration.yaml""" 60 | wnsm_sensor = WNSMSensor(config[CONF_USERNAME], config[CONF_PASSWORD], config[CONF_DEVICE_ID]) 61 | async_add_entities([wnsm_sensor], update_before_add=True) 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wiener Netze Smartmeter Integration for Home Assistant 2 | 3 | [![codecov](https://codecov.io/gh/DarwinsBuddy/WienerNetzeSmartmeter/branch/main/graph/badge.svg?token=ACYNOG1WFW)](https://codecov.io/gh/DarwinsBuddy/WienerNetzeSmartmeter) 4 | ![Tests](https://github.com/DarwinsBuddy/WienerNetzeSmartMeter/actions/workflows/test.yml/badge.svg) 5 | 6 | ![Hassfest](https://github.com/DarwinsBuddy/WienerNetzeSmartMeter/actions/workflows/hassfest.yml/badge.svg) 7 | ![Validate](https://github.com/DarwinsBuddy/WienerNetzeSmartMeter/actions/workflows/validate.yml/badge.svg) 8 | ![Release](https://github.com/DarwinsBuddy/WienerNetzeSmartMeter/actions/workflows/release.yml/badge.svg) 9 | 10 | ## About 11 | 12 | This repo contains a custom component for [Home Assistant](https://www.home-assistant.io) for exposing a sensor 13 | providing information about a registered [WienerNetze Smartmeter](https://www.wienernetze.at/smartmeter). 14 | 15 | ## FAQs 16 | [FAQs](https://github.com/DarwinsBuddy/WienerNetzeSmartmeter/discussions/19) 17 | 18 | ## Installation 19 | 20 | ### Manual 21 | 22 | Copy `/custom_components/wnsm` into `/config/custom_components` 23 | 24 | ### HACS 25 | 1. Search for `Wiener Netze Smart Meter` or `wnsm` in HACS 26 | 2. Install 27 | 3. ... 28 | 4. Profit! 29 | 30 | ## Configure 31 | 32 | You can choose between ui configuration or manual (by adding your credentials to `configuration.yaml` and `secrets.yaml` resp.) 33 | After successful configuration you can add sensors to your favourite dashboard, or even to your energy dashboard to track your total consumption. 34 | 35 | ### UI 36 | Settings 37 | Integrations 38 | Add Integration 39 | Search for WienerNetze 40 | Authenticate with your credentials 41 | Observe that all your smartmeters got imported 42 | 43 | ### Manual 44 | See [Example configuration files](https://github.com/DarwinsBuddy/WienerNetzeSmartmeter/blob/main/example/configuration.yaml) 45 | ## Copyright 46 | 47 | This integration uses the API of https://www.wienernetze.at/smartmeter 48 | All rights regarding the API are reserved by [Wiener Netze](https://www.wienernetze.at/impressum) 49 | 50 | Special thanks to [platrysma](https://github.com/platysma) 51 | for providing me a starting point [vienna-smartmeter](https://github.com/platysma/vienna-smartmeter) 52 | and especially [florianL21](https://github.com/florianL21/) 53 | for his [fork](https://github.com/florianL21/vienna-smartmeter/network) 54 | 55 | -------------------------------------------------------------------------------- /custom_components/wnsm/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions and convenience methods to avoid boilerplate 3 | """ 4 | from __future__ import annotations 5 | from functools import reduce 6 | from datetime import timezone, timedelta, datetime 7 | import logging 8 | 9 | 10 | def today(tz: None | timezone = None) -> datetime: 11 | """ 12 | today's timestamp (start of day) 13 | """ 14 | return datetime.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) 15 | 16 | 17 | def before(timestamp=None, days=1) -> datetime: 18 | """ 19 | subtract {days} days from given datetime (default: 1) 20 | """ 21 | if timestamp is None: 22 | timestamp = today() 23 | return timestamp - timedelta(days=days) 24 | 25 | 26 | def strint(string: str) -> int | None: 27 | """ 28 | convenience function for easily convert None-able str to in 29 | """ 30 | if string is not None and string.isdigit(): 31 | return int(string) 32 | return string 33 | 34 | 35 | def is_valid_access(data: list | dict, accessor: str | int) -> bool: 36 | """ 37 | convenience function for double-checking if attribute of list or dict can be accessed 38 | """ 39 | if isinstance(accessor, int) and isinstance(data, list): 40 | return accessor < len(data) 41 | if isinstance(accessor, str) and isinstance(data, dict): 42 | return accessor in data 43 | else: 44 | return False 45 | 46 | 47 | def dict_path(path: str, dictionary: dict) -> str | None: 48 | """ 49 | convenience function for accessing nested attributes within a dict 50 | """ 51 | try: 52 | return reduce( 53 | lambda acc, i: acc[i] if is_valid_access(acc, i) else None, 54 | [strint(s) for s in path.split(".")], 55 | dictionary, 56 | ) 57 | except KeyError as exception: 58 | logging.warning("Could not find key '%s' in response", exception.args[0]) 59 | except Exception as exception: # pylint: disable=broad-except 60 | logging.exception(exception) 61 | return None 62 | 63 | 64 | def safeget(dct, *keys, default=None): 65 | for key in keys: 66 | try: 67 | dct = dct[key] 68 | except KeyError: 69 | return default 70 | return dct 71 | 72 | 73 | def translate_dict( 74 | dictionary: dict, attrs_list: list[tuple[str, str]] 75 | ) -> dict[str, str]: 76 | """ 77 | Given a response dictionary and an attribute mapping (with nested accessors separated by '.') 78 | returns a dictionary including all "picked" attributes addressed by attrs_list 79 | """ 80 | result = {} 81 | for src, destination in attrs_list: 82 | value = dict_path(src, dictionary) 83 | if value is not None: 84 | result[destination] = value 85 | return result 86 | -------------------------------------------------------------------------------- /custom_components/wnsm/config_flow.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setting up config flow for homeassistant 3 | """ 4 | import logging 5 | from typing import Any, Optional 6 | 7 | import homeassistant.helpers.config_validation as cv 8 | import voluptuous as vol 9 | from homeassistant import config_entries 10 | from homeassistant.const import CONF_USERNAME, CONF_PASSWORD 11 | 12 | from .api import Smartmeter 13 | from .const import ATTRS_ZAEHLPUNKTE_CALL, DOMAIN, CONF_ZAEHLPUNKTE 14 | from .utils import translate_dict 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | AUTH_SCHEMA = vol.Schema( 19 | {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} 20 | ) 21 | 22 | 23 | class WienerNetzeSmartMeterCustomConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 24 | """Wiener Netze Smartmeter config flow""" 25 | 26 | data: Optional[dict[str, Any]] 27 | 28 | async def validate_auth(self, username: str, password: str) -> list[dict]: 29 | """ 30 | Validates credentials for smartmeter. 31 | Raises a ValueError if the auth credentials are invalid. 32 | """ 33 | smartmeter = Smartmeter(username, password) 34 | await self.hass.async_add_executor_job(smartmeter.login) 35 | contracts = await self.hass.async_add_executor_job(smartmeter.zaehlpunkte) 36 | zaehlpunkte=[] 37 | if contracts is not None and isinstance(contracts, list) and len(contracts) > 0: 38 | for contract in contracts: 39 | if "zaehlpunkte" in contract: 40 | zaehlpunkte.extend(contract["zaehlpunkte"]) 41 | return zaehlpunkte 42 | 43 | 44 | async def async_step_user(self, user_input: Optional[dict[str, Any]] = None): 45 | """Invoked when a user initiates a flow via the user interface.""" 46 | errors: dict[str, str] = {} 47 | zps = [] 48 | if user_input is not None: 49 | try: 50 | zps = await self.validate_auth( 51 | user_input[CONF_USERNAME], user_input[CONF_PASSWORD] 52 | ) 53 | except Exception as exception: # pylint: disable=broad-except 54 | _LOGGER.error("Error validating Wiener Netze auth") 55 | _LOGGER.exception(exception) 56 | errors["base"] = "auth" 57 | if not errors: 58 | # Input is valid, set data 59 | self.data = user_input 60 | self.data[CONF_ZAEHLPUNKTE] = [ 61 | translate_dict(zp, ATTRS_ZAEHLPUNKTE_CALL) for zp in zps 62 | if zp["isActive"] # only create active zaehlpunkte, as inactive ones can appear in old contracts 63 | ] 64 | # User is done authenticating, create entry 65 | return self.async_create_entry( 66 | title="Wiener Netze Smartmeter", data=self.data 67 | ) 68 | 69 | return self.async_show_form( 70 | step_id="user", data_schema=AUTH_SCHEMA, errors=errors 71 | ) 72 | -------------------------------------------------------------------------------- /custom_components/wnsm/const.py: -------------------------------------------------------------------------------- 1 | """ 2 | component constants 3 | """ 4 | DOMAIN = "wnsm" 5 | 6 | CONF_ZAEHLPUNKTE = "zaehlpunkte" 7 | 8 | ATTRS_ZAEHLPUNKT_CALL = [ 9 | ("zaehlpunktnummer", "zaehlpunktnummer"), 10 | ("customLabel", "label"), 11 | ("equipmentNumber", "equipmentNumber"), 12 | ("dailyConsumption", "dailyConsumption"), 13 | ("geraetNumber", "deviceId"), 14 | ("customerId", "geschaeftspartner"), 15 | ("verbrauchsstelle.strasse", "street"), 16 | ("verbrauchsstelle.hausnummer", "streetNumber"), 17 | ("verbrauchsstelle.postleitzahl", "zip"), 18 | ("verbrauchsstelle.ort", "city"), 19 | ("verbrauchsstelle.laengengrad", "longitude"), 20 | ("verbrauchsstelle.breitengrad", "latitude"), 21 | ("anlage.typ", "type"), 22 | ] 23 | 24 | ATTRS_ZAEHLPUNKTE_CALL = [ 25 | ("geschaeftspartner", "customerId"), 26 | ("zaehlpunktnummer", "zaehlpunktnummer"), 27 | ("customLabel", "label"), 28 | ("equipmentNumber", "equipmentNumber"), 29 | ("geraetNumber", "deviceId"), 30 | ("verbrauchsstelle.strasse", "street"), 31 | ("verbrauchsstelle.anlageHausnummer", "streetNumber"), 32 | ("verbrauchsstelle.postleitzahl", "zip"), 33 | ("verbrauchsstelle.ort", "city"), 34 | ("verbrauchsstelle.laengengrad", "longitude"), 35 | ("verbrauchsstelle.breitengrad", "latitude"), 36 | ("anlage.typ", "type"), 37 | ("isDefault", "default"), 38 | ("isActive", "active"), 39 | ("isSmartMeterMarketReady", "smartMeterReady"), 40 | ("idexStatus.granularity.status", "granularity") 41 | ] 42 | 43 | ATTRS_CONSUMPTIONS_CALL = [ 44 | ("consumptionYesterday.value", "consumptionYesterdayValue"), 45 | ("consumptionYesterday.validated", "consumptionYesterdayValidated"), 46 | ("consumptionYesterday.date", "consumptionYesterdayTimestamp"), 47 | ("consumptionDayBeforeYesterday.value", "consumptionDayBeforeYesterdayValue"), 48 | ("consumptionDayBeforeYesterday.validated", "consumptionDayBeforeYesterdayValidated"), 49 | ("consumptionDayBeforeYesterday.date", "consumptionDayBeforeYesterdayTimestamp"), 50 | ] 51 | 52 | ATTRS_BASEINFORMATION_CALL = [ 53 | ("hasSmartMeter", "hasSmartMeter"), 54 | ("isDataDeleted", "isDataDeleted"), 55 | ("dataDeletionTimestampUTC", "dataDeletionAt"), 56 | ("zaehlpunkt.zaehlpunktName", "name"), 57 | ("zaehlpunkt.zaehlpunktnummer", "zaehlpunkt"), 58 | ("zaehlpunkt.zaehlpunktAnlagentyp", "type"), 59 | ("zaehlpunkt.adresse", "address"), 60 | ("zaehlpunkt.postleitzahl", "zip"), 61 | ] 62 | 63 | ATTRS_METERREADINGS_CALL = [ 64 | ("meterReadings.0.value", "lastValue"), 65 | ("meterReadings.0.date", "lastReading"), 66 | ("meterReadings.0.validated", "lastValidated"), 67 | ("meterReadings.0.type", "lastType") 68 | ] 69 | 70 | ATTRS_VERBRAUCH_CALL = [ 71 | ("quarter-hour-opt-in", "optIn"), 72 | ("statistics.average", "consumptionAverage"), 73 | ("statistics.minimum", "consumptionMinimum"), 74 | ("statistics.maximum", "consumptionMaximum"), 75 | ("values", "values"), 76 | ] 77 | 78 | ATTRS_HISTORIC_DATA = [ 79 | ('obisCode', 'obisCode'), 80 | ('einheit', 'unitOfMeasurement'), 81 | ('messwerte', 'values'), 82 | ] 83 | 84 | ATTRS_BEWEGUNGSDATEN = [ 85 | ('descriptor.geschaeftspartnernummer', 'customerId'), 86 | ('descriptor.zaehlpunktnummer', 'zaehlpunkt'), 87 | ('descriptor.rolle', 'role'), 88 | ('descriptor.aggregat', 'aggregator'), 89 | ('descriptor.granularitaet', 'granularity'), 90 | ('descriptor.einheit', 'unitOfMeasurement'), 91 | ('values', 'values'), 92 | ] 93 | 94 | ATTRS_HISTORIC_MEASUREMENT = [ 95 | ] 96 | -------------------------------------------------------------------------------- /custom_components/wnsm/wnsm_sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from typing import Any, Optional 4 | 5 | from homeassistant.components.sensor import ( 6 | SensorDeviceClass, 7 | SensorStateClass, 8 | ENTITY_ID_FORMAT 9 | ) 10 | from homeassistant.components.sensor import SensorEntity 11 | from homeassistant.const import UnitOfEnergy 12 | from homeassistant.util import slugify 13 | 14 | from .AsyncSmartmeter import AsyncSmartmeter 15 | from .api import Smartmeter 16 | from .api.constants import ValueType 17 | from .importer import Importer 18 | from .utils import before, today 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | class WNSMSensor(SensorEntity): 24 | """ 25 | Representation of a Wiener Smartmeter sensor 26 | for measuring total increasing energy consumption for a specific zaehlpunkt 27 | """ 28 | 29 | def _icon(self) -> str: 30 | return "mdi:flash" 31 | 32 | def __init__(self, username: str, password: str, zaehlpunkt: str) -> None: 33 | super().__init__() 34 | self.username = username 35 | self.password = password 36 | self.zaehlpunkt = zaehlpunkt 37 | 38 | self._attr_native_value: int | float | None = 0 39 | self._attr_extra_state_attributes = {} 40 | self._attr_name = zaehlpunkt 41 | self._attr_icon = self._icon() 42 | self._attr_state_class = SensorStateClass.TOTAL_INCREASING 43 | self._attr_device_class = SensorDeviceClass.ENERGY 44 | self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR 45 | 46 | self.attrs: dict[str, Any] = {} 47 | self._name: str = zaehlpunkt 48 | self._available: bool = True 49 | self._updatets: str | None = None 50 | 51 | @property 52 | def get_state(self) -> Optional[str]: 53 | return f"{self._attr_native_value:.3f}" 54 | 55 | @property 56 | def _id(self): 57 | return ENTITY_ID_FORMAT.format(slugify(self._name).lower()) 58 | 59 | @property 60 | def icon(self) -> str: 61 | return self._attr_icon 62 | 63 | @property 64 | def name(self) -> str: 65 | """Return the name of the entity.""" 66 | return self._name 67 | 68 | @property 69 | def unique_id(self) -> str: 70 | """Return the unique ID of the sensor.""" 71 | return self.zaehlpunkt 72 | 73 | @property 74 | def available(self) -> bool: 75 | """Return True if entity is available.""" 76 | return self._available 77 | 78 | def granularity(self) -> ValueType: 79 | return ValueType.from_str(self._attr_extra_state_attributes.get("granularity", "QUARTER_HOUR")) 80 | 81 | async def async_update(self): 82 | """ 83 | update sensor 84 | """ 85 | try: 86 | smartmeter = Smartmeter(username=self.username, password=self.password) 87 | async_smartmeter = AsyncSmartmeter(self.hass, smartmeter) 88 | await async_smartmeter.login() 89 | zaehlpunkt_response = await async_smartmeter.get_zaehlpunkt(self.zaehlpunkt) 90 | self._attr_extra_state_attributes = zaehlpunkt_response 91 | 92 | if async_smartmeter.is_active(zaehlpunkt_response): 93 | # Since the update is not exactly at midnight, both yesterday and the day before are tried to make sure a meter reading is returned 94 | reading_dates = [before(today(), 1), before(today(), 2)] 95 | for reading_date in reading_dates: 96 | meter_reading = await async_smartmeter.get_meter_reading_from_historic_data(self.zaehlpunkt, reading_date, datetime.now()) 97 | self._attr_native_value = meter_reading 98 | importer = Importer(self.hass, async_smartmeter, self.zaehlpunkt, self.unit_of_measurement, self.granularity()) 99 | await importer.async_import() 100 | self._available = True 101 | self._updatets = datetime.now().strftime("%d.%m.%Y %H:%M:%S") 102 | except TimeoutError as e: 103 | self._available = False 104 | _LOGGER.warning( 105 | "Error retrieving data from smart meter api - Timeout: %s" % e) 106 | except RuntimeError as e: 107 | self._available = False 108 | _LOGGER.exception( 109 | "Error retrieving data from smart meter api - Error: %s" % e) 110 | -------------------------------------------------------------------------------- /.vscode/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Developer Support 2 | 3 | All contributions are welcome! The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. 4 | 5 | In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. 6 | 7 | ### Prerequisites 8 | 9 | * [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) 10 | * Docker 11 | * For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) 12 | * Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. 13 | * [Visual Studio code](https://code.visualstudio.com/) 14 | * [Remote - Containers (VSC Extension)][extension-link] 15 | 16 | [More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) 17 | 18 | [extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers 19 | 20 | ### Setup 21 | 22 | Please use following tools/extensions to contribute (feedback and suggestions very much welcome :) ): 23 | 24 | `requirements.dev.txt` 25 | * [black formatter](https://marketplace.visualstudio.com/items?itemName=ms-python.black-formatter) 26 | * [black](https://github.com/psf/black) 27 | * [pylint extension](https://marketplace.visualstudio.com/items?itemName=ms-python.pylint) 28 | * [pylint](https://github.com/PyCQA/pylint) 29 | 30 | ### Getting started 31 | 32 | 1. Install prerequisites 33 | 34 | 2. Fork the repository. 35 | 36 | 3. Clone the repository to your computer. 37 | 38 | 4. Open the repository using Visual Studio code. 39 | 40 | When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. 41 | 42 | _If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ 43 | 44 | ### Tasks 45 | 46 | The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. 47 | 48 | When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. 49 | 50 | The available tasks are: 51 | 52 | | Task | Description | 53 | | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | 54 | | Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. | 55 | | Run Home Assistant configuration against /config | Check the configuration. | 56 | | Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. | 57 | | Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. | 58 | 59 | 60 | ### Step by Step debugging 61 | 62 | With the development container, 63 | you can test your custom component in Home Assistant with step by step debugging. 64 | 65 | You need to modify the `configuration.yaml` file in `.devcontainer` folder 66 | by uncommenting the line: 67 | 68 | ```yaml 69 | # debugpy: 70 | ``` 71 | 72 | Then launch the task `Run Home Assistant on port 9123`, and launch the debugger 73 | with the existing debugging configuration `Python: Attach Local`. 74 | 75 | For more information, look at [the Remote Python Debugger integration documentation](https://www.home-assistant.io/integrations/debugpy/). -------------------------------------------------------------------------------- /custom_components/wnsm/api/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | api constants 3 | """ 4 | import enum 5 | 6 | PAGE_URL = "https://smartmeter-web.wienernetze.at/" 7 | API_CONFIG_URL = "https://smartmeter-web.wienernetze.at/assets/app-config.json" 8 | API_URL_ALT = "https://service.wienernetze.at/sm/api/" 9 | # These two URLS are also coded in the js as b2cApiUrl and b2bApiUrl 10 | API_URL = "https://api.wstw.at/gateway/WN_SMART_METER_PORTAL_API_B2C/1.0" 11 | API_URL_B2B = "https://api.wstw.at/gateway/WN_SMART_METER_PORTAL_API_B2B/1.0" 12 | REDIRECT_URI = "https://smartmeter-web.wienernetze.at/" 13 | API_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" 14 | AUTH_URL = "https://log.wien/auth/realms/logwien/protocol/openid-connect/" # noqa 15 | 16 | LOGIN_ARGS = { 17 | "client_id": "wn-smartmeter", 18 | "redirect_uri": REDIRECT_URI, 19 | "response_mode": "fragment", 20 | "response_type": "code", 21 | "scope": "openid", 22 | "nonce": "", 23 | "code_challenge": "", 24 | "code_challenge_method": "S256" 25 | } 26 | 27 | VALID_OBIS_CODES = { 28 | "1-1:1.8.0", #: Total Meter reading of consumption in Wh on selected day(s)- updated daily - used by Wiener Netze as default for meter reading ("Zählerstand") 29 | "1-1:1.9.0", #: Measured value of consumption in Wh in quarter hour or daily steps - updated daily - also used by Wiener Netze for meter readings of heat pumps 30 | "1-1:2.8.0", #: Total Meter reading of production/feeding on selected day(s) in Wh - used by Wiener Netze as default for meter reading ("Zählerstand") 31 | "1-1:2.9.0" #: Measured value of production/feeding in Wh in quarter hour or daily steps - updated daily - currently unused by Wiener Netze but accesible via API (call to zaehlpunkte/{customer_id}/{zaehlpunkt}/messwerte with ValueType DAY or QUARTER_HOUR) 32 | } 33 | 34 | class Resolution(enum.Enum): 35 | """Possible resolution for consumption data of one day""" 36 | HOUR = "HOUR" #: gets consumption data per hour 37 | QUARTER_HOUR = "QUARTER-HOUR" #: gets consumption data per 15min 38 | 39 | 40 | class ValueType(enum.Enum): 41 | """Possible 'wertetyp' for querying historical data""" 42 | METER_READ = "METER_READ" #: Meter reading for the day 43 | DAY = "DAY" #: Consumption for the day 44 | QUARTER_HOUR = "QUARTER_HOUR" #: Consumption for 15min slots 45 | 46 | @staticmethod 47 | def from_str(label): 48 | if label in ('METER_READ', 'meter_read'): 49 | return ValueType.METER_READ 50 | elif label in ('DAY', 'day'): 51 | return ValueType.DAY 52 | elif label in ('QUARTER_HOUR', 'quarter_hour'): 53 | return ValueType.QUARTER_HOUR 54 | else: 55 | raise NotImplementedError 56 | 57 | class AnlagenType(enum.Enum): 58 | """Possible types for the zaehlpunkte""" 59 | CONSUMING = "TAGSTROM" #: Zaehlpunkt is consuming ("normal" power connection) 60 | FEEDING = "BEZUG" #: Zaehlpunkt is feeding (produced power from PV, etc.) 61 | 62 | @staticmethod 63 | def from_str(label): 64 | match label.upper(): 65 | case 'TAGSTROM' | 'NACHTSTROM' | 'WAERMEPUMPE' | 'STROM': 66 | return AnlagenType.CONSUMING 67 | case 'BEZUG': 68 | return AnlagenType.FEEDING 69 | case _: 70 | raise NotImplementedError(f"AnlageType {label} not implemented") 71 | 72 | class RoleType(enum.Enum): 73 | """Possible types for the roles of bewegungsdaten - depending on the settings set in smart meter portal""" 74 | DAILY_CONSUMING = "V001" #: Consuming data is updated in daily steps 75 | QUARTER_HOURLY_CONSUMING = "V002" #: Consuming data is updated in quarter hour steps 76 | DAILY_FEEDING = "E001" #: Feeding data is updated in daily steps 77 | QUARTER_HOURLY_FEEDING = "E002" #: Feeding data is updated in quarter hour steps 78 | 79 | def build_access_token_args(**kwargs): 80 | """ 81 | build access token and add kwargs 82 | """ 83 | args = { 84 | "grant_type": "authorization_code", 85 | "client_id": "wn-smartmeter", 86 | "redirect_uri": REDIRECT_URI 87 | } 88 | args.update(**kwargs) 89 | return args 90 | 91 | 92 | def build_verbrauchs_args(**kwargs): 93 | """ 94 | build arguments for verbrauchs call and add kwargs 95 | """ 96 | args = { 97 | "period": "DAY", 98 | "accumulate": False, # can be changed to True to get a cum-sum 99 | "offset": 0, # additional offset to start cum-sum with 100 | "dayViewResolution": "HOUR", 101 | } 102 | args.update(**kwargs) 103 | return args 104 | -------------------------------------------------------------------------------- /tests/test_resources/smartmeter-web.wienernetze.at.html: -------------------------------------------------------------------------------- 1 | 2 | Smart Meter 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 |
28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /custom_components/wnsm/AsyncSmartmeter.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from asyncio import Future 4 | from datetime import datetime 5 | 6 | from homeassistant.core import HomeAssistant 7 | 8 | from .api import Smartmeter 9 | from .api.constants import ValueType 10 | from .const import ATTRS_METERREADINGS_CALL, ATTRS_BASEINFORMATION_CALL, ATTRS_CONSUMPTIONS_CALL, ATTRS_BEWEGUNGSDATEN, ATTRS_ZAEHLPUNKTE_CALL, ATTRS_HISTORIC_DATA, ATTRS_VERBRAUCH_CALL 11 | from .utils import translate_dict 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | class AsyncSmartmeter: 16 | 17 | def __init__(self, hass: HomeAssistant, smartmeter: Smartmeter = None): 18 | self.hass = hass 19 | self.smartmeter = smartmeter 20 | self.login_lock = asyncio.Lock() 21 | 22 | async def login(self) -> Future: 23 | async with self.login_lock: 24 | return await self.hass.async_add_executor_job(self.smartmeter.login) 25 | 26 | async def get_meter_readings(self) -> dict[str, any]: 27 | """ 28 | asynchronously get and parse /meterReadings response 29 | Returns response already sanitized of the specified zaehlpunkt in ctor 30 | """ 31 | response = await self.hass.async_add_executor_job( 32 | self.smartmeter.historical_data, 33 | ) 34 | if "Exception" in response: 35 | raise RuntimeError("Cannot access /meterReadings: ", response) 36 | return translate_dict(response, ATTRS_METERREADINGS_CALL) 37 | 38 | 39 | async def get_base_information(self) -> dict[str, str]: 40 | """ 41 | asynchronously get and parse /baseInformation response 42 | Returns response already sanitized of the specified zaehlpunkt in ctor 43 | """ 44 | response = await self.hass.async_add_executor_job(self.smartmeter.base_information) 45 | if "Exception" in response: 46 | raise RuntimeError("Cannot access /baseInformation: ", response) 47 | return translate_dict(response, ATTRS_BASEINFORMATION_CALL) 48 | 49 | def contracts2zaehlpunkte(self, contracts: dict, zaehlpunkt: str) -> list[dict]: 50 | zaehlpunkte = [] 51 | if contracts is not None and isinstance(contracts, list) and len(contracts) > 0: 52 | for contract in contracts: 53 | if "zaehlpunkte" in contract: 54 | geschaeftspartner = contract["geschaeftspartner"] if "geschaeftspartner" in contract else None 55 | zaehlpunkte += [ 56 | {**z, "geschaeftspartner": geschaeftspartner} for z in contract["zaehlpunkte"] if z["zaehlpunktnummer"] == zaehlpunkt 57 | ] 58 | else: 59 | raise RuntimeError(f"Cannot access Zaehlpunkt {zaehlpunkt}") 60 | return zaehlpunkte 61 | 62 | async def get_zaehlpunkt(self, zaehlpunkt: str) -> dict[str, str]: 63 | """ 64 | asynchronously get and parse /zaehlpunkt response 65 | Returns response already sanitized of the specified zaehlpunkt in ctor 66 | """ 67 | contracts = await self.hass.async_add_executor_job(self.smartmeter.zaehlpunkte) 68 | zaehlpunkte = self.contracts2zaehlpunkte(contracts, zaehlpunkt) 69 | zp = [z for z in zaehlpunkte if z["zaehlpunktnummer"] == zaehlpunkt] 70 | if len(zp) == 0: 71 | raise RuntimeError(f"Zaehlpunkt {zaehlpunkt} not found") 72 | 73 | return ( 74 | translate_dict(zp[0], ATTRS_ZAEHLPUNKTE_CALL) 75 | if len(zp) > 0 76 | else None 77 | ) 78 | 79 | async def get_consumption(self, customer_id: str, zaehlpunkt: str, start_date: datetime): 80 | """Return 24h of hourly consumption starting from a date""" 81 | response = await self.hass.async_add_executor_job( 82 | self.smartmeter.verbrauch, customer_id, zaehlpunkt, start_date 83 | ) 84 | if "Exception" in response: 85 | raise RuntimeError(f"Cannot access daily consumption: {response}") 86 | 87 | return translate_dict(response, ATTRS_VERBRAUCH_CALL) 88 | 89 | async def get_consumption_raw(self, customer_id: str, zaehlpunkt: str, start_date: datetime): 90 | """Return daily consumptions from the given start date until today""" 91 | response = await self.hass.async_add_executor_job( 92 | self.smartmeter.verbrauchRaw, customer_id, zaehlpunkt, start_date 93 | ) 94 | if "Exception" in response: 95 | raise RuntimeError(f"Cannot access daily consumption: {response}") 96 | 97 | return translate_dict(response, ATTRS_VERBRAUCH_CALL) 98 | 99 | async def get_historic_data(self, zaehlpunkt: str, date_from: datetime = None, date_to: datetime = None, granularity: ValueType = ValueType.QUARTER_HOUR): 100 | """Return three years of historic quarter-hourly data""" 101 | response = await self.hass.async_add_executor_job( 102 | self.smartmeter.historical_data, 103 | zaehlpunkt, 104 | date_from, 105 | date_to, 106 | granularity 107 | ) 108 | if "Exception" in response: 109 | raise RuntimeError(f"Cannot access historic data: {response}") 110 | _LOGGER.debug(f"Raw historical data: {response}") 111 | return translate_dict(response, ATTRS_HISTORIC_DATA) 112 | 113 | async def get_meter_reading_from_historic_data(self, zaehlpunkt: str, start_date: datetime, end_date: datetime) -> float: 114 | """Return daily meter readings from the given start date until today""" 115 | response = await self.hass.async_add_executor_job( 116 | self.smartmeter.historical_data, 117 | zaehlpunkt, 118 | start_date, 119 | end_date, 120 | ValueType.METER_READ 121 | ) 122 | if "Exception" in response: 123 | raise RuntimeError(f"Cannot access historic data: {response}") 124 | _LOGGER.debug(f"Raw historical data: {response}") 125 | meter_readings = translate_dict(response, ATTRS_HISTORIC_DATA) 126 | if "values" in meter_readings and all("messwert" in messwert for messwert in meter_readings['values']) and len(meter_readings['values']) > 0: 127 | return meter_readings['values'][0]['messwert'] / 1000 128 | 129 | @staticmethod 130 | def is_active(zaehlpunkt_response: dict) -> bool: 131 | """ 132 | returns active status of smartmeter, according to zaehlpunkt response 133 | """ 134 | return ( 135 | "active" not in zaehlpunkt_response or zaehlpunkt_response["active"] 136 | ) or ( 137 | "smartMeterReady" not in zaehlpunkt_response 138 | or zaehlpunkt_response["smartMeterReady"] 139 | ) 140 | 141 | async def get_bewegungsdaten(self, zaehlpunkt: str, start: datetime = None, end: datetime = None, granularity: ValueType = ValueType.QUARTER_HOUR): 142 | """Return three years of historic quarter-hourly data""" 143 | response = await self.hass.async_add_executor_job( 144 | self.smartmeter.bewegungsdaten, 145 | zaehlpunkt, 146 | start, 147 | end, 148 | granularity 149 | ) 150 | if "Exception" in response: 151 | raise RuntimeError(f"Cannot access bewegungsdaten: {response}") 152 | _LOGGER.debug(f"Raw bewegungsdaten: {response}") 153 | return translate_dict(response, ATTRS_BEWEGUNGSDATEN) 154 | 155 | async def get_consumptions(self) -> dict[str, str]: 156 | """ 157 | asynchronously get and parse /consumptions response 158 | Returns response already sanitized of the specified zaehlpunkt in ctor 159 | """ 160 | response = await self.hass.async_add_executor_job(self.smartmeter.consumptions) 161 | if "Exception" in response: 162 | raise RuntimeError("Cannot access /consumptions: ", response) 163 | return translate_dict(response, ATTRS_CONSUMPTIONS_CALL) -------------------------------------------------------------------------------- /custom_components/wnsm/importer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import defaultdict 3 | from datetime import timedelta, timezone, datetime 4 | from decimal import Decimal 5 | from operator import itemgetter 6 | 7 | from homeassistant.components.recorder import get_instance 8 | from homeassistant.components.recorder.models import ( 9 | StatisticData, 10 | StatisticMetaData, 11 | ) 12 | from homeassistant.components.recorder.statistics import ( 13 | get_last_statistics, async_add_external_statistics, 14 | ) 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.util import dt as dt_util 17 | 18 | from .AsyncSmartmeter import AsyncSmartmeter 19 | from .api.constants import ValueType 20 | from .const import DOMAIN 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | class Importer: 25 | 26 | def __init__(self, hass: HomeAssistant, async_smartmeter: AsyncSmartmeter, zaehlpunkt: str, unit_of_measurement: str, granularity: ValueType = ValueType.QUARTER_HOUR): 27 | self.id = f'{DOMAIN}:{zaehlpunkt.lower()}' 28 | self.zaehlpunkt = zaehlpunkt 29 | self.granularity = granularity 30 | self.unit_of_measurement = unit_of_measurement 31 | self.hass = hass 32 | self.async_smartmeter = async_smartmeter 33 | 34 | def is_last_inserted_stat_valid(self, last_inserted_stat): 35 | return len(last_inserted_stat) == 1 and len(last_inserted_stat[self.id]) == 1 and \ 36 | "sum" in last_inserted_stat[self.id][0] and "end" in last_inserted_stat[self.id][0] 37 | 38 | def prepare_start_off_point(self, last_inserted_stat): 39 | # Previous data found in the statistics table 40 | _sum = Decimal(last_inserted_stat[self.id][0]["sum"]) 41 | # The next start is the previous end 42 | # XXX: since HA core 2022.12, we get a datetime and not a str... 43 | # XXX: since HA core 2023.03, we get a float and not a datetime... 44 | start = last_inserted_stat[self.id][0]["end"] 45 | if isinstance(start, (int, float)): 46 | start = dt_util.utc_from_timestamp(start) 47 | if isinstance(start, str): 48 | start = dt_util.parse_datetime(start) 49 | 50 | if not isinstance(start, datetime): 51 | _LOGGER.error("HA core decided to change the return type AGAIN! " 52 | "Please open a bug report. " 53 | "Additional Information: %s Type: %s", 54 | last_inserted_stat, 55 | type(last_inserted_stat[self.id][0]["end"])) 56 | return None 57 | _LOGGER.debug("New starting datetime: %s", start) 58 | 59 | # Extra check to not strain the API too much: 60 | # If the last insert date is less than 24h away, simply exit here, 61 | # because we will not get any data from the API 62 | min_wait = timedelta(hours=24) 63 | delta_t = datetime.now(timezone.utc).replace(microsecond=0) - start.replace(microsecond=0) 64 | if delta_t <= min_wait: 65 | _LOGGER.debug( 66 | "Not querying the API, because last update is not older than 24 hours. Earliest update in %s" % ( 67 | min_wait - delta_t)) 68 | return None 69 | return start, _sum 70 | 71 | async def async_import(self): 72 | # Query the statistics database for the last value 73 | # It is crucial to use get_instance here! 74 | last_inserted_stat = await get_instance( 75 | self.hass 76 | ).async_add_executor_job( 77 | get_last_statistics, 78 | self.hass, 79 | 1, # Get at most one entry 80 | self.id, # of this sensor 81 | True, # convert the units 82 | # XXX: since HA core 2022.12 need to specify this: 83 | {"sum", "state"}, # the fields we want to query (state might be used in the future) 84 | ) 85 | _LOGGER.debug("Last inserted stat: %s" % last_inserted_stat) 86 | try: 87 | await self.async_smartmeter.login() 88 | zaehlpunkt = await (self.async_smartmeter.get_zaehlpunkt(self.zaehlpunkt)) 89 | 90 | if not self.async_smartmeter.is_active(zaehlpunkt): 91 | _LOGGER.debug("Smartmeter %s is not active" % zaehlpunkt) 92 | return 93 | 94 | if not self.is_last_inserted_stat_valid(last_inserted_stat): 95 | # No previous data - start from scratch 96 | _LOGGER.warning("Starting import of historical data. This might take some time.") 97 | _sum = await self._initial_import_statistics() 98 | else: 99 | start_off_point = self.prepare_start_off_point(last_inserted_stat) 100 | if start_off_point is None: 101 | return 102 | start, _sum = start_off_point 103 | _sum = await self._incremental_import_statistics(start, _sum) 104 | 105 | # XXX: Note that the state of this sensor must never be an integer value, such as 0! 106 | # If it is set to any number, home assistant will assume that a negative consumption 107 | # compensated the last statistics entry and add a negative consumption in the energy 108 | # dashboard. 109 | # This is a technical debt of HA, as we cannot import statistics and have states at the 110 | # same time. 111 | # Due to None, the sensor will always show "unkown" - but that is currently the only way 112 | # how historical data can be imported without rewriting the database on our own... 113 | last_inserted_stat = await get_instance(self.hass).async_add_executor_job( 114 | get_last_statistics, 115 | self.hass, 116 | 1, # Get at most one entry 117 | self.id, # of this sensor's statistics 118 | True, # convert the units 119 | {"sum"} # the fields we want to query 120 | ) 121 | _LOGGER.debug("Last inserted stat: %s", last_inserted_stat) 122 | except TimeoutError as e: 123 | _LOGGER.warning("Error retrieving data from smart meter api - Timeout: %s" % e) 124 | except RuntimeError as e: 125 | _LOGGER.exception("Error retrieving data from smart meter api - Error: %s" % e) 126 | 127 | def get_statistics_metadata(self): 128 | return StatisticMetaData( 129 | source=DOMAIN, 130 | statistic_id=self.id, 131 | name=self.zaehlpunkt, 132 | unit_of_measurement=self.unit_of_measurement, 133 | has_mean=False, 134 | has_sum=True, 135 | ) 136 | 137 | async def _initial_import_statistics(self): 138 | return await self._import_statistics() 139 | 140 | async def _incremental_import_statistics(self, start: datetime, total_usage: Decimal): 141 | return await self._import_statistics(start=start, total_usage=total_usage) 142 | 143 | async def _import_statistics(self, start: datetime = None, end: datetime = None, total_usage: Decimal = Decimal(0)): 144 | """Import statistics""" 145 | 146 | start = start if start is not None else datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=365 * 3) 147 | end = end if end is not None else datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) 148 | 149 | if start.tzinfo is None: 150 | raise ValueError("start datetime must be timezone-aware!") 151 | 152 | _LOGGER.debug("Selecting data up to %s" % end) 153 | if start > end: 154 | _LOGGER.warning(f"Ignoring async update since last import happened in the future (should not happen) {start} > {end}") 155 | return 156 | 157 | bewegungsdaten = await self.async_smartmeter.get_bewegungsdaten(self.zaehlpunkt, start, end, self.granularity) 158 | _LOGGER.debug(f"Mapped historical data: {bewegungsdaten}") 159 | if bewegungsdaten['unitOfMeasurement'] == 'WH': 160 | factor = 1e-3 161 | elif bewegungsdaten['unitOfMeasurement'] == 'KWH': 162 | factor = 1.0 163 | else: 164 | raise NotImplementedError(f'Unit {bewegungsdaten["unitOfMeasurement"]}" is not yet implemented. Please report!') 165 | 166 | dates = defaultdict(Decimal) 167 | if 'values' not in bewegungsdaten: 168 | raise ValueError("WienerNetze does not report historical data (yet)") 169 | total_consumption = sum([v.get("wert", 0) for v in bewegungsdaten['values']]) 170 | # Can actually check, if the whole batch can be skipped. 171 | if total_consumption == 0: 172 | _LOGGER.debug(f"Batch of data starting at {start} does not contain any bewegungsdaten. Seems there is nothing to import, yet.") 173 | return 174 | 175 | last_ts = start 176 | for value in bewegungsdaten['values']: 177 | ts = dt_util.parse_datetime(value['zeitpunktVon']) 178 | if ts < last_ts: 179 | # This should prevent any issues with ambiguous values though... 180 | _LOGGER.warning(f"Timestamp from API ({ts}) is less than previously collected timestamp ({last_ts}), ignoring value!") 181 | continue 182 | last_ts = ts 183 | if value['wert'] is None: 184 | # Usually this means that the measurement is not yet in the WSTW database. 185 | continue 186 | reading = Decimal(value['wert'] * factor) 187 | if ts.minute % 15 != 0 or ts.second != 0 or ts.microsecond != 0: 188 | _LOGGER.warning(f"Unexpected time detected in historic data: {value}") 189 | dates[ts.replace(minute=0)] += reading 190 | if value['geschaetzt']: 191 | _LOGGER.debug(f"Not seen that before: Estimated Value found for {ts}: {reading}") 192 | 193 | statistics = [] 194 | metadata = self.get_statistics_metadata() 195 | 196 | for ts, usage in sorted(dates.items(), key=itemgetter(0)): 197 | total_usage += usage 198 | statistics.append(StatisticData(start=ts, sum=total_usage, state=float(usage))) 199 | if len(statistics) > 0: 200 | _LOGGER.debug(f"Importing statistics from {statistics[0]} to {statistics[-1]}") 201 | async_add_external_statistics(self.hass, metadata, statistics) 202 | return total_usage -------------------------------------------------------------------------------- /tests/it/test_api.py: -------------------------------------------------------------------------------- 1 | """API tests""" 2 | import pytest 3 | import time 4 | import logging 5 | from requests_mock import Mocker 6 | import datetime as dt 7 | from dateutil.relativedelta import relativedelta 8 | 9 | from it import ( 10 | expect_login, 11 | expect_verbrauch, 12 | smartmeter, 13 | expect_zaehlpunkte, 14 | verbrauch_raw_response, 15 | zaehlpunkt, 16 | zaehlpunkt_feeding, 17 | enabled, 18 | disabled, 19 | mock_login_page, 20 | mock_authenticate, 21 | PASSWORD, 22 | USERNAME, 23 | mock_token, 24 | mock_get_api_key, 25 | expect_history, expect_bewegungsdaten, zaehlpunkt_response, 26 | ) 27 | from wnsm.api.errors import SmartmeterConnectionError, SmartmeterLoginError, SmartmeterQueryError 28 | import wnsm.api.constants as const 29 | 30 | COUNT = 10 31 | 32 | logger = logging.getLogger(__name__) 33 | 34 | @pytest.mark.usefixtures("requests_mock") 35 | def test_successful_login(requests_mock: Mocker): 36 | expect_login(requests_mock) 37 | 38 | smartmeter().login() 39 | assert True 40 | 41 | 42 | @pytest.mark.usefixtures("requests_mock") 43 | def test_unsuccessful_login_failing_on_login_page_load(requests_mock): 44 | mock_login_page(requests_mock, 404) 45 | with pytest.raises(SmartmeterConnectionError) as exc_info: 46 | smartmeter().login() 47 | assert 'Could not load login page. Error: ' in str(exc_info.value) 48 | 49 | 50 | @pytest.mark.usefixtures("requests_mock") 51 | def test_unsuccessful_login_failing_on_connection_timeout_while_login_page_load(requests_mock): 52 | mock_login_page(requests_mock, None) 53 | with pytest.raises(SmartmeterConnectionError) as exc_info: 54 | smartmeter().login() 55 | assert 'Could not load login page' in str(exc_info.value) 56 | 57 | 58 | @pytest.mark.usefixtures("requests_mock") 59 | def test_unsuccessful_login_failing_on_connection_timeout(requests_mock): 60 | mock_login_page(requests_mock) 61 | mock_authenticate(requests_mock, USERNAME, PASSWORD, status=None) 62 | with pytest.raises(SmartmeterConnectionError) as exc_info: 63 | smartmeter().login() 64 | assert 'Could not login with credentials' in str(exc_info.value) 65 | 66 | 67 | @pytest.mark.usefixtures("requests_mock") 68 | def test_unsuccessful_login_failing_on_credentials_login(requests_mock): 69 | mock_login_page(requests_mock) 70 | mock_authenticate(requests_mock, USERNAME, "WrongPassword", status=403) 71 | with pytest.raises(SmartmeterLoginError) as exc_info: 72 | smartmeter(username=USERNAME, password="WrongPassword").login() 73 | assert 'Login failed. Check username/password.' in str(exc_info.value) 74 | 75 | 76 | @pytest.mark.usefixtures("requests_mock") 77 | def test_unsuccessful_login_failing_on_non_bearer_token(requests_mock): 78 | mock_login_page(requests_mock) 79 | mock_authenticate(requests_mock, USERNAME, PASSWORD) 80 | mock_token(requests_mock, token_type="IAmNotABearerToken") 81 | with pytest.raises(SmartmeterLoginError) as exc_info: 82 | smartmeter(username=USERNAME, password=PASSWORD).login() 83 | assert 'Bearer token required' in str(exc_info.value) 84 | 85 | 86 | @pytest.mark.usefixtures("requests_mock") 87 | def test_unsuccessful_login_failing_on_redirect_when_location_header_does_not_bear_code(requests_mock): 88 | mock_login_page(requests_mock) 89 | mock_authenticate(requests_mock, USERNAME, PASSWORD, status=404) 90 | with pytest.raises(SmartmeterLoginError) as exc_info: 91 | smartmeter().login() 92 | assert "Login failed. Could not extract 'code' from 'Location'" in str(exc_info.value) 93 | 94 | 95 | @pytest.mark.usefixtures("requests_mock") 96 | def test_unsuccessful_login_failing_on_connection_timeout_while_retrieving_access_token(requests_mock): 97 | mock_login_page(requests_mock) 98 | mock_authenticate(requests_mock, USERNAME, PASSWORD) 99 | mock_token(requests_mock, status=None) 100 | with pytest.raises(SmartmeterConnectionError) as exc_info: 101 | smartmeter().login() 102 | assert 'Could not obtain access token' == str(exc_info.value) 103 | 104 | 105 | @pytest.mark.usefixtures("requests_mock") 106 | def test_unsuccessful_login_failing_on_empty_response_while_retrieving_access_token(requests_mock): 107 | mock_login_page(requests_mock) 108 | mock_authenticate(requests_mock, USERNAME, PASSWORD) 109 | mock_token(requests_mock, status=404) 110 | with pytest.raises(SmartmeterConnectionError) as exc_info: 111 | smartmeter().login() 112 | assert 'Could not obtain access token: ' in str(exc_info.value) 113 | 114 | 115 | @pytest.mark.usefixtures("requests_mock") 116 | def test_unsuccessful_login_failing_on_connection_timeout_while_get_config_on_get_api_key(requests_mock): 117 | mock_login_page(requests_mock) 118 | mock_authenticate(requests_mock, USERNAME, PASSWORD) 119 | mock_token(requests_mock) 120 | mock_get_api_key(requests_mock, get_config_status=None) 121 | with pytest.raises(SmartmeterConnectionError) as exc_info: 122 | smartmeter().login() 123 | assert 'Could not obtain API key' == str(exc_info.value) 124 | 125 | @pytest.mark.usefixtures("requests_mock") 126 | def test_unsuccessful_login_failing_on_b2c_api_key_missing(requests_mock): 127 | mock_login_page(requests_mock) 128 | mock_authenticate(requests_mock, USERNAME, PASSWORD) 129 | mock_token(requests_mock) 130 | mock_get_api_key(requests_mock, include_b2c_key = False) 131 | with pytest.raises(SmartmeterConnectionError) as exc_info: 132 | smartmeter().login() 133 | assert 'b2cApiKey not found in response!' == str(exc_info.value) 134 | 135 | @pytest.mark.usefixtures("requests_mock") 136 | def test_unsuccessful_login_failing_on_b2b_api_key_missing(requests_mock): 137 | mock_login_page(requests_mock) 138 | mock_authenticate(requests_mock, USERNAME, PASSWORD) 139 | mock_token(requests_mock) 140 | mock_get_api_key(requests_mock, include_b2b_key = False) 141 | with pytest.raises(SmartmeterConnectionError) as exc_info: 142 | smartmeter().login() 143 | assert 'b2bApiKey not found in response!' == str(exc_info.value) 144 | 145 | @pytest.mark.usefixtures("requests_mock") 146 | def test_warning_b2c_api_key_change(requests_mock,caplog): 147 | mock_login_page(requests_mock) 148 | mock_authenticate(requests_mock, USERNAME, PASSWORD) 149 | mock_token(requests_mock) 150 | mock_get_api_key(requests_mock, same_b2c_url = False) 151 | smartmeter().login() 152 | assert const.API_URL == "https://api.wstw.at/gateway/WN_SMART_METER_PORTAL_API_B2C/2.0" 153 | assert 'The b2cApiUrl has changed' in caplog.text 154 | 155 | @pytest.mark.usefixtures("requests_mock") 156 | def test_warning_b2b_api_key_change(requests_mock,caplog): 157 | mock_login_page(requests_mock) 158 | mock_authenticate(requests_mock, USERNAME, PASSWORD) 159 | mock_token(requests_mock) 160 | mock_get_api_key(requests_mock, same_b2b_url = False) 161 | smartmeter().login() 162 | assert const.API_URL_B2B == "https://api.wstw.at/gateway/WN_SMART_METER_PORTAL_API_B2B/2.0" 163 | assert 'The b2bApiUrl has changed' in caplog.text 164 | 165 | @pytest.mark.usefixtures("requests_mock") 166 | def test_access_key_expired(requests_mock): 167 | mock_login_page(requests_mock) 168 | mock_authenticate(requests_mock, USERNAME, PASSWORD) 169 | mock_token(requests_mock, expires=1) 170 | mock_get_api_key(requests_mock) 171 | sm = smartmeter(username=USERNAME, password=PASSWORD).login() 172 | time.sleep(2) 173 | with pytest.raises(SmartmeterConnectionError) as exc_info: 174 | sm._access_valid_or_raise() 175 | assert 'Access Token is not valid anymore' in str(exc_info.value) 176 | 177 | 178 | @pytest.mark.usefixtures("requests_mock") 179 | def test_zaehlpunkte(requests_mock: Mocker): 180 | expect_login(requests_mock) 181 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt()), disabled(zaehlpunkt())]) 182 | 183 | zps = smartmeter().login().zaehlpunkte() 184 | assert 2 == len(zps[0]['zaehlpunkte']) 185 | assert zps[0]['zaehlpunkte'][0]['isActive'] 186 | assert not zps[0]['zaehlpunkte'][1]['isActive'] 187 | 188 | 189 | @pytest.mark.usefixtures("requests_mock") 190 | def test_history(requests_mock: Mocker): 191 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 192 | zp = z["zaehlpunkte"][0]['zaehlpunktnummer'] 193 | customer_id = z["geschaeftspartner"] 194 | expect_login(requests_mock) 195 | expect_history(requests_mock, customer_id, zp) 196 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 197 | hist = smartmeter().login().historical_data() 198 | assert 1 == len(hist['messwerte']) 199 | assert 'WH' == hist['einheit'] 200 | assert '1-1:1.8.0' == hist['obisCode'] 201 | 202 | @pytest.mark.usefixtures("requests_mock") 203 | def test_history_with_zp(requests_mock: Mocker): 204 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 205 | zp = z["zaehlpunkte"][0]['zaehlpunktnummer'] 206 | customer_id = z["geschaeftspartner"] 207 | expect_login(requests_mock) 208 | expect_history(requests_mock, customer_id, zp) 209 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 210 | hist = smartmeter().login().historical_data(zp) 211 | assert 1 == len(hist['messwerte']) 212 | assert 'WH' == hist['einheit'] 213 | assert '1-1:1.8.0' == hist['obisCode'] 214 | 215 | @pytest.mark.usefixtures("requests_mock") 216 | def test_history_wrong_zp(requests_mock: Mocker, caplog): 217 | caplog.set_level(logging.DEBUG) 218 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 219 | zp = z["zaehlpunkte"][0]['zaehlpunktnummer'] 220 | customer_id = z["geschaeftspartner"] 221 | expect_login(requests_mock) 222 | expect_history(requests_mock, customer_id, zp, wrong_zp = True) 223 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 224 | with pytest.raises(SmartmeterQueryError) as exc_info: 225 | smartmeter().login().historical_data() 226 | assert 'Returned data: ' in caplog.text 227 | assert 'Returned data does not match given zaehlpunkt!' == str(exc_info.value) 228 | 229 | @pytest.mark.usefixtures("requests_mock") 230 | def test_history_invalid_obis_code(requests_mock: Mocker): 231 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 232 | zp = z["zaehlpunkte"][0]['zaehlpunktnummer'] 233 | customer_id = z["geschaeftspartner"] 234 | expect_login(requests_mock) 235 | expect_history(requests_mock, customer_id, zp, all_invalid_obis = True) 236 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 237 | with pytest.raises(SmartmeterQueryError) as exc_info: 238 | smartmeter().login().historical_data() 239 | assert "No valid OBIS code found. OBIS codes in data: ['9-9:9.9.9']" == str(exc_info.value) 240 | 241 | @pytest.mark.usefixtures("requests_mock") 242 | def test_history_multiple_zaehlwerke_one_valid(requests_mock: Mocker): 243 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 244 | zp = z["zaehlpunkte"][0]['zaehlpunktnummer'] 245 | customer_id = z["geschaeftspartner"] 246 | expect_login(requests_mock) 247 | expect_history(requests_mock, customer_id, zp, zaehlwerk_amount = 3) 248 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 249 | hist = smartmeter().login().historical_data() 250 | assert 1 == len(hist['messwerte']) 251 | assert 'WH' == hist['einheit'] 252 | assert '1-1:1.8.0' == hist['obisCode'] 253 | 254 | @pytest.mark.usefixtures("requests_mock") 255 | def test_history_multiple_zaehlwerke_all_valid(requests_mock: Mocker, caplog): 256 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 257 | zp = z["zaehlpunkte"][0]['zaehlpunktnummer'] 258 | customer_id = z["geschaeftspartner"] 259 | expect_login(requests_mock) 260 | expect_history(requests_mock, customer_id, zp, zaehlwerk_amount = 3, all_valid_obis = True) 261 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 262 | hist = smartmeter().login().historical_data() 263 | assert 1 == len(hist['messwerte']) 264 | assert 'WH' == hist['einheit'] 265 | assert '1-1:1.8.0' == hist['obisCode'] 266 | assert "Multiple valid OBIS codes found: ['1-1:1.8.0', '1-1:1.9.0', '1-1:2.8.0']. Using the first one." in caplog.text 267 | 268 | @pytest.mark.usefixtures("requests_mock") 269 | def test_history_multiple_zaehlwerke_all_invalid(requests_mock: Mocker, caplog): 270 | caplog.set_level(logging.DEBUG) 271 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 272 | zp = z["zaehlpunkte"][0]['zaehlpunktnummer'] 273 | customer_id = z["geschaeftspartner"] 274 | expect_login(requests_mock) 275 | expect_history(requests_mock, customer_id, zp, zaehlwerk_amount = 3, all_invalid_obis = True) 276 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 277 | with pytest.raises(SmartmeterQueryError) as exc_info: 278 | smartmeter().login().historical_data() 279 | assert 'Returned zaehlwerke: ' in caplog.text 280 | assert "No valid OBIS code found. OBIS codes in data: ['9-9:9.9.9', '9-9:9.9.9', '9-9:9.9.9']" == str(exc_info.value) 281 | 282 | @pytest.mark.usefixtures("requests_mock") 283 | def test_history_empty_messwerte(requests_mock: Mocker, caplog): 284 | caplog.set_level(logging.DEBUG) 285 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 286 | zp = z["zaehlpunkte"][0]['zaehlpunktnummer'] 287 | customer_id = z["geschaeftspartner"] 288 | expect_login(requests_mock) 289 | expect_history(requests_mock, customer_id, zp, empty_messwerte = True) 290 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 291 | hist=smartmeter().login().historical_data() 292 | assert 0 == len(hist['messwerte']) 293 | assert 'WH' == hist['einheit'] 294 | assert '1-1:1.8.0' == hist['obisCode'] 295 | assert "Valid OBIS code '1-1:1.8.0' has empty or missing messwerte." in caplog.text 296 | 297 | @pytest.mark.usefixtures("requests_mock") 298 | def test_history_no_zaehlwerke(requests_mock: Mocker, caplog): 299 | caplog.set_level(logging.DEBUG) 300 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 301 | zp = z["zaehlpunkte"][0]['zaehlpunktnummer'] 302 | customer_id = z["geschaeftspartner"] 303 | expect_login(requests_mock) 304 | expect_history(requests_mock, customer_id, zp, no_zaehlwerke = True) 305 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 306 | with pytest.raises(SmartmeterQueryError) as exc_info: 307 | smartmeter().login().historical_data() 308 | assert 'Returned data: ' in caplog.text 309 | assert 'Returned data does not contain any zaehlwerke or is empty.' == str(exc_info.value) 310 | 311 | @pytest.mark.usefixtures("requests_mock") 312 | def test_history_empty_zaehlwerke(requests_mock: Mocker, caplog): 313 | caplog.set_level(logging.DEBUG) 314 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 315 | zp = z["zaehlpunkte"][0]['zaehlpunktnummer'] 316 | customer_id = z["geschaeftspartner"] 317 | expect_login(requests_mock) 318 | expect_history(requests_mock, customer_id, zp, empty_zaehlwerke = True) 319 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 320 | with pytest.raises(SmartmeterQueryError) as exc_info: 321 | smartmeter().login().historical_data() 322 | assert 'Returned data: ' in caplog.text 323 | assert 'Returned data does not contain any zaehlwerke or is empty.' == str(exc_info.value) 324 | 325 | @pytest.mark.usefixtures("requests_mock") 326 | def test_history_no_obis_code(requests_mock: Mocker, caplog): 327 | caplog.set_level(logging.DEBUG) 328 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 329 | zp = z["zaehlpunkte"][0]['zaehlpunktnummer'] 330 | customer_id = z["geschaeftspartner"] 331 | expect_login(requests_mock) 332 | expect_history(requests_mock, customer_id, zp, no_obis_code = True) 333 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 334 | with pytest.raises(SmartmeterQueryError) as exc_info: 335 | smartmeter().login().historical_data() 336 | assert 'Returned zaehlwerke: ' in caplog.text 337 | assert 'No OBIS codes found in the provided data.' == str(exc_info.value) 338 | 339 | @pytest.mark.usefixtures("requests_mock") 340 | def test_bewegungsdaten_quarterly_hour_consuming(requests_mock: Mocker): 341 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 342 | dateFrom = dt.datetime(2023, 4, 21, 00, 00, 00, 0) 343 | dateTo = dt.datetime(2023, 5, 1, 23, 59, 59, 999999) 344 | zpn = z["zaehlpunkte"][0]['zaehlpunktnummer'] 345 | expect_login(requests_mock) 346 | expect_bewegungsdaten(requests_mock, z["geschaeftspartner"], zpn, dateFrom, dateTo, const.ValueType.QUARTER_HOUR, values_count=COUNT) 347 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 348 | 349 | hist = smartmeter().login().bewegungsdaten(None, dateFrom, dateTo) 350 | 351 | assert 10 == len(hist['values']) 352 | 353 | @pytest.mark.usefixtures("requests_mock") 354 | def test_bewegungsdaten_daily_consuming(requests_mock: Mocker): 355 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 356 | dateFrom = dt.datetime(2023, 4, 21, 00, 00, 00, 0) 357 | dateTo = dt.datetime(2023, 5, 1, 23, 59, 59, 999999) 358 | zpn = z["zaehlpunkte"][0]['zaehlpunktnummer'] 359 | expect_login(requests_mock) 360 | expect_bewegungsdaten(requests_mock, z["geschaeftspartner"], zpn, dateFrom, dateTo, const.ValueType.DAY, values_count=COUNT) 361 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 362 | 363 | hist = smartmeter().login().bewegungsdaten(None, dateFrom, dateTo, const.ValueType.DAY) 364 | 365 | assert 10 == len(hist['values']) 366 | 367 | @pytest.mark.usefixtures("requests_mock") 368 | def test_bewegungsdaten_quarterly_hour_feeding(requests_mock: Mocker): 369 | z = zaehlpunkt_response([enabled(zaehlpunkt_feeding())])[0] 370 | dateFrom = dt.datetime(2023, 4, 21, 00, 00, 00, 0) 371 | dateTo = dt.datetime(2023, 5, 1, 23, 59, 59, 999999) 372 | zpn = z["zaehlpunkte"][0]['zaehlpunktnummer'] 373 | expect_login(requests_mock) 374 | expect_bewegungsdaten(requests_mock, z["geschaeftspartner"], zpn, dateFrom, dateTo, const.ValueType.QUARTER_HOUR, const.AnlagenType.FEEDING, values_count=COUNT) 375 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt_feeding())]) 376 | 377 | hist = smartmeter().login().bewegungsdaten(None, dateFrom, dateTo) 378 | 379 | assert 10 == len(hist['values']) 380 | 381 | @pytest.mark.usefixtures("requests_mock") 382 | def test_bewegungsdaten_daily_feeding(requests_mock: Mocker): 383 | z = zaehlpunkt_response([enabled(zaehlpunkt_feeding())])[0] 384 | dateFrom = dt.datetime(2023, 4, 21, 00, 00, 00, 0) 385 | dateTo = dt.datetime(2023, 5, 1, 23, 59, 59, 999999) 386 | zpn = z["zaehlpunkte"][0]['zaehlpunktnummer'] 387 | expect_login(requests_mock) 388 | expect_bewegungsdaten(requests_mock, z["geschaeftspartner"], zpn, dateFrom, dateTo, const.ValueType.DAY, const.AnlagenType.FEEDING, values_count=COUNT) 389 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt_feeding())]) 390 | 391 | hist = smartmeter().login().bewegungsdaten(None, dateFrom, dateTo, const.ValueType.DAY) 392 | 393 | assert 10 == len(hist['values']) 394 | 395 | @pytest.mark.usefixtures("requests_mock") 396 | def test_bewegungsdaten_no_dates_given(requests_mock: Mocker): 397 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 398 | dateTo = dt.date.today() 399 | dateFrom = dateTo - relativedelta(years=3) 400 | zpn = z["zaehlpunkte"][0]['zaehlpunktnummer'] 401 | expect_login(requests_mock) 402 | expect_bewegungsdaten(requests_mock, z["geschaeftspartner"], zpn, dateFrom, dateTo, values_count=COUNT) 403 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 404 | 405 | hist = smartmeter().login().bewegungsdaten() 406 | 407 | assert 10 == len(hist['values']) 408 | 409 | @pytest.mark.usefixtures("requests_mock") 410 | def test_bewegungsdaten_wrong_zp(requests_mock: Mocker): 411 | z = zaehlpunkt_response([enabled(zaehlpunkt())])[0] 412 | dateFrom = dt.datetime(2023, 4, 21, 00, 00, 00, 0) 413 | dateTo = dt.datetime(2023, 5, 1, 23, 59, 59, 999999) 414 | zpn = z["zaehlpunkte"][0]['zaehlpunktnummer'] 415 | expect_login(requests_mock) 416 | expect_bewegungsdaten(requests_mock, z["geschaeftspartner"], zpn, dateFrom, dateTo, wrong_zp = True, values_count=COUNT) 417 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 418 | with pytest.raises(SmartmeterQueryError) as exc_info: 419 | smartmeter().login().bewegungsdaten(None, dateFrom, dateTo) 420 | assert 'Returned data does not match given zaehlpunkt!' == str(exc_info.value) 421 | 422 | 423 | @pytest.mark.usefixtures("requests_mock") 424 | def test_verbrauch_raw(requests_mock: Mocker): 425 | 426 | dateFrom = dt.datetime(2023, 4, 21, 22, 00, 00) 427 | zp = "AT000000001234567890" 428 | customer_id = "123456789" 429 | valid_verbrauch_raw_response = verbrauch_raw_response() 430 | expect_login(requests_mock) 431 | expect_history(requests_mock, customer_id, enabled(zaehlpunkt())['zaehlpunktnummer']) 432 | expect_zaehlpunkte(requests_mock, [enabled(zaehlpunkt())]) 433 | expect_verbrauch(requests_mock, customer_id, zp, dateFrom, valid_verbrauch_raw_response) 434 | 435 | verbrauch = smartmeter().login().verbrauch(customer_id, zp, dateFrom) 436 | 437 | assert 7 == len(verbrauch['values']) 438 | -------------------------------------------------------------------------------- /custom_components/wnsm/api/client.py: -------------------------------------------------------------------------------- 1 | """Contains the Smartmeter API Client.""" 2 | import json 3 | import logging 4 | from datetime import datetime, timedelta, date 5 | from urllib import parse 6 | from typing import List, Dict, Any 7 | 8 | import requests 9 | from dateutil.relativedelta import relativedelta 10 | from lxml import html 11 | 12 | import base64 13 | import hashlib 14 | import os 15 | import copy 16 | import re 17 | 18 | from . import constants as const 19 | from .errors import ( 20 | SmartmeterConnectionError, 21 | SmartmeterLoginError, 22 | SmartmeterQueryError, 23 | ) 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | class Smartmeter: 29 | """Smartmeter client.""" 30 | 31 | def __init__(self, username, password, input_code_verifier=None): 32 | """Access the Smartmeter API. 33 | 34 | Args: 35 | username (str): Username used for API Login. 36 | password (str): Password used for API Login. 37 | """ 38 | self.username = username 39 | self.password = password 40 | self.session = requests.Session() 41 | self._access_token = None 42 | self._refresh_token = None 43 | self._api_gateway_token = None 44 | self._access_token_expiration = None 45 | self._refresh_token_expiration = None 46 | self._api_gateway_b2b_token = None 47 | 48 | self._code_verifier = None 49 | if input_code_verifier is not None: 50 | if self.is_valid_code_verifier(input_code_verifier): 51 | self._code_verifier = input_code_verifier 52 | 53 | self._code_challenge = None 54 | self._local_login_args = None 55 | 56 | def reset(self): 57 | self.session = requests.Session() 58 | self._access_token = None 59 | self._refresh_token = None 60 | self._api_gateway_token = None 61 | self._access_token_expiration = None 62 | self._refresh_token_expiration = None 63 | self._api_gateway_b2b_token = None 64 | self._code_verifier = None 65 | self._code_challenge = None 66 | self._local_login_args = None 67 | 68 | def is_login_expired(self): 69 | return self._access_token_expiration is not None and datetime.now() >= self._access_token_expiration 70 | 71 | def is_logged_in(self): 72 | return self._access_token is not None and not self.is_login_expired() 73 | 74 | def generate_code_verifier(self): 75 | """ 76 | generate a code verifier 77 | """ 78 | return base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8').rstrip('=') 79 | 80 | def generate_code_challenge(self, code_verifier): 81 | """ 82 | generate a code challenge from the code verifier 83 | """ 84 | code_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest() 85 | return base64.urlsafe_b64encode(code_challenge).decode('utf-8').rstrip('=') 86 | 87 | def is_valid_code_verifier(self, code_verifier): 88 | if not (43 <= len(code_verifier) <= 128): 89 | return False 90 | 91 | pattern = r'^[A-Za-z0-9\-._~]+$' 92 | if not re.match(pattern, code_verifier): 93 | return False 94 | 95 | return True 96 | 97 | def load_login_page(self): 98 | """ 99 | loads login page and extracts encoded login url 100 | """ 101 | 102 | #generate a code verifier, which serves as a secure random value 103 | if not hasattr(self, '_code_verifier') or self._code_verifier is None: 104 | #only generate if it does not exist 105 | self._code_verifier = self.generate_code_verifier() 106 | 107 | #generate a code challenge from the code verifier to enhance security 108 | self._code_challenge = self.generate_code_challenge(self._code_verifier) 109 | 110 | #copy const.LOGIN_ARGS 111 | self._local_login_args = copy.deepcopy(const.LOGIN_ARGS) 112 | 113 | #add code_challenge in self._local_login_args 114 | self._local_login_args["code_challenge"] = self._code_challenge 115 | 116 | login_url = const.AUTH_URL + "auth?" + parse.urlencode(self._local_login_args) 117 | try: 118 | result = self.session.get(login_url) 119 | except Exception as exception: 120 | raise SmartmeterConnectionError("Could not load login page") from exception 121 | if result.status_code != 200: 122 | raise SmartmeterConnectionError( 123 | f"Could not load login page. Error: {result.content}" 124 | ) 125 | tree = html.fromstring(result.content) 126 | forms = tree.xpath("(//form/@action)") 127 | 128 | if not forms: 129 | raise SmartmeterConnectionError("No form found on the login page.") 130 | 131 | action = forms[0] 132 | return action 133 | 134 | def credentials_login(self, url): 135 | """ 136 | login with credentials provided the login url 137 | """ 138 | try: 139 | result = self.session.post( 140 | url, 141 | data={ 142 | "username": self.username, 143 | "login": " " 144 | }, 145 | allow_redirects=False, 146 | ) 147 | tree = html.fromstring(result.content) 148 | action = tree.xpath("(//form/@action)")[0] 149 | 150 | result = self.session.post( 151 | action, 152 | data={ 153 | "username": self.username, 154 | "password": self.password, 155 | }, 156 | allow_redirects=False, 157 | ) 158 | except Exception as exception: 159 | raise SmartmeterConnectionError( 160 | "Could not login with credentials" 161 | ) from exception 162 | 163 | if "Location" not in result.headers: 164 | raise SmartmeterLoginError("Login failed. Check username/password.") 165 | location = result.headers["Location"] 166 | 167 | parsed_url = parse.urlparse(location) 168 | 169 | fragment_dict = dict( 170 | [ 171 | x.split("=") 172 | for x in parsed_url.fragment.split("&") 173 | if len(x.split("=")) == 2 174 | ] 175 | ) 176 | if "code" not in fragment_dict: 177 | raise SmartmeterLoginError( 178 | "Login failed. Could not extract 'code' from 'Location'" 179 | ) 180 | 181 | code = fragment_dict["code"] 182 | return code 183 | 184 | def load_tokens(self, code): 185 | """ 186 | Provided the totp code loads access and refresh token 187 | """ 188 | try: 189 | result = self.session.post( 190 | const.AUTH_URL + "token", 191 | data=const.build_access_token_args(code=code , code_verifier=self._code_verifier) 192 | ) 193 | except Exception as exception: 194 | raise SmartmeterConnectionError( 195 | "Could not obtain access token" 196 | ) from exception 197 | 198 | if result.status_code != 200: 199 | raise SmartmeterConnectionError( 200 | f"Could not obtain access token: {result.content}" 201 | ) 202 | tokens = result.json() 203 | if tokens["token_type"] != "Bearer": 204 | raise SmartmeterLoginError( 205 | f'Bearer token required, but got {tokens["token_type"]!r}' 206 | ) 207 | return tokens 208 | 209 | def login(self): 210 | """ 211 | login with credentials specified in ctor 212 | """ 213 | if self.is_login_expired(): 214 | self.reset() 215 | if not self.is_logged_in(): 216 | url = self.load_login_page() 217 | code = self.credentials_login(url) 218 | tokens = self.load_tokens(code) 219 | self._access_token = tokens["access_token"] 220 | self._refresh_token = tokens["refresh_token"] 221 | now = datetime.now() 222 | self._access_token_expiration = now + timedelta(seconds=tokens["expires_in"]) 223 | self._refresh_token_expiration = now + timedelta( 224 | seconds=tokens["refresh_expires_in"] 225 | ) 226 | 227 | logger.debug("Access Token valid until %s" % self._access_token_expiration) 228 | 229 | self._api_gateway_token, self._api_gateway_b2b_token = self._get_api_key( 230 | self._access_token 231 | ) 232 | return self 233 | 234 | def _access_valid_or_raise(self): 235 | """Checks if the access token is still valid or raises an exception""" 236 | if datetime.now() >= self._access_token_expiration: 237 | # TODO: If the refresh token is still valid, it could be refreshed here 238 | raise SmartmeterConnectionError( 239 | "Access Token is not valid anymore, please re-log!" 240 | ) 241 | 242 | def _get_api_key(self, token): 243 | self._access_valid_or_raise() 244 | 245 | headers = {"Authorization": f"Bearer {token}"} 246 | try: 247 | result = self.session.get(const.API_CONFIG_URL, headers=headers).json() 248 | except Exception as exception: 249 | raise SmartmeterConnectionError("Could not obtain API key") from exception 250 | 251 | find_keys = ["b2cApiKey", "b2bApiKey"] 252 | for key in find_keys: 253 | if key not in result: 254 | raise SmartmeterConnectionError(f"{key} not found in response!") 255 | 256 | # The b2bApiUrl and b2cApiUrl can also be gathered from the configuration 257 | # TODO: reduce code duplication... 258 | if "b2cApiUrl" in result and result["b2cApiUrl"] != const.API_URL: 259 | const.API_URL = result["b2cApiUrl"] 260 | logger.warning("The b2cApiUrl has changed to %s! Update API_URL!", const.API_URL) 261 | if "b2bApiUrl" in result and result["b2bApiUrl"] != const.API_URL_B2B: 262 | const.API_URL_B2B = result["b2bApiUrl"] 263 | logger.warning("The b2bApiUrl has changed to %s! Update API_URL_B2B!", const.API_URL_B2B) 264 | 265 | return (result[key] for key in find_keys) 266 | 267 | @staticmethod 268 | def _dt_string(datetime_string): 269 | return datetime_string.strftime(const.API_DATE_FORMAT)[:-3] + "Z" 270 | 271 | def _call_api( 272 | self, 273 | endpoint, 274 | base_url=None, 275 | method="GET", 276 | data=None, 277 | query=None, 278 | return_response=False, 279 | timeout=60.0, 280 | extra_headers=None, 281 | ): 282 | self._access_valid_or_raise() 283 | 284 | if base_url is None: 285 | base_url = const.API_URL 286 | url = parse.urljoin(base_url, endpoint) 287 | 288 | if query: 289 | url += ("?" if "?" not in endpoint else "&") + parse.urlencode(query) 290 | 291 | headers = { 292 | "Authorization": f"Bearer {self._access_token}", 293 | } 294 | 295 | # For API calls to B2C or B2B, we need to add the Gateway-APIKey: 296 | # TODO: This may be prone to errors if URLs are compared like this. 297 | # The Strings has to be exactly the same, but that may not be the case, 298 | # even though the URLs are the same. 299 | if base_url == const.API_URL: 300 | headers["X-Gateway-APIKey"] = self._api_gateway_token 301 | elif base_url == const.API_URL_B2B: 302 | headers["X-Gateway-APIKey"] = self._api_gateway_b2b_token 303 | 304 | if extra_headers: 305 | headers.update(extra_headers) 306 | 307 | if data: 308 | headers["Content-Type"] = "application/json" 309 | 310 | response = self.session.request( 311 | method, url, headers=headers, json=data, timeout=timeout 312 | ) 313 | 314 | logger.debug("\nAPI Request: %s\n%s\n\nAPI Response: %s" % ( 315 | url, ("" if data is None else "body: "+json.dumps(data, indent=2)), 316 | None if response is None or response.json() is None else json.dumps(response.json(), indent=2))) 317 | 318 | if return_response: 319 | return response 320 | 321 | return response.json() 322 | 323 | def get_zaehlpunkt(self, zaehlpunkt: str = None) -> tuple[str, str, str]: 324 | contracts = self.zaehlpunkte() 325 | if zaehlpunkt is None: 326 | customer_id = contracts[0]["geschaeftspartner"] 327 | zp = contracts[0]["zaehlpunkte"][0]["zaehlpunktnummer"] 328 | anlagetype = contracts[0]["zaehlpunkte"][0]["anlage"]["typ"] 329 | else: 330 | customer_id = zp = anlagetype = None 331 | for contract in contracts: 332 | zp_details = [z for z in contract["zaehlpunkte"] if z["zaehlpunktnummer"] == zaehlpunkt] 333 | if len(zp_details) > 0: 334 | anlagetype = zp_details[0]["anlage"]["typ"] 335 | zp = zp_details[0]["zaehlpunktnummer"] 336 | customer_id = contract["geschaeftspartner"] 337 | return customer_id, zp, const.AnlagenType.from_str(anlagetype) 338 | 339 | def zaehlpunkte(self): 340 | """Returns zaehlpunkte for currently logged in user.""" 341 | return self._call_api("zaehlpunkte") 342 | 343 | def consumptions(self): 344 | """Returns response from 'consumptions' endpoint.""" 345 | return self._call_api("zaehlpunkt/consumptions") 346 | 347 | def base_information(self): 348 | """Returns response from 'baseInformation' endpoint.""" 349 | return self._call_api("zaehlpunkt/baseInformation") 350 | 351 | def meter_readings(self): 352 | """Returns response from 'meterReadings' endpoint.""" 353 | return self._call_api("zaehlpunkt/meterReadings") 354 | 355 | def verbrauch( 356 | self, 357 | customer_id: str, 358 | zaehlpunkt: str, 359 | date_from: datetime, 360 | resolution: const.Resolution = const.Resolution.HOUR 361 | ): 362 | """Returns energy usage. 363 | 364 | This returns hourly or quarter hour consumptions for a single day, 365 | i.e., for 24 hours after the given date_from. 366 | 367 | Args: 368 | customer_id (str): Customer ID returned by zaehlpunkt call ("geschaeftspartner") 369 | zaehlpunkt (str, optional): id for desired smartmeter. 370 | If None, check for first meter in user profile. 371 | date_from (datetime): Start date for energy usage request 372 | date_to (datetime, optional): End date for energy usage request. 373 | Defaults to datetime.now() 374 | resolution (const.Resolution, optional): Specify either 1h or 15min resolution 375 | Returns: 376 | dict: JSON response of api call to 377 | 'messdaten/CUSTOMER_ID/ZAEHLPUNKT/verbrauchRaw' 378 | """ 379 | if zaehlpunkt is None or customer_id is None: 380 | customer_id, zaehlpunkt, anlagetype = self.get_zaehlpunkt() 381 | endpoint = f"messdaten/{customer_id}/{zaehlpunkt}/verbrauch" 382 | query = const.build_verbrauchs_args( 383 | # This one does not have a dateTo... 384 | dateFrom=self._dt_string(date_from), 385 | dayViewResolution=resolution.value 386 | ) 387 | return self._call_api(endpoint, query=query) 388 | 389 | def verbrauchRaw( 390 | self, 391 | customer_id: str, 392 | zaehlpunkt: str, 393 | date_from: datetime, 394 | date_to: datetime = None, 395 | ): 396 | """Returns energy usage. 397 | This can be used to query the daily consumption for a long period of time, 398 | for example several months or a week. 399 | 400 | Note: The minimal resolution is a single day. 401 | For hourly consumptions use `verbrauch`. 402 | 403 | Args: 404 | customer_id (str): Customer ID returned by zaehlpunkt call ("geschaeftspartner") 405 | zaehlpunkt (str, optional): id for desired smartmeter. 406 | If None, check for first meter in user profile. 407 | date_from (datetime): Start date for energy usage request 408 | date_to (datetime, optional): End date for energy usage request. 409 | Defaults to datetime.now() 410 | Returns: 411 | dict: JSON response of api call to 412 | 'messdaten/CUSTOMER_ID/ZAEHLPUNKT/verbrauchRaw' 413 | """ 414 | if date_to is None: 415 | date_to = datetime.now() 416 | if zaehlpunkt is None or customer_id is None: 417 | customer_id, zaehlpunkt, anlagetype = self.get_zaehlpunkt() 418 | endpoint = f"messdaten/{customer_id}/{zaehlpunkt}/verbrauchRaw" 419 | query = dict( 420 | # These are the only three fields that are used for that endpoint: 421 | dateFrom=self._dt_string(date_from), 422 | dateTo=self._dt_string(date_to), 423 | granularity="DAY", 424 | ) 425 | return self._call_api(endpoint, query=query) 426 | 427 | def profil(self): 428 | """Returns profile of a logged-in user. 429 | 430 | Returns: 431 | dict: JSON response of api call to 'user/profile' 432 | """ 433 | return self._call_api("user/profile", const.API_URL_ALT) 434 | 435 | def ereignisse( 436 | self, date_from: datetime, date_to: datetime = None, zaehlpunkt=None 437 | ): 438 | """Returns events between date_from and date_to of a specific smart meter. 439 | Args: 440 | date_from (datetime.datetime): Starting date for request 441 | date_to (datetime.datetime, optional): Ending date for request. 442 | Defaults to datetime.datetime.now(). 443 | zaehlpunkt (str, optional): id for desired smart meter. 444 | If is None check for first meter in user profile. 445 | Returns: 446 | dict: JSON response of api call to 'user/ereignisse' 447 | """ 448 | if date_to is None: 449 | date_to = datetime.now() 450 | if zaehlpunkt is None: 451 | customer_id, zaehlpunkt, anlagetype = self.get_zaehlpunkt() 452 | query = { 453 | "zaehlpunkt": zaehlpunkt, 454 | "dateFrom": self._dt_string(date_from), 455 | "dateUntil": self._dt_string(date_to), 456 | } 457 | return self._call_api("user/ereignisse", const.API_URL_ALT, query=query) 458 | 459 | def create_ereignis(self, zaehlpunkt, name, date_from, date_to=None): 460 | """Creates new event. 461 | Args: 462 | zaehlpunkt (str): Id for desired smartmeter. 463 | If None, check for first meter in user profile 464 | name (str): Event name 465 | date_from (datetime.datetime): (Starting) date for request 466 | date_to (datetime.datetime, optional): Ending date for request. 467 | Returns: 468 | dict: JSON response of api call to 'user/ereignis' 469 | """ 470 | if date_to is None: 471 | dto = None 472 | typ = "ZEITPUNKT" 473 | else: 474 | dto = self._dt_string(date_to) 475 | typ = "ZEITSPANNE" 476 | 477 | data = { 478 | "endAt": dto, 479 | "name": name, 480 | "startAt": self._dt_string(date_from), 481 | "typ": typ, 482 | "zaehlpunkt": zaehlpunkt, 483 | } 484 | 485 | return self._call_api("user/ereignis", data=data, method="POST") 486 | 487 | def delete_ereignis(self, ereignis_id): 488 | """Deletes ereignis.""" 489 | return self._call_api(f"user/ereignis/{ereignis_id}", method="DELETE") 490 | 491 | def find_valid_obis_data(self, zaehlwerke: List[Dict[str, Any]]) -> Dict[str, Any]: 492 | """ 493 | Find and validate data with valid OBIS codes from a list of zaehlwerke. 494 | """ 495 | 496 | # Check if any OBIS codes exist 497 | all_obis_codes = [zaehlwerk.get("obisCode") for zaehlwerk in zaehlwerke] 498 | if not any(all_obis_codes): 499 | logger.debug("Returned zaehlwerke: %s", zaehlwerke) 500 | raise SmartmeterQueryError("No OBIS codes found in the provided data.") 501 | 502 | # Filter data for valid OBIS codes 503 | valid_data = [ 504 | zaehlwerk for zaehlwerk in zaehlwerke 505 | if zaehlwerk.get("obisCode") in const.VALID_OBIS_CODES 506 | ] 507 | 508 | if not valid_data: 509 | logger.debug("Returned zaehlwerke: %s", zaehlwerke) 510 | raise SmartmeterQueryError(f"No valid OBIS code found. OBIS codes in data: {all_obis_codes}") 511 | 512 | # Check for empty or missing messwerte 513 | for zaehlwerk in valid_data: 514 | if not zaehlwerk.get("messwerte"): 515 | obis = zaehlwerk.get("obisCode") 516 | logger.debug(f"Valid OBIS code '{obis}' has empty or missing messwerte. Data is probably not available yet.") 517 | 518 | # Log a warning if multiple valid OBIS codes are found 519 | if len(valid_data) > 1: 520 | found_valid_obis = [zaehlwerk["obisCode"] for zaehlwerk in valid_data] 521 | logger.warning(f"Multiple valid OBIS codes found: {found_valid_obis}. Using the first one.") 522 | 523 | return valid_data[0] 524 | 525 | def historical_data( 526 | self, 527 | zaehlpunktnummer: str = None, 528 | date_from: date = None, 529 | date_until: date = None, 530 | valuetype: const.ValueType = const.ValueType.METER_READ 531 | ): 532 | """ 533 | Query historical data in a batch 534 | If no arguments are given, a span of three year is queried (same day as today but from current year - 3). 535 | If date_from is not given but date_until, again a three year span is assumed. 536 | """ 537 | # Resolve Zaehlpunkt 538 | if zaehlpunktnummer is None: 539 | customer_id, zaehlpunkt, anlagetype = self.get_zaehlpunkt() 540 | else: 541 | customer_id, zaehlpunkt, anlagetype = self.get_zaehlpunkt(zaehlpunktnummer) 542 | 543 | # Set date range defaults 544 | if date_until is None: 545 | date_until = date.today() 546 | 547 | if date_from is None: 548 | date_from = date_until - relativedelta(years=3) 549 | 550 | # Query parameters 551 | query = { 552 | "datumVon": date_from.strftime("%Y-%m-%d"), 553 | "datumBis": date_until.strftime("%Y-%m-%d"), 554 | "wertetyp": valuetype.value, 555 | } 556 | 557 | extra = { 558 | # For this API Call, requesting json is important! 559 | "Accept": "application/json" 560 | } 561 | 562 | # API Call 563 | data = self._call_api( 564 | f"zaehlpunkte/{customer_id}/{zaehlpunkt}/messwerte", 565 | base_url=const.API_URL_B2B, 566 | query=query, 567 | extra_headers=extra, 568 | ) 569 | 570 | # Sanity check: Validate returned zaehlpunkt 571 | if data.get("zaehlpunkt") != zaehlpunkt: 572 | logger.debug("Returned data: %s", data) 573 | raise SmartmeterQueryError("Returned data does not match given zaehlpunkt!") 574 | 575 | # Validate and extract valid OBIS data 576 | zaehlwerke = data.get("zaehlwerke") 577 | if not zaehlwerke: 578 | logger.debug("Returned data: %s", data) 579 | raise SmartmeterQueryError("Returned data does not contain any zaehlwerke or is empty.") 580 | 581 | valid_obis_data = self.find_valid_obis_data(zaehlwerke) 582 | return valid_obis_data 583 | 584 | def bewegungsdaten( 585 | self, 586 | zaehlpunktnummer: str = None, 587 | date_from: date = None, 588 | date_until: date = None, 589 | valuetype: const.ValueType = const.ValueType.QUARTER_HOUR, 590 | aggregat: str = None, 591 | ): 592 | """ 593 | Query historical data in a batch 594 | If no arguments are given, a span of three year is queried (same day as today but from current year - 3). 595 | If date_from is not given but date_until, again a three year span is assumed. 596 | """ 597 | customer_id, zaehlpunkt, anlagetype = self.get_zaehlpunkt(zaehlpunktnummer) 598 | 599 | if anlagetype == const.AnlagenType.FEEDING: 600 | if valuetype == const.ValueType.DAY: 601 | rolle = const.RoleType.DAILY_FEEDING.value 602 | else: 603 | rolle = const.RoleType.QUARTER_HOURLY_FEEDING.value 604 | else: 605 | if valuetype == const.ValueType.DAY: 606 | rolle = const.RoleType.DAILY_CONSUMING.value 607 | else: 608 | rolle = const.RoleType.QUARTER_HOURLY_CONSUMING.value 609 | 610 | if date_until is None: 611 | date_until = date.today() 612 | 613 | if date_from is None: 614 | date_from = date_until - relativedelta(years=3) 615 | 616 | query = { 617 | "geschaeftspartner": customer_id, 618 | "zaehlpunktnummer": zaehlpunkt, 619 | "rolle": rolle, 620 | "zeitpunktVon": date_from.strftime("%Y-%m-%dT%H:%M:00.000Z"), # we catch up from the exact date of the last import to compensate for time shift 621 | "zeitpunktBis": date_until.strftime("%Y-%m-%dT23:59:59.999Z"), 622 | "aggregat": aggregat or "NONE" 623 | } 624 | 625 | extra = { 626 | # For this API Call, requesting json is important! 627 | "Accept": "application/json" 628 | } 629 | 630 | data = self._call_api( 631 | f"user/messwerte/bewegungsdaten", 632 | base_url=const.API_URL_ALT, 633 | query=query, 634 | extra_headers=extra, 635 | ) 636 | if data["descriptor"]["zaehlpunktnummer"] != zaehlpunkt: 637 | raise SmartmeterQueryError("Returned data does not match given zaehlpunkt!") 638 | return data 639 | -------------------------------------------------------------------------------- /tests/it/__init__.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import json 3 | import os 4 | import random 5 | import sys 6 | from datetime import datetime, timedelta 7 | from importlib.resources import files 8 | from urllib import parse 9 | from urllib.parse import urlencode 10 | 11 | import pytest 12 | import requests 13 | from requests_mock import Mocker 14 | 15 | from test_resources import post_data_matcher 16 | 17 | # necessary for pytest-cov to measure coverage 18 | myPath = os.path.dirname(os.path.abspath(__file__)) 19 | sys.path.insert(0, myPath + '/../../custom_components') 20 | from wnsm import api # noqa: E402 21 | from wnsm.api.constants import ValueType, AnlagenType, RoleType # noqa: E402 22 | 23 | 24 | def _dt_string(datetime_string): 25 | return datetime_string.isoformat(timespec='milliseconds') + "Z" 26 | 27 | CODE_VERIFIER = "30VayZvGKqlW9eImS9ksvvjRePhfox2qSYda-tLE6hc" 28 | CODE_CHALLENGE = "K3kc5ihkd-TB4ZmZT1Vo4-5vX5FNJvhNDSxYjkLkFOU" 29 | 30 | PAGE_URL = "https://smartmeter-web.wienernetze.at/" 31 | API_CONFIG_URL = "https://smartmeter-web.wienernetze.at/assets/app-config.json" 32 | API_URL_ALT = "https://service.wienernetze.at/sm/api/" 33 | API_URL_B2C = "https://api.wstw.at/gateway/WN_SMART_METER_PORTAL_API_B2C/1.0" 34 | API_URL_B2B = "https://api.wstw.at/gateway/WN_SMART_METER_PORTAL_API_B2B/1.0" 35 | REDIRECT_URI = "https://smartmeter-web.wienernetze.at/" 36 | API_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" 37 | AUTH_URL = "https://log.wien/auth/realms/logwien/protocol/openid-connect" # noqa 38 | B2C_API_KEY = "afb0be74-6455-44f5-a34d-6994223020ba" 39 | B2B_API_KEY = "93d5d520-7cc8-11eb-99bc-ba811041b5f6" 40 | LOGIN_ARGS = { 41 | "client_id": "wn-smartmeter", 42 | "redirect_uri": REDIRECT_URI, 43 | "response_mode": "fragment", 44 | "response_type": "code", 45 | "scope": "openid", 46 | "nonce": "", 47 | "code_challenge": CODE_CHALLENGE, 48 | "code_challenge_method": "S256" 49 | } 50 | 51 | USERNAME = "margit.musterfrau@gmail.com" 52 | PASSWORD = "Margit1234!" 53 | 54 | RESPONSE_CODE = 'b04c44f7-55c6-4c0e-b2af-e9d9408ded2b.949e0f0d-b447-4208-bfef-273d694dc633.c514bbef-6269-48ca-9991-d7d5cd941213' # noqa: E501 55 | 56 | ACCESS_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IlBFbVQzTlI1UHV0TlVhelhaeVdlVXphcEhENzFuTW5BQVFZeU9PUWZYVk0ifQ.eyJleHAiOjE2NzczMTIxODEsImlhdCI6MTY3NzMxMTg4MSwiYXV0aF90aW1lIjoxNjc3MzExODgwLCJqdGkiOiIxMjM0NTY3OC0xMjM0LTEyMzQtMTIzNC0xMjM0NTY3ODkwMTIiLCJpc3MiOiJodHRwczovL2xvZy53aWVuL2F1dGgvcmVhbG1zL2xvZ3dpZW4iLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEyIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoid24tc21hcnRtZXRlciIsIm5vbmNlIjoiMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEyIiwic2Vzc2lvbl9zdGF0ZSI6IjEyMzQ1Njc4LTEyMzQtMTIzNC0xMjM0LTEyMzQ1Njc4OTAxMiIsImFsbG93ZWQtb3JpZ2lucyI6WyJodHRwczovL3NtYXJ0bWV0ZXItd2ViLndpZW5lcm5ldHplLmF0IiwiaHR0cHM6Ly93d3cud2llbmVybmV0emUuYXQiLCJodHRwOi8vbG9jYWxob3N0OjQyMDAiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtd2xvZ2luIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsInNpZCI6IjEyMzQ1Njc4LTEyMzQtMTIzNC0xMjM0LTEyMzQ1Njc4OTAxMiIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiTWFyZ2l0IE11c3RlcmZyYXUiLCJjb21wYW55IjoiUHJpdmF0IiwicHJlZmVycmVkX3VzZXJuYW1lIjoibWFyZ2l0Lm11c3RlcmZyYXVAZ21haWwuY29tIiwic2FsdXRhdGlvbiI6IkZyYXUiLCJnaXZlbl9uYW1lIjoiTWFyZ2l0IiwibG9jYWxlIjoiZW4iLCJmYW1pbHlfbmFtZSI6Ik11c3RlcmZyYXUiLCJlbWFpbCI6Im1hcmdpdC5tdXN0ZXJmcmF1QGdtYWlsLmNvbSJ9.4x8uJ3LE8i5fnyw5qpTiZbi44hvoIM0MhQMCkmH_RUQ' # noqa: E501 57 | REFRESH_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJkZDFhMDQ0LTgzZWUtNDUzMi1hOTk3LTBkMjI1YzcxNTYyNCJ9.eyJleHAiOjE2NzczMTM2ODEsImlhdCI6MTY3NzMxMTg4MSwianRpIjoiMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEyIiwiaXNzIjoiaHR0cHM6Ly9sb2cud2llbi9hdXRoL3JlYWxtcy9sb2d3aWVuIiwiYXVkIjoiaHR0cHM6Ly9sb2cud2llbi9hdXRoL3JlYWxtcy9sb2d3aWVuIiwic3ViIjoiMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEyIiwidHlwIjoiUmVmcmVzaCIsImF6cCI6InduLXNtYXJ0bWV0ZXIiLCJub25jZSI6IjEyMzQ1Njc4LTEyMzQtMTIzNC0xMjM0LTEyMzQ1Njc4OTAxMiIsInNlc3Npb25fc3RhdGUiOiIxMjM0NTY3OC0xMjM0LTEyMzQtMTIzNC0xMjM0NTY3ODkwMTIiLCJzY29wZSI6Im9wZW5pZCBlbWFpbCBwcm9maWxlIiwic2lkIjoiMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEyIn0.eJ2f9hOGaLFgQmcL0WQDsyt3E92Ri9qmJ4lnhZY_W2o' # noqa: E501 58 | ID_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IlBFbVQzTlI1UHV0TlVhelhaeVdlVXphcEhENzFuTW5BQVFZeU9PUWZYVk0ifQ.eyJleHAiOjE2NzczMTIxODEsImlhdCI6MTY3NzMxMTg4MSwiYXV0aF90aW1lIjoxNjc3MzExODgwLCJqdGkiOiIxMjM0NTY3OC0xMjM0LTEyMzQtMTIzNC0xMjM0NTY3ODkwMTIiLCJpc3MiOiJodHRwczovL2xvZy53aWVuL2F1dGgvcmVhbG1zL2xvZ3dpZW4iLCJhdWQiOiJ3bi1zbWFydG1ldGVyIiwic3ViIjoiMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEyIiwidHlwIjoiSUQiLCJhenAiOiJ3bi1zbWFydG1ldGVyIiwibm9uY2UiOiIxMjM0NTY3OC0xMjM0LTEyMzQtMTIzNC0xMjM0NTY3ODkwMTIiLCJzZXNzaW9uX3N0YXRlIjoiMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEyIiwiYXRfaGFzaCI6ImZRQmFaQU1JVC1ucGktWmxCS1JTdHciLCJzaWQiOiIxMjM0NTY3OC0xMjM0LTEyMzQtMTIzNC0xMjM0NTY3ODkwMTIiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6Ik1hcmdpdCBNdXN0ZXJmcmF1IiwicHJlZmVycmVkX3VzZXJuYW1lIjoibWFyZ2l0Lm11c3RlcmZyYXVAZ21haWwuY29tIiwiZ2l2ZW5fbmFtZSI6Ik1hcmdpdCIsImxvY2FsZSI6ImVuIiwiZmFtaWx5X25hbWUiOiJNdXN0ZXJmcmF1IiwiZW1haWwiOiJtYXJnaXQubXVzdGVyZnJhdUBnbWFpbC5jb20ifQ.FBGAMU-bDKorGnIC-douEsKJ3VfwpNRBoeHMtytnow8' # noqa: E501 59 | 60 | zp_template = { 61 | "zaehlpunktnummer": "AT0010000000000000001000011111111", 62 | "equipmentNumber": "1111111111", 63 | "geraetNumber": "ABC1111111111111", 64 | "isSmartMeter": True, 65 | "isDefault": True, 66 | "isActive": True, 67 | "isDataDeleted": False, 68 | "isSmartMeterMarketReady": True, 69 | "dataDeletionTimestampUTC": None, 70 | "verbrauchsstelle": { 71 | "strasse": "Eine Strasse", 72 | "hausnummer": "1/2/3", 73 | "anlageHausnummer": "1", 74 | "postleitzahl": "1010", 75 | "ort": "Wien", 76 | "laengengrad": "16.3738", 77 | "breitengrad": "48.2082" 78 | }, 79 | "anlage": { 80 | "typ": "TAGSTROM" 81 | }, 82 | "vertraege": [ 83 | { 84 | "einzugsdatum": "2010-01-01", 85 | "auszugsdatum": "2015-12-31" 86 | } 87 | ], 88 | "idexStatus": { 89 | "granularity": { 90 | "status": "QUARTER_HOUR", 91 | "canBeChanged": True 92 | }, 93 | "customerInterface": { 94 | "status": "active", 95 | "canBeChanged": True 96 | }, 97 | "display": { 98 | "isLocked": True, 99 | "canBeChanged": True 100 | }, 101 | "displayProfile": { 102 | "canBeChanged": True, 103 | "displayProfile": "VERBRAUCH" 104 | } 105 | }, 106 | "optOutDetails": { 107 | "isOptOut": False 108 | }, 109 | "zpSharingInfo": { 110 | "isOwner": False 111 | } 112 | } 113 | 114 | zp_feeding_template = { 115 | "zaehlpunktnummer": "AT0010000000000000001000011111112", 116 | "equipmentNumber": "1111111111", 117 | "geraetNumber": "ABC1111111111111", 118 | "isSmartMeter": True, 119 | "isDefault": False, 120 | "isActive": True, 121 | "isDataDeleted": False, 122 | "isSmartMeterMarketReady": True, 123 | "dataDeletionTimestampUTC": None, 124 | "verbrauchsstelle": { 125 | "strasse": "Eine Strasse", 126 | "hausnummer": "1/2/3", 127 | "anlageHausnummer": "1", 128 | "postleitzahl": "1010", 129 | "ort": "Wien", 130 | "laengengrad": "16.3738", 131 | "breitengrad": "48.2082" 132 | }, 133 | "anlage": { 134 | "typ": "BEZUG" 135 | }, 136 | "vertraege": [ 137 | { 138 | "einzugsdatum": "2010-01-01", 139 | "auszugsdatum": "2015-12-31" 140 | } 141 | ], 142 | "idexStatus": { 143 | "granularity": { 144 | "status": "QUARTER_HOUR", 145 | "canBeChanged": True 146 | }, 147 | "customerInterface": { 148 | "status": "active", 149 | "canBeChanged": True 150 | }, 151 | "display": { 152 | "isLocked": True, 153 | "canBeChanged": True 154 | }, 155 | "displayProfile": { 156 | "canBeChanged": True, 157 | "displayProfile": "VERBRAUCH" 158 | } 159 | }, 160 | "optOutDetails": { 161 | "isOptOut": False 162 | }, 163 | "zpSharingInfo": { 164 | "isOwner": False 165 | } 166 | } 167 | 168 | def _set_status(zp: dict, status: bool): 169 | zp['isActive'] = status 170 | for idexStatus in ['granularity', 'customerInterface', 'display', 'displayProfile']: 171 | zp["idexStatus"][idexStatus]["canBeChanged"] = status 172 | return zp 173 | 174 | 175 | def enabled(zp: dict): 176 | return _set_status(zp, True) 177 | 178 | 179 | def disabled(zp: dict): 180 | return _set_status(zp, False) 181 | 182 | 183 | def quarterly(zp: dict): 184 | zp["idexStatus"]["granularity"]["status"] = "QUARTER_HOUR" 185 | return zaehlpunkt 186 | 187 | 188 | def hourly(zp: dict): 189 | zp["idexStatus"]["granularity"]["status"] = "" 190 | return zp 191 | 192 | 193 | def zaehlpunkt(): 194 | return dict(zp_template) 195 | 196 | def zaehlpunkt_feeding(): 197 | return dict(zp_feeding_template) 198 | 199 | def zaehlpunkt_response(zps=None): 200 | return [ 201 | { 202 | "bezeichnung": "Margit Musterfrau, Kundennummer 1234567890", 203 | "geschaeftspartner": "1234567890", 204 | "zaehlpunkte": zps or [] 205 | } 206 | ] 207 | 208 | 209 | def verbrauch_raw_response(): 210 | return { 211 | "quarter-hour-opt-in": True, 212 | "values": [ 213 | { 214 | "value": 5461, 215 | "timestamp": "2023-04-22T22:00:00.000Z", 216 | "isEstimated": False 217 | }, 218 | { 219 | "value": 5513, 220 | "timestamp": "2023-04-23T22:00:00.000Z", 221 | "isEstimated": False 222 | }, 223 | { 224 | "value": 4672, 225 | "timestamp": "2023-04-24T22:00:00.000Z", 226 | "isEstimated": False 227 | }, 228 | { 229 | "value": 5550, 230 | "timestamp": "2023-04-25T22:00:00.000Z", 231 | "isEstimated": False 232 | }, 233 | { 234 | "value": 3856, 235 | "timestamp": "2023-04-26T22:00:00.000Z", 236 | "isEstimated": False 237 | }, 238 | { 239 | "value": 5137, 240 | "timestamp": "2023-04-27T22:00:00.000Z", 241 | "isEstimated": False 242 | }, 243 | { 244 | "value": 6918, 245 | "timestamp": "2023-04-28T22:00:00.000Z", 246 | "isEstimated": False 247 | } 248 | ], 249 | "statistics": { 250 | "maximum": 6918, 251 | "minimum": 3856, 252 | "average": 5301 253 | } 254 | } 255 | 256 | def history_response( 257 | zp: str, 258 | zaehlwerk_amount: int = 1, 259 | wrong_zp: bool = False, 260 | all_invalid_obis: bool = False, 261 | all_valid_obis: bool = False, 262 | empty_messwerte: bool = False, 263 | no_zaehlwerke: bool = False, 264 | empty_zaehlwerke: bool = False, 265 | no_obis_code: bool = False, 266 | ): 267 | """ 268 | Generates test data for history response based on specified conditions. 269 | """ 270 | valid_obis_codes = ["1-1:1.8.0", "1-1:1.9.0", "1-1:2.8.0", "1-1:2.9.0"] 271 | invalid_obis_code = "9-9:9.9.9" 272 | 273 | # Modify zp if wrong_zp is True 274 | if wrong_zp: 275 | zp = zp[:-1] + "9" 276 | 277 | # Handle no_zaehlwerke case 278 | if no_zaehlwerke: 279 | return {"zaehlpunkt": zp} 280 | 281 | # Handle empty zaehlwerke case 282 | if empty_zaehlwerke: 283 | return {"zaehlwerke": [], "zaehlpunkt": zp} 284 | 285 | # Prepare messwerte 286 | messwerte = [] if empty_messwerte else [ 287 | { 288 | "messwert": 7256686, 289 | "zeitVon": "2024-11-11T23:00:00.000Z", 290 | "zeitBis": "2024-11-12T23:00:00.000Z", 291 | "qualitaet": "VAL", 292 | } 293 | ] 294 | 295 | # Generate zaehlwerke 296 | zaehlwerke = [] 297 | for i in range(zaehlwerk_amount): 298 | if no_obis_code: 299 | zaehlwerke.append({"einheit": "WH", "messwerte": messwerte}) 300 | else: 301 | if all_invalid_obis: 302 | obis = invalid_obis_code 303 | elif all_valid_obis or i == 0: # First one valid, or all valid if multiple_valid_obis is True 304 | obis = valid_obis_codes[i % len(valid_obis_codes)] 305 | else: # Default: first valid, rest invalid 306 | obis = invalid_obis_code 307 | zaehlwerke.append({"obisCode": obis, "einheit": "WH", "messwerte": messwerte}) 308 | 309 | return {"zaehlwerke": zaehlwerke, "zaehlpunkt": zp} 310 | 311 | def delta(i: str, n: int=0) -> timedelta: 312 | return { 313 | "h": timedelta(hours=n), 314 | "d": timedelta(days=n), 315 | "qh": timedelta(minutes=15 * n) 316 | }[i.lower()] 317 | 318 | def bewegungsdaten_value(ts: datetime, interval: str, i: int = 0) -> dict: 319 | t = ts.replace(minute=0, second=0, microsecond=0) 320 | return { 321 | "wert": round(random.gauss(0.045,0.015), 3), 322 | "zeitpunktVon": (t + delta(interval, i)).strftime('%Y-%m-%dT%H:%M:%SZ'), 323 | "zeitpunktBis": (t + delta(interval, i+1)).strftime('%Y-%m-%dT%H:%M:%SZ'), 324 | "geschaetzt": False 325 | } 326 | 327 | def bewegungsdaten(count=24, timestamp=None, interval='h'): 328 | if timestamp is None: 329 | timestamp = datetime.now().replace(minute=0, second=0, microsecond=0) 330 | return [bewegungsdaten_value(timestamp, interval, i) for i in list(range(0,count))] 331 | 332 | 333 | def bewegungsdaten_response(customer_id: str, zp: str, 334 | granularity: ValueType = ValueType.QUARTER_HOUR, anlagetype: AnlagenType = AnlagenType.CONSUMING, 335 | wrong_zp: bool = False, values_count: int = 10): 336 | if granularity == ValueType.QUARTER_HOUR: 337 | gran = "QH" 338 | if anlagetype == AnlagenType.CONSUMING: 339 | rolle = "V002" 340 | else: 341 | rolle = "E002" 342 | else: 343 | gran = "D" 344 | if anlagetype == AnlagenType.CONSUMING: 345 | rolle = "V001" 346 | else: 347 | rolle = "V002" 348 | if wrong_zp: 349 | zp = zp + "9" 350 | 351 | values = [] if values_count == 0 else bewegungsdaten(count=values_count, timestamp=datetime(2022,8,7,0,0,0), interval=gran) 352 | 353 | return { 354 | "descriptor": { 355 | "geschaeftspartnernummer": customer_id, 356 | "zaehlpunktnummer": zp, 357 | "rolle": rolle, 358 | "aggregat": "NONE", 359 | "granularitaet": gran, 360 | "einheit": "KWH" 361 | }, 362 | "values": values 363 | } 364 | 365 | 366 | def smartmeter(username=USERNAME, password=PASSWORD, code_verifier=CODE_VERIFIER): 367 | return api.client.Smartmeter(username=username, password=password, input_code_verifier=code_verifier) 368 | 369 | 370 | @pytest.mark.usefixtures("requests_mock") 371 | def mock_login_page(requests_mock: Mocker, status: int | None = 200): 372 | """ 373 | mock GET login url from login page (+ session_code + client_id + execution param) 374 | """ 375 | get_login_url = AUTH_URL + "/auth?" + parse.urlencode(LOGIN_ARGS) 376 | if status == 200: 377 | requests_mock.get(url=get_login_url, text=files('test_resources').joinpath('auth.html').read_text()) 378 | elif status is None: 379 | requests_mock.get(url=get_login_url, exc=requests.exceptions.ConnectTimeout) 380 | else: 381 | requests_mock.get(url=get_login_url, text='', status_code=status) 382 | 383 | 384 | @pytest.mark.usefixtures("requests_mock") 385 | def mock_get_api_key(requests_mock: Mocker, bearer_token: str = ACCESS_TOKEN, 386 | get_config_status: int | None = 200, include_b2c_key: bool = True, include_b2b_key: bool = True, 387 | same_b2c_url: bool = True, same_b2b_url: bool = True): 388 | """ 389 | mock GET smartmeter-web.wienernetze.at to retrieve app-config.json which carries the b2cApiKey and b2bApiKey 390 | """ 391 | config_path = files('test_resources').joinpath('app-config.json') 392 | config_response = config_path.read_text() 393 | 394 | config_data = json.loads(config_response) 395 | if not include_b2c_key: 396 | del config_data["b2cApiKey"] 397 | if not include_b2b_key: 398 | del config_data["b2bApiKey"] 399 | 400 | if not same_b2c_url: 401 | config_data["b2cApiUrl"] = "https://api.wstw.at/gateway/WN_SMART_METER_PORTAL_API_B2C/2.0" 402 | if not same_b2b_url: 403 | config_data["b2bApiUrl"] = "https://api.wstw.at/gateway/WN_SMART_METER_PORTAL_API_B2B/2.0" 404 | 405 | config_response = json.dumps(config_data) 406 | 407 | if get_config_status is None: 408 | requests_mock.get(url=API_CONFIG_URL, request_headers={"Authorization": f"Bearer {bearer_token}"}, 409 | exc=requests.exceptions.ConnectTimeout) 410 | else: 411 | requests_mock.get(url=API_CONFIG_URL, request_headers={"Authorization": f"Bearer {bearer_token}"}, 412 | status_code=get_config_status, text=config_response) 413 | 414 | @pytest.mark.usefixtures("requests_mock") 415 | def mock_token(requests_mock: Mocker, code=RESPONSE_CODE, access_token=ACCESS_TOKEN, refresh_token=REFRESH_TOKEN, code_verifier=CODE_VERIFIER, 416 | id_token=ID_TOKEN, status: int | None = 200, 417 | expires: int = 300, 418 | token_type: str = "Bearer"): 419 | response = { 420 | "access_token": access_token, 421 | "expires_in": expires, 422 | "refresh_expires_in": 6 * expires, 423 | "refresh_token": refresh_token, 424 | "token_type": token_type, 425 | "id_token": id_token, 426 | "not-before-policy": 0, 427 | "session_state": "949e0f0d-b447-4208-bfef-273d694dc633", 428 | "scope": "openid email profile" 429 | } 430 | if status == 200: 431 | requests_mock.post(f'{AUTH_URL}/token', additional_matcher=post_data_matcher({ 432 | "grant_type": "authorization_code", 433 | "client_id": "wn-smartmeter", 434 | "redirect_uri": REDIRECT_URI, 435 | "code": code, 436 | "code_verifier": code_verifier 437 | }), json=response, status_code=status) 438 | elif status is None: 439 | requests_mock.post(f'{AUTH_URL}/token', additional_matcher=post_data_matcher({ 440 | "grant_type": "authorization_code", 441 | "client_id": "wn-smartmeter", 442 | "redirect_uri": REDIRECT_URI, 443 | "code": code, 444 | "code_verifier": code_verifier 445 | }), exc=requests.exceptions.ConnectTimeout) 446 | else: 447 | requests_mock.post(f'{AUTH_URL}/token', additional_matcher=post_data_matcher({ 448 | "grant_type": "authorization_code", 449 | "client_id": "wn-smartmeter", 450 | "redirect_uri": REDIRECT_URI, 451 | "code": code, 452 | "code_verifier": code_verifier 453 | }), json={}, status_code=status) 454 | 455 | 456 | @pytest.mark.usefixtures("requests_mock") 457 | def mock_authenticate(requests_mock: Mocker, username, password, code=RESPONSE_CODE, status: int | None = 302): 458 | """ 459 | mock POST authenticate call resulting in a 302 Found redirecting to another Location 460 | """ 461 | authenticate_query_params = { 462 | "session_code": "SESSION_CODE_PLACEHOLDER", 463 | "execution": "5939ddcc-efd4-407c-b01c-df8977d522b5", 464 | "client_id": "wn-smartmeter", 465 | "tab_id": "6tDgFA2FxbU" 466 | } 467 | authenticate_url = f'https://log.wien/auth/realms/logwien/login-actions/authenticate?{parse.urlencode(authenticate_query_params)}' 468 | 469 | # for some weird reason we have to perform this call before. maybe to create a login session. idk 470 | requests_mock.post(authenticate_url, status_code=status, 471 | additional_matcher=post_data_matcher({"username": username, "login": " "}), 472 | text=files('test_resources').joinpath('auth.html').read_text() 473 | ) 474 | 475 | if status == 302: 476 | redirect_url = f'{REDIRECT_URI}/#state=cb142d1b-d8b4-4bf3-8a3e-92544790c5c4' \ 477 | '&session_state=949e0f0d-b447-4208-bfef-273d694dc633' \ 478 | f'&code={code}' 479 | requests_mock.post(authenticate_url, status_code=status, 480 | additional_matcher=post_data_matcher({"username": username, "password": password}), 481 | headers={'location': redirect_url}, 482 | ) 483 | requests_mock.get(redirect_url, text='Some page which loads a js, performing a call to /token') 484 | elif status == 403: # do not provide Location header on 403 485 | requests_mock.post(authenticate_url, status_code=status, 486 | additional_matcher=post_data_matcher({"username": username, "password": password}) 487 | ) 488 | elif status == 404: # do provide a redirect to not-found page in Location header on 404 -> 302 489 | requests_mock.post(authenticate_url, status_code=302, 490 | additional_matcher=post_data_matcher({"username": username, "password": password}), 491 | headers={'location': REDIRECT_URI + "not-found"} 492 | ) 493 | elif status == 201: # if code is not within query params, but encoded otherwise 494 | redirect_url = f'{REDIRECT_URI}/#code={code}#state=cb142d1b-d8b4-4bf3-8a3e-92544790c5c4' \ 495 | '&session_state=949e0f0d-b447-4208-bfef-273d694dc633' 496 | requests_mock.post(authenticate_url, status_code=201, 497 | additional_matcher=post_data_matcher({"username": username, "password": password}), 498 | headers={'location': redirect_url} 499 | ) 500 | elif status is None: 501 | requests_mock.post(authenticate_url, exc=requests.exceptions.ConnectTimeout, 502 | additional_matcher=post_data_matcher({"username": username, "password": password}) 503 | ) 504 | 505 | 506 | @pytest.mark.usefixtures("requests_mock") 507 | def expect_login(requests_mock: Mocker, username=USERNAME, password=PASSWORD): 508 | mock_login_page(requests_mock) 509 | mock_authenticate(requests_mock, username, password) 510 | mock_token(requests_mock) 511 | mock_get_api_key(requests_mock) 512 | 513 | 514 | @pytest.mark.usefixtures("requests_mock") 515 | def expect_zaehlpunkte(requests_mock: Mocker, zps: list[dict]): 516 | requests_mock.get(parse.urljoin(API_URL_B2C,'zaehlpunkte'), 517 | headers={ 518 | "Authorization": f"Bearer {ACCESS_TOKEN}", 519 | "X-Gateway-APIKey": B2C_API_KEY, 520 | }, 521 | json=zaehlpunkt_response(zps)) 522 | 523 | 524 | @pytest.mark.usefixtures("requests_mock") 525 | def expect_verbrauch(requests_mock: Mocker, customer_id: str, zp: str, dateFrom: dt.datetime, response: dict, 526 | granularity='DAY', resolution='HOUR'): 527 | params = { 528 | "dateFrom": _dt_string(dateFrom), 529 | "dayViewResolution": resolution 530 | } 531 | path = f'messdaten/{customer_id}/{zp}/verbrauch?{urlencode(params)}' 532 | requests_mock.get(parse.urljoin(API_URL_B2C,path), 533 | headers={ 534 | "Authorization": f"Bearer {ACCESS_TOKEN}", 535 | "X-Gateway-APIKey": B2C_API_KEY, 536 | }, 537 | json=response) 538 | 539 | 540 | @pytest.mark.usefixtures("requests_mock") 541 | def expect_history( 542 | requests_mock: Mocker, 543 | customer_id: str, 544 | zp: str, 545 | zaehlwerk_amount: int = 1, 546 | wrong_zp: bool = False, 547 | all_invalid_obis: bool = False, 548 | all_valid_obis: bool = False, 549 | empty_messwerte: bool = False, 550 | no_zaehlwerke: bool = False, 551 | empty_zaehlwerke: bool = False, 552 | no_obis_code: bool = False 553 | ): 554 | path = f'zaehlpunkte/{customer_id}/{zp}/messwerte' 555 | requests_mock.get(parse.urljoin(API_URL_B2B, path), 556 | headers={ 557 | "Authorization": f"Bearer {ACCESS_TOKEN}", 558 | "X-Gateway-APIKey": B2B_API_KEY, 559 | "Accept": "application/json" 560 | }, 561 | json=history_response( 562 | zp, 563 | zaehlwerk_amount, 564 | wrong_zp, 565 | all_invalid_obis, 566 | all_valid_obis, 567 | empty_messwerte, 568 | no_zaehlwerke, 569 | empty_zaehlwerke, 570 | no_obis_code 571 | ) 572 | ) 573 | 574 | @pytest.mark.usefixtures("requests_mock") 575 | def expect_bewegungsdaten(requests_mock: Mocker, customer_id: str, zp: str, dateFrom: dt.datetime, dateTo: dt.datetime, 576 | granularity:ValueType = ValueType.QUARTER_HOUR, anlagetype: AnlagenType = AnlagenType.CONSUMING, 577 | wrong_zp: bool = False, values_count=10): 578 | if anlagetype== AnlagenType.FEEDING: 579 | if granularity == ValueType.DAY: 580 | rolle = RoleType.DAILY_FEEDING.value 581 | else: 582 | rolle = RoleType.QUARTER_HOURLY_FEEDING.value 583 | else: 584 | if granularity == ValueType.DAY: 585 | rolle = RoleType.DAILY_CONSUMING.value 586 | else: 587 | rolle = RoleType.QUARTER_HOURLY_CONSUMING.value 588 | params = { 589 | "geschaeftspartner": customer_id, 590 | "zaehlpunktnummer": zp, 591 | "rolle": rolle, 592 | "zeitpunktVon": dateFrom.strftime("%Y-%m-%dT00:00:00.000Z"), 593 | "zeitpunktBis": dateTo.strftime("%Y-%m-%dT23:59:59.999Z"), 594 | "aggregat": "NONE" 595 | } 596 | url = parse.urljoin(API_URL_ALT, f'user/messwerte/bewegungsdaten?{urlencode(params)}') 597 | requests_mock.get(url, 598 | headers={ 599 | "Authorization": f"Bearer {ACCESS_TOKEN}", 600 | "Accept": "application/json" 601 | }, 602 | json=bewegungsdaten_response(customer_id, zp, granularity, anlagetype, wrong_zp, values_count)) 603 | --------------------------------------------------------------------------------