pax_global_header00006660000000000000000000000064146414600600014513gustar00rootroot0000000000000052 comment=2427f673ae02470e93b3e30c21aedebe6531efb8 gjong-youless-python-bridge-2427f67/000077500000000000000000000000001464146006000173545ustar00rootroot00000000000000gjong-youless-python-bridge-2427f67/.github/000077500000000000000000000000001464146006000207145ustar00rootroot00000000000000gjong-youless-python-bridge-2427f67/.github/ISSUE_TEMPLATE/000077500000000000000000000000001464146006000230775ustar00rootroot00000000000000gjong-youless-python-bridge-2427f67/.github/ISSUE_TEMPLATE/bug-report.yml000066400000000000000000000014631464146006000257140ustar00rootroot00000000000000name: Bug Report description: File a bug report. title: "[Bug]: " labels: ["bug", "triage"] assignees: - gjong body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! Please provide as much information as possible to help us reproduce and fix the issue. - type: textarea id: what-happened attributes: label: What happened? description: Also tell us, what did you expect to happen? placeholder: Tell us what you see! value: "A bug happened!" validations: required: true - type: textarea id: logs attributes: label: Relevant log output description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. render: shellgjong-youless-python-bridge-2427f67/.github/ISSUE_TEMPLATE/feature-request.yml000066400000000000000000000014761464146006000267530ustar00rootroot00000000000000name: Feature request description: Request a new feature. title: "[Feature]: " labels: ["enhancement", "triage"] assignees: - gjong body: - type: markdown attributes: value: | Thanks for taking the time to fill out this feature request! Please provide as much information as possible to help us understand your request. - type: textarea id: summary attributes: label: "Summary" description: Provide a brief explanation of the feature placeholder: Describe in a few lines your feature request validations: required: true - type: textarea id: unresolved_question attributes: label: "Open questions" description: What questions still remain unresolved ? placeholder: Identify any unresolved issues. validations: required: falsegjong-youless-python-bridge-2427f67/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000031431464146006000245160ustar00rootroot00000000000000## Proposed change ## Type of change - [ ] Dependency upgrade - [ ] Bugfix (non-breaking change which fixes an issue) - [ ] New feature (which adds functionality to the application) - [ ] Deprecation (breaking change to happen in the future) - [ ] Breaking change (fix/feature causing existing functionality to break) - [ ] Code quality improvements to existing code or addition of tests ## Additional information - This PR fixes or closes issue: fixes # ## Checklist - [ ] The code change is tested and works locally. - [ ] Local tests pass. **Your PR cannot be merged unless tests pass** - [ ] There is no commented out code in this PR. - [ ] Tests have been added to verify that the new code works.gjong-youless-python-bridge-2427f67/.github/workflows/000077500000000000000000000000001464146006000227515ustar00rootroot00000000000000gjong-youless-python-bridge-2427f67/.github/workflows/python-package.yml000066400000000000000000000024401464146006000264060ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python name: Python package on: push: branches: [ "master" ] pull_request: branches: [ "master" ] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | pytest gjong-youless-python-bridge-2427f67/.github/workflows/python-publish.yml000066400000000000000000000023461464146006000264660ustar00rootroot00000000000000# This workflow will upload a Python Package using Twine when a release is created # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support # documentation. name: Upload Python Package on: release: types: [published] permissions: contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: | echo "Github ref: ${GITHUB_REF}" export PUBLISH_VERSION=$(echo "${GITHUB_REF}" | cut -d "/" -f3) echo "New version: ${PUBLISH_VERSION}" python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} gjong-youless-python-bridge-2427f67/.gitignore000066400000000000000000000002651464146006000213470ustar00rootroot00000000000000# Compiled python modules. *.pyc # Setuptools distribution folder. /dist/ /build # Python egg metadata, regenerated from source files by setuptools. *.egg-info *.egg .eggs .idea/gjong-youless-python-bridge-2427f67/LICENSE000066400000000000000000000020611464146006000203600ustar00rootroot00000000000000MIT License Copyright (c) 2024 Gerben Jongerius Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. gjong-youless-python-bridge-2427f67/README.md000066400000000000000000000035211464146006000206340ustar00rootroot00000000000000# YouLess Python Data Bridge [![PyPI version](https://badge.fury.io/py/youless-api.svg)](https://badge.fury.io/py/youless-api) ![Python package](https://github.com/gjong/youless-python-bridge/actions/workflows/python-package.yml/badge.svg?branch=master) This package contains support classes to fetch data from the YouLess sensors. The current implementation supports the following YouLess devices: * LS120, both the Enologic and the PVOutput firmware * LS110 Experimental support for authentication was added in v0.15 of the youless-python-bridge. ## Getting started To use the API use the following code: ```python from youless_api.youless_api import YoulessAPI if __name__ == '__main__': api = YoulessAPI("192.168.1.2") # use the ip address of the YouLess device api.initialize() api.update() # from this point on on you should be able to access the sensors through the YouLess bridge gasUsage = api.gas_meter.value ``` To use authentication please use the snippet below (this is still experimental): ```python from youless_api.youless_api import YoulessAPI if __name__ == '__main__': api = YoulessAPI("192.168.1.2", "my-user-name", "my-password") # use the ip address of the YouLess device api.initialize() api.update() # from this point on on you should be able to access the sensors through the YouLess bridge gasUsage = api.gas_meter.value ``` ## Contributing The Youless Python Data Bridge is an open-source project and welcomes any additions by the community. If you would like to contribute, please fork this repository and make your desired changes. You can then offer those changes using a pull request into this repository. ### The contributors :star2: [![Contributors](https://contrib.rocks/image?repo=gjong/youless-python-bridge)](https://github.com/gjong/youless-python-bridge/graphs/contributors) gjong-youless-python-bridge-2427f67/RELEASES.md000066400000000000000000000034101464146006000210770ustar00rootroot00000000000000# Release notes - YouLess API - Version 1.0 [Code changes](https://bitbucket.org/jongsoftdev/youless-python-bridge/branches/compare/1.0%0D0.16#diff) ### Feature [YA-14](https://jongsoftdev.atlassian.net/browse/YA-14) Add support for the new phase information in firmware 1.5.x # Release notes - YouLess API - Version 0.16 [Code changes](https://bitbucket.org/jongsoftdev/youless-python-bridge/branches/compare/0.16%0D0.15#diff) ### Bug [YA-13](https://jongsoftdev.atlassian.net/browse/YA-13) Youless PVOutput error in extra meter # Release notes - YouLess API - Version 0.15 [Code changes](https://bitbucket.org/jongsoftdev/youless-python-bridge/branches/compare/0.15%0D0.14#diff) ### Bug [YA-12](https://jongsoftdev.atlassian.net/browse/YA-12) Fix incorrect values for LS110 ### Feature [YA-3](https://jongsoftdev.atlassian.net/browse/YA-3) Add support for YouLess devices with authentication # Release notes - YouLess API - Version 0.14 [Code changes](https://bitbucket.org/jongsoftdev/youless-python-bridge/branches/compare/0.14%0D0.13#diff) ### Bug-fix Fix issue in the LS110 device with older firmware not exposing the 'cs0' or 'ps0' sensors in the JSON # Release notes - YouLess API - Version 0.13 [Code changes](https://bitbucket.org/jongsoftdev/youless-python-bridge/branches/compare/0.13%0D0.12#diff) ### Feature [YA-11](https://jongsoftdev.atlassian.net/browse/YA-11) Add support for PVOutput firmware on LS120 # Release notes - YouLess API - Version 0.12 ### Bug [YA-10](https://jongsoftdev.atlassian.net/browse/YA-10) Integrate solution for missing sensors in the sensor readings for LS120 # Release notes - YouLess API - Version 0.11 ### Bug [YA-9](https://jongsoftdev.atlassian.net/browse/YA-9) LS120 incorrectly blocks readings when no gas meter is present gjong-youless-python-bridge-2427f67/requirements.txt000066400000000000000000000002571464146006000226440ustar00rootroot00000000000000setuptools==69.1.1 wheel==0.43.0 twine==5.0.0 requests==2.31.0 pytest==7.1.3 coverage==6.5.0 urllib3>=2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability gjong-youless-python-bridge-2427f67/requirements_test.txt000066400000000000000000000000361464146006000236760ustar00rootroot00000000000000pytest==7.1.3 coverage==6.5.0 gjong-youless-python-bridge-2427f67/setup.py000066400000000000000000000011251464146006000210650ustar00rootroot00000000000000import os import setuptools with open("README.md", "r") as fh: long_description = fh.read() default_version = '2.0.0' version = os.getenv('PUBLISH_VERSION', default_version) setuptools.setup( name='youless_api', version=version, description='A bridge for python to the YouLess sensor', long_description=long_description, long_description_content_type="text/markdown", url='https://github.com/gjong/youless-python-bridge', author='G. Jongerius', license='MIT', packages=setuptools.find_packages(exclude=("test",)), zip_safe=False) gjong-youless-python-bridge-2427f67/youless_api/000077500000000000000000000000001464146006000217105ustar00rootroot00000000000000gjong-youless-python-bridge-2427f67/youless_api/__init__.py000066400000000000000000000120151464146006000240200ustar00rootroot00000000000000""" This file contains a helper class to easily obtain data from the YouLess sensor. """ import logging from typing import Optional from youless_api.const import SensorType from youless_api.device import ls110, ls120 from youless_api.gateway import fetch_device_info, fetch_enologic_api from youless_api.youless_sensor import YoulessSensor, PowerMeter, ExtraMeter, DeliveryMeter, Phase name = "youless_api" logger = logging.getLogger(__name__) class YoulessAPI: """A helper class to obtain data from the YouLess Sensor.""" _cache_data: Optional[dict] = None def __init__(self, host, username=None, password=None): """Initialize the data bridge.""" self._host = host if username is None: self._authentication = None else: self._authentication = (username, password) self._model = None self._mac_address = None self._firmware_version = None self._fetcher = None def initialize(self): """Establish a connection to the remote device""" device_info = fetch_device_info(self._host, self._authentication) if device_info is None: logger.debug("No device information discovered, assuming LS110 device.") self._model = "LS110" self._fetcher = ls110(self._host, self._authentication) else: self._mac_address = device_info["mac"] self._firmware_version = device_info["fw"] if "fw" in device_info else None enologic_data = fetch_enologic_api(self._host, self._authentication) if enologic_data is None: logger.debug("Incorrect enologic response, assuming LS120 with PVOutput firmware.") self._model = "LS120 - PVOutput" self._fetcher = ls110(self._host, self._authentication) else: logger.debug("Enologic output detected, LS120 device found.") self._model = "LS120" self._fetcher = ls120(self._host, self._authentication, device_info) def update(self): """Fetch the latest settings from the Youless Sensor.""" if self._fetcher: self._cache_data = self._fetcher() else: logger.warning("No fetch algorithm is chosen, setup failed.") @property def mac_address(self) -> Optional[str]: """Get the MAC address of the connected device.""" return self._mac_address @property def model(self) -> Optional[str]: """Return the model of the connected device.""" return self._model @property def firmware_version(self) -> Optional[str]: """Get the firmware version of the connected device.""" return self._firmware_version @property def current_tariff(self) -> Optional[int]: """Get the current tariff, is either 0 or 1 and only present if phase information is present.""" return self._cache_data[SensorType.TARIFF] if SensorType.TARIFF in self._cache_data else None @property def water_meter(self) -> Optional[YoulessSensor]: """Get the water data available.""" return self._cache_data[SensorType.WATER] if SensorType.WATER in self._cache_data else None @property def gas_meter(self) -> Optional[YoulessSensor]: """"Get the gas data available.""" return self._cache_data[SensorType.GAS] if SensorType.GAS in self._cache_data else None @property def current_power_usage(self) -> Optional[YoulessSensor]: """Get the current power usage.""" return self._cache_data[SensorType.POWER_USAGE] if SensorType.POWER_USAGE in self._cache_data else None @property def power_meter(self) -> Optional[PowerMeter]: """Get the power meter values.""" return self._cache_data[SensorType.POWER_METER] if SensorType.POWER_METER in self._cache_data else None @property def delivery_meter(self) -> Optional[DeliveryMeter]: """Get the power delivered values.""" return self._cache_data[SensorType.DELIVERY_METER] if SensorType.DELIVERY_METER in self._cache_data else None @property def extra_meter(self) -> Optional[ExtraMeter]: """Get the meter values of an attached meter.""" return self._cache_data[SensorType.EXTRA_METER] if SensorType.EXTRA_METER in self._cache_data else None @property def phase1(self) -> Optional[Phase]: """Get the phase 1 information""" return self._cache_data[SensorType.PHASE1] if SensorType.PHASE1 in self._cache_data else None @property def phase2(self) -> Optional[Phase]: """Get the phase 1 information""" return self._cache_data[SensorType.PHASE2] if SensorType.PHASE2 in self._cache_data else None @property def phase3(self) -> Optional[Phase]: """Get the phase 1 information""" return self._cache_data[SensorType.PHASE3] if SensorType.PHASE3 in self._cache_data else None @property def secured(self) -> bool: """Flag indicating if the API has authentication or not.""" return self._authentication is not None gjong-youless-python-bridge-2427f67/youless_api/const.py000066400000000000000000000006671464146006000234210ustar00rootroot00000000000000from enum import Enum STATE_OK = "ok" STATE_FAILED = "failed" class SensorType(Enum): """The sensor type class creates an enumeration of the supported sensors by YouLess.""" WATER = "water" GAS = "gas" POWER_USAGE = "power_usage" POWER_METER = "power_meter" DELIVERY_METER = "delivery_meter" EXTRA_METER = "extra_meter" PHASE1 = "phase1" PHASE2 = "phase2" PHASE3 = "phase3" TARIFF = "tariff" gjong-youless-python-bridge-2427f67/youless_api/device/000077500000000000000000000000001464146006000231475ustar00rootroot00000000000000gjong-youless-python-bridge-2427f67/youless_api/device/__init__.py000066400000000000000000000061211464146006000252600ustar00rootroot00000000000000from typing import Optional from youless_api.gateway import fetch_enologic_api, fetch_generic_api, fetch_phase_api from youless_api.youless_sensor import YoulessSensor, PowerMeter, DeliveryMeter, ExtraMeter, Phase from youless_api.const import SensorType def ls110(host, authentication): """The device wrapper for the LS110, will return a function to fetch the latest information.""" def update() -> Optional[dict]: """The actual method to refresh the data.""" dataset = fetch_generic_api(host, authentication) if dataset is None: return None return { SensorType.POWER_METER: PowerMeter( YoulessSensor(None, None), YoulessSensor(None, None), YoulessSensor(dataset['cnt'], 'kWh') ), SensorType.POWER_USAGE: YoulessSensor(dataset['pwr'], 'W'), SensorType.EXTRA_METER: ExtraMeter( YoulessSensor(dataset['cs0'], 'kWh'), YoulessSensor(dataset['ps0'], 'W') ) } return update def ls120(host, authentication, device_info): """The device wrapper for the LS120, will return a function to fetch the latest information.""" supports_phases = False if 'fw' in device_info: supports_phases = float(device_info['fw'][0:3]) >= 1.5 def update() -> Optional[dict]: """The actual method to refresh the data.""" dataset = fetch_enologic_api(host, authentication) phase_info = fetch_phase_api(host, authentication) if supports_phases else {} if dataset is None: return None return { SensorType.GAS: YoulessSensor(dataset['wtr'], 'm3'), SensorType.POWER_USAGE: YoulessSensor(dataset['pwr'], 'W'), SensorType.POWER_METER: PowerMeter( YoulessSensor(dataset['p1'], 'kWh'), YoulessSensor(dataset['p2'], 'kWh'), YoulessSensor(dataset['net'], 'kWh') ), SensorType.DELIVERY_METER: DeliveryMeter( YoulessSensor(dataset['n1'], 'kWh'), YoulessSensor(dataset['n2'], 'kWh') ), SensorType.EXTRA_METER: ExtraMeter( YoulessSensor(dataset['cs0'], 'kWh'), YoulessSensor(dataset['ps0'], 'W') ), SensorType.PHASE1: Phase( YoulessSensor(phase_info['i1'], 'A'), YoulessSensor(phase_info['v1'], 'V'), YoulessSensor(phase_info['l1'], 'W')) if 'i1' in phase_info else None, SensorType.PHASE2: Phase( YoulessSensor(phase_info['i2'], 'A'), YoulessSensor(phase_info['v2'], 'V'), YoulessSensor(phase_info['l2'], '')) if 'i2' in phase_info else None, SensorType.PHASE3: Phase( YoulessSensor(phase_info['i3'], 'A'), YoulessSensor(phase_info['v3'], 'V'), YoulessSensor(phase_info['l3'], 'W')) if 'i3' in phase_info else None, SensorType.TARIFF: phase_info['tr'] if phase_info else None, } return update gjong-youless-python-bridge-2427f67/youless_api/gateway/000077500000000000000000000000001464146006000233515ustar00rootroot00000000000000gjong-youless-python-bridge-2427f67/youless_api/gateway/__init__.py000066400000000000000000000045771464146006000254770ustar00rootroot00000000000000from datetime import datetime from typing import Optional import requests def fetch_generic_api(host, authentication=None) -> Optional[dict]: """Fetches the data from the Youless API on the /a endpoint.""" response = requests.get(f"http://{host}/a?f=j", auth=authentication, timeout=2) if response.ok: corrected = {**{'cs0': None, 'ps0': None}, **response.json()} parse_float_values_for = ['cnt', 'cs0'] for correct_value in parse_float_values_for: if correct_value in corrected and corrected[correct_value] is not None: corrected[correct_value] = float(corrected[correct_value].replace(",", ".")) return corrected return None def fetch_phase_api(host, authentication=None) -> Optional[dict]: """Fetches the data from the Youless API on the /f endpoint.""" response = requests.get(f"http://{host}/f", auth=authentication, timeout=2) return response.json() if response.ok else {} def fetch_enologic_api(host, authentication=None): """Fetches the data from the Youless API on the /e endpoint.""" response = requests.get(f"http://{host}/e", auth=authentication, timeout=2) if response.ok and response.headers['Content-Type'] == 'application/json': """Use fallback values if specific sensor values are missing.""" corrected = { **{ 'p1': None, 'p2': None, 'n1': None, 'n2': None, 'gas': None, 'wtr': None, 'pwr': None, 'cs0': None, 'ps0': None}, **response.json()[0] } if 'gts' in corrected: formatted_date = datetime.now().strftime("%y%m%d") + "0000" if corrected["gts"] != 0 and int(formatted_date) >= corrected["gts"]: corrected["gas"] = None if 'wts' in corrected: formatted_date = datetime.now().strftime("%y%m%d") + "0000" if corrected["wts"] != 0 and int(formatted_date) >= corrected["wts"]: corrected["wtr"] = None return corrected return None def fetch_device_info(host, authentication=None) -> Optional[dict]: """Fetch the device information from the Youless device.""" response = requests.get(f"http://{host}/d", auth=authentication, timeout=2) return response.json() if response.ok else None gjong-youless-python-bridge-2427f67/youless_api/test/000077500000000000000000000000001464146006000226675ustar00rootroot00000000000000gjong-youless-python-bridge-2427f67/youless_api/test/__init__.py000066400000000000000000000010631464146006000250000ustar00rootroot00000000000000 URI_ELOGIC = '/e' URI_DEVICE_INFO = '/d' URI_GENERIC = '/a?f=j' URI_PHASES = '/f' class MockResponse: def __init__(self): self._ok = False self._json = lambda: 0 self._headers = {} self._text = '' def setup(self, ok, json, text, headers): self._ok = ok self._json = json self._text = text self._headers = headers @property def ok(self): return self._ok def json(self): return self._json() @property def headers(self): return self._headers gjong-youless-python-bridge-2427f67/youless_api/test/test_api.py000066400000000000000000000172111464146006000250530ustar00rootroot00000000000000import unittest from datetime import datetime from unittest.mock import patch, Mock, MagicMock from requests import Response from youless_api import YoulessAPI test_host = "192.1.1.1" def mock_ls120_pvoutput(*args, **kwargs) -> Response: if args[0] == 'http://192.1.1.1/d': return Mock( ok=True, json=lambda: {'mac': '293:23fd:23'} ) if args[0] == 'http://192.1.1.1/e': return Mock( ok=True, headers={'Content-Type': 'text/html'} ) if args[0] == 'http://192.1.1.1/a?f=j': return Mock( ok=True, json=lambda: { "cnt": "141950,625", "pwr": 750, "lvl": 90, "dev": "(±3%)", "det": "", "con": "OK", "sts": "(33)", "raw": 743 }) return Mock(ok=False) def mock_ls120(*args, **kwargs) -> Response: if args[0] == 'http://192.1.1.1/d': return Mock( ok=True, json=lambda: {'mac': '293:23fd:23', 'fw': '1.6.0-EL'} ) if args[0] == 'http://192.1.1.1/e': return Mock( ok=True, headers={'Content-Type': 'application/json'}, json=lambda: [{ "tm": 1611929119, "net": 9194.164, "pwr": 2382, "ts0": 1608654000, "cs0": 15.000, "ps0": 10, "p1": 4703.562, "p2": 4490.631, "n1": 0.029, "n2": 0.000, "gas": 1624.264, "gts": int(datetime.now().strftime("%y%m%d%H00")), "wtr": 1234.564, "wts": int(datetime.now().strftime("%y%m%d%H00")) }]) if args[0] == 'http://192.1.1.1/f': return Mock( ok=True, json=lambda: { "tr": 1, "i1": 0.123, "v1": 240, "l1": 462, "v2": 240, "l2": 230, "i2": 0.123, "v3": 240, "l3": 230, "i3": 0.123 } ) return Mock(ok=False) def mock_ls120_reported(*args, **kwargs) -> Response: if args[0] == 'http://192.1.1.1/d': return Mock( ok=True, json=lambda: {'mac': '293:23fd:23', 'fw': '1.6.0-EL'} ) if args[0] == 'http://192.1.1.1/e': return Mock( ok=True, headers={'Content-Type': 'application/json'}, json=lambda: [{ "tm": 1719966932, "net": 24277.256, "pwr": -6, "ts0": 1719964559, "cs0": 75.271, "ps0": 0, "p1": 13775.844, "p2": 12057.301, "n1": 439.157, "n2": 1116.732, "gas": 3754.789, "gts":int(datetime.now().strftime("%y%m%d%H00")), "wtr": 0.000, "wts": 0 }]) if args[0] == 'http://192.1.1.1/f': return Mock( ok=True, json=lambda: { "tr": 2, "pa": 0, "pp": 0, "pts": 0, "i1": 1.000, "i2": 2.000, "i3": 1.000, "v1": 233.900, "v2": 232.400, "v3": 233.600, "l1": 50, "l2": 199, "l3": -218} ) return Mock(ok=False) def mock_ls110_device(*args, **kwargs): if args[0] == 'http://192.1.1.1/d': return Mock(ok=False) if args[0] == 'http://192.1.1.1/a?f=j': return Mock( ok=True, json=lambda: { "cnt": "141950,625", "pwr": 750, "lvl": 90, "dev": "(±3%)", "det": "", "con": "OK", "sts": "(33)", "raw": 743 }) return Mock(ok=False) class YoulessAPITest(unittest.TestCase): @patch('youless_api.gateway.requests.get', side_effect=mock_ls120) def test_device_ls120(self, mock_get: MagicMock): api = YoulessAPI(test_host) api.initialize() api.update() self.assertEqual(api.model, 'LS120') self.assertEqual(api.mac_address, '293:23fd:23') self.assertEqual(api.firmware_version, '1.6.0-EL') self.assertEqual(api.current_tariff, 1) self.assertEqual(api.extra_meter.total.value, 15.0) self.assertEqual(api.extra_meter.usage.value, 10) self.assertEqual(api.delivery_meter.low.value, 0.029) self.assertEqual(api.delivery_meter.high.value, 0.0) mock_get.assert_any_call('http://192.1.1.1/d', auth=None, timeout=2) mock_get.assert_any_call('http://192.1.1.1/e', auth=None, timeout=2) mock_get.assert_any_call('http://192.1.1.1/f', auth=None, timeout=2) @patch('youless_api.gateway.requests.get', side_effect=mock_ls120_reported) def test_device_ls120_reported_issues(self, mock_get: MagicMock): api = YoulessAPI(test_host) api.initialize() api.update() self.assertEqual(api.extra_meter.total.value, 75.271) self.assertEqual(api.extra_meter.usage.value, 0) @patch('youless_api.gateway.requests.get', side_effect=mock_ls120) def test_device_ls120_authenticated(self, mock_get: MagicMock): api = YoulessAPI(test_host, 'admin', 'password') api.initialize() self.assertEqual(api.model, 'LS120') mock_get.assert_any_call('http://192.1.1.1/d', auth=('admin', 'password'), timeout=2) mock_get.assert_any_call('http://192.1.1.1/e', auth=('admin', 'password'), timeout=2) @patch('youless_api.gateway.requests.get', side_effect=mock_ls120_pvoutput) def test_ls120_firmare_pvoutput(self, mock_get: MagicMock): api = YoulessAPI(test_host) api.initialize() api.update() self.assertEqual(api.model, 'LS120 - PVOutput') self.assertEqual(api.mac_address, '293:23fd:23') mock_get.assert_any_call('http://192.1.1.1/d', auth=None, timeout=2) mock_get.assert_any_call('http://192.1.1.1/e', auth=None, timeout=2) @patch('youless_api.gateway.requests.get', side_effect=mock_ls120_pvoutput) def test_ls120_firmare_pvoutput_authenticated(self, mock_get: MagicMock): api = YoulessAPI(test_host, 'admin', 'password') api.initialize() self.assertEqual(api.model, 'LS120 - PVOutput') self.assertEqual(api.mac_address, '293:23fd:23') mock_get.assert_any_call('http://192.1.1.1/d', auth=('admin', 'password'), timeout=2) mock_get.assert_any_call('http://192.1.1.1/e', auth=('admin', 'password'), timeout=2) @patch('youless_api.gateway.requests.get', side_effect=mock_ls110_device) def test_device_ls110(self, mock_get: MagicMock): api = YoulessAPI(test_host) api.initialize() mock_get.assert_called_with('http://192.1.1.1/d', auth=None, timeout=2) self.assertEqual(api.model, 'LS110') self.assertIsNone(api.mac_address) api.update() mock_get.assert_called_with('http://192.1.1.1/a?f=j', auth=None, timeout=2) @patch('youless_api.gateway.requests.get', side_effect=mock_ls110_device) def test_device_ls110_authenticated(self, mock_get: MagicMock): api = YoulessAPI(test_host, 'admin', 'password') api.initialize() mock_get.assert_called_with('http://192.1.1.1/d', auth=('admin', 'password'), timeout=2) self.assertEqual(api.model, 'LS110') self.assertIsNone(api.mac_address) gjong-youless-python-bridge-2427f67/youless_api/test/test_gateway_enelogic.py000066400000000000000000000110101464146006000275770ustar00rootroot00000000000000from datetime import datetime from unittest import TestCase from unittest.mock import patch, Mock, MagicMock from requests import Response from youless_api.gateway import fetch_enologic_api def mock_ls120_ok(*args, **kwargs) -> Response: if args[0] == 'http://localhost/e': return Mock( ok=True, headers={'Content-Type': 'application/json'}, json=lambda: [{ "tm": 1611929119, "net": 9194.164, "pwr": 2382, "ts0": 1608654000, "cs0": 0.000, "ps0": 0, "p1": 4703.562, "p2": 4490.631, "n1": 0.029, "n2": 0.000, "gas": 1624.264, "gts": int(datetime.now().strftime("%y%m%d%H00")), "wtr": 1234.564, "wts": int(datetime.now().strftime("%y%m%d%H00")) }] ) return Mock(ok=False) def mock_stale_gas(*args, **kwargs) -> Response: if args[0] == 'http://localhost/e': return Mock( ok=True, headers={'Content-Type': 'application/json'}, json=lambda: [{ "tm": 1611929119, "net": 9194.164, "pwr": 2382, "ts0": 1608654000, "cs0": 15.000, "ps0": 10, "p1": 4703.562, "p2": 4490.631, "n1": 0.029, "n2": 0.000, "gas": 1624.264, "gts": 3894900, "wtr": 1234.564, "wts": 3894900 }] ) return Mock(ok=False) def mock_enologic_missing_values(*args, **kwargs) -> Response: if args[0] == 'http://localhost/e': return Mock( ok=True, headers={'Content-Type': 'application/json'}, json=lambda: [{ "tm": 1611929119, "net": 9194.164, "pwr": 2382, "ts0": 1608654000, "cs0": 0.000, "ps0": 0, "gas": 1624.264, "gts": int(datetime.now().strftime("%y%m%d%H00")), "wtr": 1234.564, "wts": int(datetime.now().strftime("%y%m%d%H00")) }] ) return Mock(ok=False) def mock_enologic_error(*args, **kwargs) -> Response: return Mock(ok=False) class GatewayTest(TestCase): @patch('youless_api.gateway.requests.get', side_effect=mock_ls120_ok) def test_enologic_correct(self, mock_get: MagicMock): dataset = fetch_enologic_api('localhost', None) self.assertEqual(dataset['net'], 9194.164) self.assertEqual(dataset['p2'], 4490.631) self.assertEqual(dataset['p1'], 4703.562) self.assertEqual(dataset['pwr'], 2382) self.assertEqual(dataset['gas'], 1624.264) self.assertEqual(dataset['wtr'], 1234.564) self.assertEqual(dataset['cs0'], 0.000) self.assertEqual(dataset['n1'], 0.029) mock_get.assert_any_call('http://localhost/e', auth=None, timeout=2) @patch('youless_api.gateway.requests.get', side_effect=mock_stale_gas) def test_enologic_stale_gas(self, mock_get: MagicMock): dataset = fetch_enologic_api('localhost', None) self.assertEqual(dataset['net'], 9194.164) self.assertEqual(dataset['p2'], 4490.631) self.assertEqual(dataset['p1'], 4703.562) self.assertEqual(dataset['pwr'], 2382) self.assertEqual(dataset['gas'], None) self.assertEqual(dataset['wtr'], None) self.assertEqual(dataset['cs0'], 15.000) self.assertEqual(dataset['ps0'], 10) self.assertEqual(dataset['n1'], 0.029) mock_get.assert_any_call('http://localhost/e', auth=None, timeout=2) @patch('youless_api.gateway.requests.get', side_effect=mock_enologic_missing_values) def test_enologic_missing_p_and_n(self, mock_get: MagicMock): dataset = fetch_enologic_api('localhost', None) self.assertEqual(dataset['p1'], None) self.assertEqual(dataset['p2'], None) self.assertEqual(dataset['n1'], None) self.assertEqual(dataset['n2'], None) mock_get.assert_any_call('http://localhost/e', auth=None, timeout=2) @patch('youless_api.gateway.requests.get', side_effect=mock_enologic_error) def test_enologic_error(self, mock_get: MagicMock): dataset = fetch_enologic_api('localhost', None) self.assertEqual(dataset, None) mock_get.assert_any_call('http://localhost/e', auth=None, timeout=2) gjong-youless-python-bridge-2427f67/youless_api/test/test_gateway_generic.py000066400000000000000000000023071464146006000274370ustar00rootroot00000000000000from unittest import TestCase from unittest.mock import patch, Mock, MagicMock from requests import Response from youless_api.gateway import fetch_generic_api def mock_generic_ok(*args, **kwargs) -> Response: return Mock( ok=True, json=lambda: { "cnt": "141950,625", "pwr": 750, "lvl": 90, "dev": "(±3%)", "det": "", "con": "OK", "sts": "(33)", "raw": 743 }) class GatewayGenericTest(TestCase): @patch('youless_api.gateway.requests.get', side_effect=mock_generic_ok) def test_generic_ok(self, mock_get: MagicMock): dataset = fetch_generic_api('localhost', None) self.assertEqual(dataset['cnt'], 141950.625) self.assertEqual(dataset['pwr'], 750) self.assertEqual(dataset['lvl'], 90) self.assertEqual(dataset['dev'], '(±3%)') self.assertEqual(dataset['det'], "") self.assertEqual(dataset['con'], 'OK') self.assertEqual(dataset['sts'], "(33)") self.assertEqual(dataset['raw'], 743) mock_get.assert_any_call('http://localhost/a?f=j', auth=None, timeout=2) gjong-youless-python-bridge-2427f67/youless_api/test/test_youless_sensor.py000066400000000000000000000004371464146006000274000ustar00rootroot00000000000000import unittest from youless_api.youless_sensor import YoulessSensor class TestYoulessData(unittest.TestCase): def test_value(self): value = YoulessSensor(1.232, "w") self.assertTrue(value.value == 1.232) self.assertTrue(value.unit_of_measurement == "w") gjong-youless-python-bridge-2427f67/youless_api/youless_sensor.py000066400000000000000000000037011464146006000253570ustar00rootroot00000000000000""" This file contains the sensor class for the youless API """ class YoulessSensor: """A wrapper class to contain the Youless Sensor values.""" def __init__(self, value, uom): """Initialize the value wrapper.""" self._value = value self._uom = uom @property def unit_of_measurement(self): """Get the unit of measurement for this value.""" return self._uom @property def value(self): """Get the current value""" return self._value class PowerMeter: def __init__(self, low: YoulessSensor, high: YoulessSensor, total: YoulessSensor): self._low = low self._high = high self._total = total @property def low(self) -> YoulessSensor: return self._low @property def high(self) -> YoulessSensor: return self._high @property def total(self): return self._total class DeliveryMeter: def __init__(self, low: YoulessSensor, high: YoulessSensor): self._low = low self._high = high @property def low(self) -> YoulessSensor: return self._low @property def high(self) -> YoulessSensor: return self._high class ExtraMeter: def __init__(self, total: YoulessSensor, usage: YoulessSensor): self._total = total self._usage = usage @property def usage(self) -> YoulessSensor: return self._usage @property def total(self) -> YoulessSensor: return self._total class Phase: def __init__(self, current: YoulessSensor, voltage: YoulessSensor, power: YoulessSensor): self._current = current self._voltage = voltage self._power = power @property def current(self) -> YoulessSensor: return self._current @property def voltage(self) -> YoulessSensor: return self._voltage @property def power(self) -> YoulessSensor: return self._power