pax_global_header00006660000000000000000000000064151346541020014513gustar00rootroot0000000000000052 comment=86b5496fcdf9de6a3c573b62e0a152b7440d504b iometer-gmbh-iometer.py-86b5496/000077500000000000000000000000001513465410200164545ustar00rootroot00000000000000iometer-gmbh-iometer.py-86b5496/.github/000077500000000000000000000000001513465410200200145ustar00rootroot00000000000000iometer-gmbh-iometer.py-86b5496/.github/workflows/000077500000000000000000000000001513465410200220515ustar00rootroot00000000000000iometer-gmbh-iometer.py-86b5496/.github/workflows/release.yml000066400000000000000000000014431513465410200242160ustar00rootroot00000000000000name: Publish to PyPI on: workflow_dispatch: jobs: test_pypi_release: runs-on: ubuntu-latest steps: - name: Check out code from GitHub uses: actions/checkout@v2 - name : Setup Python uses: actions/setup-python@v2 - name: Install Poetry run: pipx install Poetry - name: Install dependencies run: poetry install --no-interaction - name: Run pytest run: poetry run pytest tests/test.py - name: Build package run: poetry build --no-interaction - name: Set test PyPi repository run: poetry config repositories.pypi https://test.pypi.org/legacy/ - name: Add token run: poetry config pypi-token.pypi ${{ secrets.PYPI_API_KEY }} - name: Publish package run: poetry publishiometer-gmbh-iometer.py-86b5496/.github/workflows/test.yml000066400000000000000000000010161513465410200235510ustar00rootroot00000000000000name: Testing on: push: branches: - main pull_request: workflow_dispatch: env: DEFAULT_PYTHON: "3.12" jobs: test: runs-on: ubuntu-latest steps: - name: Check out code from GitHub uses: actions/checkout@v2 - name : Setup Python uses: actions/setup-python@v2 - name: Install Poetry run: pipx install Poetry - name: Install dependencies run: poetry install --no-interaction - name: Run pytest run: poetry run pytest tests/test.pyiometer-gmbh-iometer.py-86b5496/.github/workflows/test_release.yml000066400000000000000000000015011513465410200252500ustar00rootroot00000000000000name: Publish to Test PyPI on: workflow_dispatch: jobs: test_pypi_release: runs-on: ubuntu-latest steps: - name: Check out code from GitHub uses: actions/checkout@v2 - name : Setup Python uses: actions/setup-python@v2 - name: Install Poetry run: pipx install Poetry - name: Install dependencies run: poetry install --no-interaction - name: Run pytest run: poetry run pytest tests/test.py - name: Build package run: poetry build --no-interaction - name: Set test PyPi repository run: poetry config repositories.testpypi https://test.pypi.org/legacy/ - name: Add token run: poetry config pypi-token.testpypi ${{ secrets.TEST_PYPI_API_KEY }} - name: Publish package run: poetry publish -r testpypiiometer-gmbh-iometer.py-86b5496/.gitignore000066400000000000000000000061621513465410200204510ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/latest/usage/project/#working-with-version-control .pdm.toml .pdm-python .pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ ./venv/* .vscode/ **/__pycache__ poetry.lockiometer-gmbh-iometer.py-86b5496/LICENSE000066400000000000000000000020531513465410200174610ustar00rootroot00000000000000MIT License Copyright (c) [2024] [IOmeter] 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.iometer-gmbh-iometer.py-86b5496/README.md000066400000000000000000000056361513465410200177450ustar00rootroot00000000000000# IOmeter Python Client A Python client for interacting with [IOmeter](https://iometer.de/) devices over HTTP. This client provides an async interface for reading energy consumption/production data and monitoring device status. ## Features - 🔌 Asynchronous communication with IOmeter bridge over HTTP - 📊 Read energy consumption and energy production data - 🔋 Monitor device status including battery level and signal strength ## Quick Start ### Installation ```bash pip install iometer ``` ### Basic Usage ```python from iometer import IOmeterClient async def check_meter_reading(): async with IOmeterClient("192.168.1.100") as client: # Get current reading reading = await client.get_current_reading() # Access basic metrics consumption = reading.get_total_consumption() production = reading.get_total_production() power = reading.get_current_power() print(f"Meter: {reading.meter.number}") print(f"Time: {reading.meter.reading.time}") print(f"Consumption: {consumption} Wh") print(f"Production: {production} Wh") print(f"Current Power: {power} W") ``` ### Continuous Monitoring ```python import asyncio from iometer import IOmeterClient async def monitor_readings(interval: int = 300): """Monitor readings every 5 minutes.""" async with IOmeterClient("192.168.1.100") as client: while True: try: reading = await client.get_current_reading() print(f"Time: {reading.meter.reading.time}") print(f"Consumption: {reading.get_total_consumption()} Wh") await asyncio.sleep(interval) except Exception as e: print(f"Error: {e}") await asyncio.sleep(60) # Wait before retry ``` ### Device Status Information ```python from iometer import IOmeterClient async def check_device_status(): async with IOmeterClient("192.168.1.100") as client: # Get current status status = await client.get_current_status() # Bridge information print(f"Bridge Version: {status.device.bridge.version}") print(f"Bridge Signal: {status.device.bridge.rssi} dBm") # Core information core = status.device.core print(f"Connection: {core.connection_status}") print(f"Power Mode: {core.power_status}") if core.power_status.value == "battery": print(f"Battery Level: {core.battery_level}%") ``` ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. ## Setting up a dev environment We use [Poetry](https://python-poetry.org/) for dependency management and testing. Install everything with: ```bash poetry install ``` To run the Python tests use: ```bash poetry run pytest tests/test.py ``` ## License This project is licensed under the MIT License - see the LICENSE file for details. iometer-gmbh-iometer.py-86b5496/docs/000077500000000000000000000000001513465410200174045ustar00rootroot00000000000000iometer-gmbh-iometer.py-86b5496/docs/api.md000066400000000000000000000111211513465410200204730ustar00rootroot00000000000000# IOmeter Bridge API Documentation This document describes the HTTP API endpoints exposed by the IOmeter bridge. ## Base URL The current base URL is: ``` http://{bridge-ip}/v1 ``` ## Endpoints ### Reading endpoint Get the last/current meter reading including consumption and production values. Learn more on how the module handles the readings [here](reading.md). #### Request ```http GET /reading ``` #### Response ```json { "__typename": "iometer.reading.v1", "meter": { "number": "1HLY0000000000", "reading": { "time": "2024-11-11T11:11:00Z", "registers": [ { "obis": "01-00:01.08.00*ff", "value": 1234.5, "unit": "Wh" }, { "obis": "01-00:02.08.00*ff", "value": 5432.1, "unit": "Wh" }, { "obis": "01-00:10.07.00*ff", "value": 10, "unit": "W" } ] } } } ``` #### Fields | Field | Type | Description | |-------|------|-------------| | __typename | string | Type identifier for the response | | meter.number | string | Meter number | | meter.reading.time | string | ISO 8601 timestamp | | meter.reading.registers | array | List of register readings | #### Register OBIS Codes | OBIS Code | Description | Unit | |-----------|-------------|------| | 01-00:01.08.00*ff | Total energy consumption | Wh | | 01-00:02.08.00*ff | Total energy production | Wh | | 01-00:10.07.00*ff | Current power consumption | W | ### Status endpoint Get the current status of the bridge and core. Learn more on how the module handles the status [here](status.md). #### Request ```http GET /status ``` #### Response with Battery Power ```json { "__typename": "iometer.status.v1", "meter": { "number": "1ISK0000000000", }, "device": { "bridge": { "rssi": -30, "version": "build-60" }, "id": "658c2b34-2017-45f2-a12b-731235f8bb97", "core": { "connectionStatus": "connected", "rssi": -30, "version": "build-58", "powerStatus": "battery", "batteryLevel": 100, "attachmentStatus": "attached", "pinStatus": "entered" } } } ``` #### Response with Wired Power ```json { "__typename": "iometer.status.v1", "meter": { "number": "1ISK0000000000", }, "device": { "bridge": { "rssi": -15, "version": "build-60" }, "id": "eaf4f756-1d8b-41fa-9d4f-06eff5b33dea", "core": { "connectionStatus": "connected", "rssi": -30, "version": "build-58", "powerStatus": "wired", "attachmentStatus": "attached", "pinStatus": "entered" } } } ``` #### Response when Core is disconnected ```json { "__typename": "iometer.status.v1", "meter": { "number": "1ISK0000000000", }, "device": { "bridge": { "rssi": -15, "version": "build-60" }, "id": "eaf4f756-1d8b-41fa-9d4f-06eff5b33dea", "core": { "connectionStatus": "disconnected" } } } ``` #### Fields | Field | Type | Description | |-------|------|-------------| | __typename | string | Type identifier for the response | | meter.number | string | Meter number | | device.bridge.rssi | integer? | WiFi signal strength in dBm (optional) | | device.bridge.version | string? | Bridge firmware version (optional) | | device.id | string | Unique device identifier | | device.core.connectionStatus | string | Connection status ("connected", "disconnected") | | device.core.rssi | integer? | Core/Bridge signal strength in dBm (optional) | | device.core.version | string? | Core firmware version (optional) | | device.core.powerStatus | string? | Power source ("battery", "wired") (optional) | | device.core.batteryLevel | integer? | Battery percentage, only present with battery power (optional) | | device.core.attachmentStatus | string? | Physical attachment status ("attached", "detached") (optional) | | device.core.pinStatus | string? | PIN status ("entered", "pending", "missing") (optional) | ## Example Requests Using curl: ```bash # Get current reading curl --url "http://192.168.1.100/v1/reading" \ --header "User-Agent: curl/8.10.1" \ --header "Accept: application/json" # Get device status curl --url "http://192.168.1.100/v1/status" \ --header "User-Agent: curl/8.10.1" \ --header "Accept: application/json" ``` ## Discovery The IOmeter bridge can be found on networks that support [mDNS](https://en.wikipedia.org/wiki/Multicast_DNS). The fully qualified service type name is `_iometer._tcp.local.`. Prominient Python modules to discover devices are for instance [python-zeroconf](https://python-zeroconf.readthedocs.io). iometer-gmbh-iometer.py-86b5496/docs/examples.md000066400000000000000000000060661513465410200215540ustar00rootroot00000000000000# IOmeter Examples This document provides practical examples for using the IOmeter library in different scenarios. ## Basic Usage ### Getting Current Reading ```python from iometer import IOmeterClient async def check_meter_reading(): async with IOmeterClient("192.168.1.100") as client: # Get current reading reading = await client.get_current_reading() # Access basic metrics consumption = reading.get_total_consumption() production = reading.get_total_production() power = reading.get_current_power() print(f"Meter: {reading.meter.number}") print(f"Time: {reading.meter.reading.time}") print(f"Consumption: {consumption} Wh") print(f"Production: {production} Wh") print(f"Current Power: {power} W") ``` ### Check Device Status ```python from iometer import IOmeterClient async def check_device_status(): async with IOmeterClient("192.168.1.100") as client: # Get current status status = await client.get_current_status() # Bridge information print(f"Bridge Version: {status.device.bridge.version}") print(f"Bridge Signal: {status.device.bridge.rssi} dBm") # Core information core = status.device.core print(f"Connection: {core.connection_status}") print(f"Power Mode: {core.power_status}") if core.power_status == "battery": print(f"Battery Level: {core.battery_level}%") ``` ## Continuous Monitoring ### Reading Monitor ```python import asyncio from iometer import IOmeterClient async def monitor_readings(interval: int = 300): """Monitor readings every 5 minutes.""" async with IOmeterClient("192.168.1.100") as client: while True: try: reading = await client.get_current_reading() print(f"Time: {reading.meter.reading.time}") print(f"Consumption: {reading.get_total_consumption()} Wh") await asyncio.sleep(interval) except Exception as e: print(f"Error: {e}") await asyncio.sleep(60) # Wait before retry ``` ### Health Monitor ```python import asyncio from iometer import IOmeterClient async def monitor_health(interval: int = 60): """Monitor device health every minute.""" async with IOmeterClient("192.168.1.100") as client: while True: try: status = await client.get_current_status() core = status.device.core health_info = { "connection": core.connection_status, "signal": core.rssi, "power": core.power_status } if core.power_status == "battery": health_info["battery"] = core.battery_level print(health_info) await asyncio.sleep(interval) except Exception as e: print(f"Error: {e}") await asyncio.sleep(60) ```iometer-gmbh-iometer.py-86b5496/docs/index.md000066400000000000000000000031421513465410200210350ustar00rootroot00000000000000# IOmeter Python Library A Python client for polling IOmeter devices over HTTP. This client provides an async interface for reading energy consumption/production data and monitoring device status. ## Features - 🔌 Asynchronous communication with IOmeter device over HTTP - 📊 Read energy consumption and production data - 🔋 Monitor device status including battery levels and signal strength etc. Refer to the [HTTP API](api.md) documentation for further information on how to interact with your IOmeter bridge in your local network. ## Quick Start ### Installation ```bash pip install iometer ``` ### Basic Usage ```python import asyncio from iometer import IOmeterClient async def main(): async with IOmeterClient("192.168.1.100") as client: # Get current reading reading = await client.get_current_reading() print(f"Total consumption: {reading.get_total_consumption()} Wh") print(f"Total production: {reading.get_total_production()} Wh") # Get device status status = await client.get_current_status() print(f"Signal strength: {status.device.bridge.rssi} dBm") if status.device.core.power_status == "battery": print(f"Battery level: {status.device.core.battery_level}%") if __name__ == "__main__": asyncio.run(main()) ``` ## Requirements - Python 3.12 or higher, not tested on lower versions - aiohttp - yarl ## Next Steps - Check out the [Examples](examples.md) for more usage scenarios - Learn about the [Status](status.md) for device status monitoring - Explore the [Reading](reading.md) for energy data collectioniometer-gmbh-iometer.py-86b5496/docs/reading.md000066400000000000000000000040431513465410200213400ustar00rootroot00000000000000# Reading Documentation The Reading module provides classes for handling IOmeter readings. ## OBIS Codes Important OBIS codes used in the module: - `01-00:01.08.00*ff`: Total energy consumption on all tariffs - `01-00:02.08.00*ff`: Total energy production on all tariffs - `01-00:10.07.00*ff`: Current power consumption ## Classes ### Reading Top-level class for complete meter reading, currently consist of one meter: ```python @dataclass class Reading: __typename: str = "iometer.reading.v1" meter: Meter = None # OBIS code constants TOTAL_CONSUMPTION_OBIS = "01-00:01.08.00*ff" TOTAL_PRODUCTION_OBIS = "01-00:02.08.00*ff" @classmethod def from_json(cls, json_str: str) -> 'Reading': # Creates Reading instance from JSON string def get_total_consumption(self) -> float: # Returns total consumption in Wh def get_total_production(self) -> float: # Returns total production in Wh def get_current_power(self) -> float: # Returns current power consumption in W ``` ### Meter Represents the meter device and its reading: ```python @dataclass class Meter: number: str # Meter serial number reading: MeterReading # Current meter reading ``` ### MeterReading Represents a collection of register readings at a specific time: ```python @dataclass class MeterReading: time: datetime # Timestamp of the reading in UTC registers: List[Register] # List of register readings def get_register_by_obis(self, obis: str) -> Register | None: # Returns specific register by OBIS code ``` ### Register Represents a single meter register reading: ```python @dataclass class Register: obis: str # OBIS code identifying the reading type value: float # Reading value unit: str # Unit of measurement (e.g., "Wh", "W") ``` ### JSON Handling ```python # Parse from JSON reading = Reading.from_json(json_data) # Access data meter_number = reading.meter.number timestamp = reading.meter.reading.time consumption = reading.get_total_consumption() ```iometer-gmbh-iometer.py-86b5496/docs/status.md000066400000000000000000000045121513465410200212530ustar00rootroot00000000000000# Status Documentation The Status module provides classes for handling IOmeter device status information. It includes enums for various device states. ## Classes ### Status Top-level class for complete device status: ```python @dataclass class Status: meter: Meter device: Device typename: str = "iometer.status.v1" @classmethod def from_json(cls, json_str: str) -> 'Status': # Creates Status instance from JSON string def to_json(self) -> str: # Converts Status to JSON string ``` ### Meter Represents the meter device: ```python @dataclass class Meter: number: str ``` ### Device Combines bridge, device id and core information: ```python @dataclass class Device: bridge: Bridge id: str core: Core ``` ### Core Represents the core device status: ```python @dataclass class Core: connection_status: ConnectionStatus rssi: int # Signal strength in dBm version: str # Core firmware version power_status: PowerStatus attachment_status: AttachmentStatus battery_level: Optional[int] = None # Battery percentage if applicable pin_status: Optional[PinStatus] = None # PIN status if applicable def to_dict(self) -> dict: # Converts core data to ordered dictionary ``` ### Bridge Represents the bridge device status: ```python @dataclass class Bridge: rssi: int # Signal strength in dBm version: str # Bridge firmware version def __str__(self) -> str: # Returns formatted string with signal strength and version ``` ## Enums ### ConnectionStatus Represents the device connection state: ```python class ConnectionStatus(Enum): CONNECTED = "connected" DISCONNECTED = "disconnected" ``` ### PowerStatus Represents the device power source: ```python class PowerStatus(Enum): BATTERY = "battery" WIRED = "wired" ``` ### AttachmentStatus Represents physical attachment state: ```python class AttachmentStatus(Enum): ATTACHED = "attached" DETACHED = "detached" ``` ### PinStatus Represents PIN entry state: ```python class PinStatus(Enum): ENTERED = "entered" PENDING = "pending" MISSING = "missing" ``` ### JSON Handling ```python # Parse from JSON status = Status.from_json(json_string) # Convert to JSON json_string = status.to_json() ```iometer-gmbh-iometer.py-86b5496/iometer/000077500000000000000000000000001513465410200201205ustar00rootroot00000000000000iometer-gmbh-iometer.py-86b5496/iometer/__init__.py000066400000000000000000000007331513465410200222340ustar00rootroot00000000000000"""Asynchronous Python client for IOmeter.""" from .client import IOmeterClient from .exceptions import ( IOmeterConnectionError, IOmeterNoReadingsError, IOmeterNoStatusError, IOmeterTimeoutError, ) from .reading import Reading from .status import Status __version__ = "0.1.0" __all__ = [ "IOmeterClient", "IOmeterConnectionError", "IOmeterTimeoutError", "IOmeterNoReadingsError", "IOmeterNoStatusError", "Reading", "Status", ] iometer-gmbh-iometer.py-86b5496/iometer/client.py000066400000000000000000000104321513465410200217500ustar00rootroot00000000000000"""Asynchronous Python client for IOmeter.""" import asyncio from dataclasses import dataclass from typing import Optional, Self from aiohttp import ClientResponseError, ClientSession from yarl import URL from .exceptions import ( IOmeterConnectionError, IOmeterNoReadingsError, IOmeterNoStatusError, IOmeterTimeoutError, ) from .reading import Reading from .status import Status @dataclass class IOmeterClient: """Main IOmeter client class for handling HTTP connections with the IOmeter bridge. Attributes: host: The hostname or IP address of the IOmeter bridge request_timeout: Number of seconds to wait for bridge response session: Optional aiohttp ClientSession for making requests Example: async with IOmeterClient("192.168.1.100") as client: reading = await client.get_current_reading() status = await client.get_current_status() """ host: str request_timeout: int = 60 session: Optional[ClientSession] = None async def _request(self, uri: str) -> str: """Make a request to the IOmeter bridge. Args: uri: The URI endpoint to request Returns: The response text from the bridge Raises: IOmeterConnectionError: If any communication error occurs """ if not self.session: raise RuntimeError("Client session not initialized") url = URL.build(scheme="http", host=self.host).joinpath(uri) headers = { "User-Agent": "PythonIOmeter/0.1", "Accept": "application/json", } try: async with asyncio.timeout(self.request_timeout): response = await self.session.get(url, headers=headers) response.raise_for_status() return await response.text() except asyncio.TimeoutError as error: raise IOmeterTimeoutError( "Timeout while communicating with IOmeter bridge" ) from error except ClientResponseError as error: # Map 404 responses to more specific exceptions depending on the # requested endpoint so callers can handle "no data" cases. if error.status == 404: if uri.endswith("v1/reading") or "/reading" in uri: raise IOmeterNoReadingsError( "No readings available from IOmeter bridge" ) from error if uri.endswith("v1/status") or "/status" in uri: raise IOmeterNoStatusError( "No status available from IOmeter bridge" ) from error # For other HTTP errors, raise a generic connection error. raise IOmeterConnectionError( f"Bridge returned error {error.status}: {error.message}" ) from error except Exception as error: raise IOmeterConnectionError( f"Error communicating with IOmeter bridge: {str(error)}" ) from error async def get_current_reading(self) -> Reading: """Get current reading from IOmeter bridge. Returns: Reading object containing the current meter values Raises: IOmeterConnectionError: If communication with bridge fails """ response = await self._request("v1/reading") return Reading.from_json(response) async def get_current_status(self) -> Status: """Get device status from IOmeter bridge. Returns: Status object containing the current bridge status Raises: IOmeterConnectionError: If communication with bridge fails """ response = await self._request("v1/status") return Status.from_json(response) async def close(self) -> None: """Close the client session.""" if self.session: await self.session.close() self.session = None async def __aenter__(self) -> Self: """Set up the client session. Returns: The configured client instance """ self.session = self.session or ClientSession() return self async def __aexit__(self, *_exc_info: object) -> None: """Clean up the client session.""" await self.close() iometer-gmbh-iometer.py-86b5496/iometer/exceptions.py000066400000000000000000000007221513465410200226540ustar00rootroot00000000000000"""Asynchronous Python client for IOmeter.""" class IOmeterError(Exception): """Generic exception.""" class IOmeterConnectionError(IOmeterError): """IOmeter connection exception.""" class IOmeterTimeoutError(IOmeterError): """IOmeter client and bridge timeout exception.""" class IOmeterNoReadingsError(IOmeterError): """No readings available exception.""" class IOmeterNoStatusError(IOmeterError): """No status available exception.""" iometer-gmbh-iometer.py-86b5496/iometer/reading.py000066400000000000000000000112751513465410200221110ustar00rootroot00000000000000"""IOmeter reading.""" import json from dataclasses import dataclass from datetime import datetime from typing import List @dataclass class Register: """Represents a meter register reading.""" obis: str value: float unit: str @dataclass class MeterReading: """Represents a point-in-time reading.""" time: datetime registers: List[Register] def get_register_by_obis(self, obis: str) -> Register | None: """Get register by OBIS code.""" return next((reg for reg in self.registers if reg.obis == obis), None) @dataclass class Meter: """Represents the meter device.""" number: str reading: MeterReading @dataclass class Reading: """Top level class representing a complete meter reading.""" meter: Meter typename: str = "iometer.reading.v1" # OBIS code constants TOTAL_CONSUMPTION_OBIS = "01-00:01.08.00*ff" TOTAL_PRODUCTION_OBIS = "01-00:02.08.00*ff" CURRENT_POWER_OBIS = "01-00:10.07.00*ff" CURRENT_POWER_OBIS_ALT = "01-00:24.07.00*ff" CONSUMPTION_TARIFF_T1_OBIS = "01-00:01.08.01*ff" CONSUMPTION_TARIFF_T2_OBIS = "01-00:01.08.02*ff" @classmethod def from_json(cls, json_str: str) -> "Reading": """Create Reading instance from JSON string.""" data = json.loads(json_str) # Create registers registers = [ Register(obis=reg["obis"], value=reg["value"], unit=reg["unit"]) for reg in data["meter"]["reading"]["registers"] ] # Create meter reading meter_reading = MeterReading( time=datetime.fromisoformat( data["meter"]["reading"]["time"].replace("Z", "+00:00") ), registers=registers, ) # Create meter meter = Meter(number=data["meter"]["number"], reading=meter_reading) # Create reading return cls(meter=meter) def to_json(self) -> str: """Convert the status to JSON string""" return json.dumps( { "__typename": self.typename, "meter": { "number": self.meter.number, "reading": { "time": self.meter.reading.time.strftime("%Y-%m-%dT%H:%M:%SZ"), "registers": [ { "obis": register.obis, "value": register.value, "unit": register.unit, } for register in self.meter.reading.registers ], }, }, } ) def get_total_consumption(self) -> float | None: """Get total consumption in Wh.""" register = self.meter.reading.get_register_by_obis(self.TOTAL_CONSUMPTION_OBIS) return register.value if register else None def get_total_production(self) -> float | None: """Get total production in Wh. Returns None if OBIS is not found, otherwise the value in Wh as float. """ register = self.meter.reading.get_register_by_obis(self.TOTAL_PRODUCTION_OBIS) return register.value if register else None def get_consumption_tariff_T1(self) -> float | None: """Get consumption for tariff T1 in Wh. Look for OBIS code 1.8.1. Not all meters report this value. Returns None if neither OBIS is found, otherwise the value in W as float. """ register = self.meter.reading.get_register_by_obis(self.CONSUMPTION_TARIFF_T1_OBIS) return register.value if register else None def get_consumption_tariff_T2(self) -> float | None: """Get consumption for tariff T2 in Wh. Look for OBIS code 1.8.2. Not all meters report this value. Returns None if neither OBIS is found, otherwise the value in Wh as float. """ register = self.meter.reading.get_register_by_obis(self.CONSUMPTION_TARIFF_T2_OBIS) return register.value if register else None def get_current_power(self) -> float | None: """Get current power consumption in W. Prefer 10.07.00 OBIS, it is missing in some meters. These meters falsely report 24.07.00 OBIS as current power. Returns None if neither OBIS is found, otherwise the value in W as float. """ register = self.meter.reading.get_register_by_obis(self.CURRENT_POWER_OBIS) if register: return register.value register_alt = self.meter.reading.get_register_by_obis( self.CURRENT_POWER_OBIS_ALT ) return register_alt.value if register_alt else None def __str__(self) -> str: return self.to_json() iometer-gmbh-iometer.py-86b5496/iometer/status.py000066400000000000000000000066311513465410200220230ustar00rootroot00000000000000"""Device status for IOmeter bridge and core""" import json from dataclasses import dataclass, field @dataclass class Bridge: """Represents the bridge device status""" rssi: int version: str @dataclass class Core: """Represents the core device status""" connection_status: str rssi: int | None version: str | None power_status: str | None attachment_status: str | None pin_status: str | None battery_level: int | None @dataclass class Device: """Represents the complete device information""" bridge: Bridge id: str core: Core @dataclass class Meter: """Represents the meter device.""" number: str | None class NullMeter(Meter): """Null Object for Meter to avoid None-attribute errors.""" def __init__(self) -> None: super().__init__(number=None) def __bool__(self) -> bool: return False @dataclass class Status: """Top level class representing the complete device status""" device: Device meter: Meter = field(default_factory=NullMeter) typename: str = "iometer.status.v1" @classmethod def from_json(cls, json_str: str) -> "Status": """Create a Status instance from JSON string""" data = json.loads(json_str) # Create bridge bridge = Bridge( rssi=data["device"]["bridge"]["rssi"], version=data["device"]["bridge"]["version"], ) # Create Core core_data = data["device"]["core"] core = Core( connection_status=core_data["connectionStatus"], rssi=core_data.get("rssi", None), version=core_data.get("version", None), power_status=core_data.get("powerStatus", None), battery_level=core_data.get("batteryLevel", None), attachment_status=core_data.get("attachmentStatus", None), pin_status=core_data.get("pinStatus", None), ) # Create device device = Device(bridge=bridge, id=data["device"]["id"], core=core) # Create meter (use Null Object if missing) meter = ( Meter(number=data["meter"]["number"]) if data.get("meter") else NullMeter() ) # Create full status return cls(meter=meter, device=device) def to_json(self) -> str: """Convert the status to JSON string""" return json.dumps( { "__typename": self.typename, # If meter is a NullMeter, serialize as null "meter": {"number": self.meter.number} if self.meter else None, "device": { "bridge": { "rssi": self.device.bridge.rssi, "version": self.device.bridge.version, }, "id": self.device.id, "core": { "connectionStatus": self.device.core.connection_status, "rssi": self.device.core.rssi, "version": self.device.core.version, "powerStatus": self.device.core.power_status, "batteryLevel": self.device.core.battery_level, "attachmentStatus": self.device.core.attachment_status, "pinStatus": self.device.core.pin_status, }, }, } ) def __str__(self) -> str: return self.to_json() iometer-gmbh-iometer.py-86b5496/pyproject.toml000066400000000000000000000016671513465410200214020ustar00rootroot00000000000000[tool.poetry] name = "iometer" version = "0.4.0" description = "Asynchronous Python client for IOmeter" authors = ["jukrebs "] license = "MIT" readme = "README.md" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] keywords = ["IOmeter", "smart meter", "energy", "automation"] homepage = "https://iometer.de/" repository = "https://github.com/iometer-gmbh/iometer.py" documentation = "https://github.com/iometer-gmbh/iometer.py/blob/main/docs/index.md" [tool.poetry.dependencies] python = "^3.10" aiohttp = "^3.0.0" yarl = ">=1.6.0" [tool.poetry.group.dev.dependencies] pytest = "8.3.4" pytest-asyncio = "0.25.1" aioresponses = "0.7.7" [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" iometer-gmbh-iometer.py-86b5496/tests/000077500000000000000000000000001513465410200176165ustar00rootroot00000000000000iometer-gmbh-iometer.py-86b5496/tests/test.py000066400000000000000000000367241513465410200211630ustar00rootroot00000000000000"""Tests for the IOmeter package.""" import pytest from aiohttp import ClientResponseError, ClientSession from aioresponses import aioresponses from iometer.client import IOmeterClient from iometer.exceptions import ( IOmeterConnectionError, IOmeterNoReadingsError, IOmeterNoStatusError, IOmeterTimeoutError, ) from iometer.reading import Reading from iometer.status import NullMeter, Status HOST = "192.168.1.100" @pytest.fixture(name="reading_json") def reading_json_fixture(): """Fixture reading response.""" return { "__typename": "iometer.reading.v1", "meter": { "number": "1ISK0000000000", "reading": { "time": "2024-11-11T11:11:11Z", "registers": [ {"obis": "01-00:01.08.00*ff", "value": 1234.5, "unit": "Wh"}, {"obis": "01-00:02.08.00*ff", "value": 5432.1, "unit": "Wh"}, {"obis": "01-00:10.07.00*ff", "value": 100, "unit": "W"}, ], }, }, } @pytest.fixture(name="reading_alt_obis_json") def reading_alt_obis_json_fixture(): """Fixture reading response with alternate current power OBIS only.""" return { "__typename": "iometer.reading.v1", "meter": { "number": "1ISK0000000000", "reading": { "time": "2024-11-11T11:11:11Z", "registers": [ {"obis": "01-00:01.08.00*ff", "value": 1234.5, "unit": "Wh"}, {"obis": "01-00:02.08.00*ff", "value": 5432.1, "unit": "Wh"}, {"obis": "01-00:24.07.00*ff", "value": 100, "unit": "W"}, ], }, }, } @pytest.fixture(name="reading_no_power_obis_json") def reading_no_power_obis_json_fixture(): """Fixture reading response without any current power OBIS registers.""" return { "__typename": "iometer.reading.v1", "meter": { "number": "1ISK0000000000", "reading": { "time": "2024-11-11T11:11:11Z", "registers": [ {"obis": "01-00:01.08.00*ff", "value": 1234.5, "unit": "Wh"}, {"obis": "01-00:02.08.00*ff", "value": 5432.1, "unit": "Wh"}, ], }, }, } @pytest.fixture(name="reading_no_power_no_production_obis_json") def reading_no_power_no_production_obis_json_fixture(): """Fixture reading response without any current power OBIS and production OBIS register. """ return { "__typename": "iometer.reading.v1", "meter": { "number": "1ISK0000000000", "reading": { "time": "2024-11-11T11:11:11Z", "registers": [ {"obis": "01-00:01.08.00*ff", "value": 1234.5, "unit": "Wh"} ], }, }, } @pytest.fixture(name="status_json") def status_json_fixture(): """ "Fixture status response""" return { "__typename": "iometer.status.v1", "meter": { "number": "1ISK0000000000", }, "device": { "bridge": {"rssi": -30, "version": "build-65"}, "id": "658c2b34-2017-45f2-a12b-731235f8bb97", "core": { "connectionStatus": "connected", "rssi": -30, "version": "build-58", "powerStatus": "battery", "batteryLevel": 100, "attachmentStatus": "attached", "pinStatus": "entered", }, }, } @pytest.fixture(name="status_wired_json") def status_wired_json_fixture(): """ "Fixture status response with wired power""" return { "__typename": "iometer.status.v1", "meter": { "number": "1ISK0000000000", }, "device": { "bridge": {"rssi": -30, "version": "build-65"}, "id": "658c2b34-2017-45f2-a12b-731235f8bb97", "core": { "connectionStatus": "connected", "rssi": -30, "version": "build-58", "powerStatus": "wired", "attachmentStatus": "attached", "pinStatus": "entered", }, }, } @pytest.fixture(name="status_detached_json") def status_detached_json_fixture(): """ "Fixture status response with detached core""" return { "__typename": "iometer.status.v1", "meter": { "number": "1ISK0000000000", }, "device": { "bridge": {"rssi": -30, "version": "build-65"}, "id": "658c2b34-2017-45f2-a12b-731235f8bb97", "core": { "connectionStatus": "connected", "rssi": -30, "version": "build-58", "powerStatus": "wired", "attachmentStatus": "detached", }, }, } @pytest.fixture(name="status_disconnected_json") def status_disconnected_json_fixture(): """ "Fixture status response with disconnected core""" return { "__typename": "iometer.status.v1", "meter": { "number": "1ISK0000000000", }, "device": { "bridge": {"rssi": -30, "version": "build-65"}, "id": "658c2b34-2017-45f2-a12b-731235f8bb97", "core": {"connectionStatus": "disconnected"}, }, } @pytest.fixture(name="status_no_meter_json") def status_no_meter_json_fixture(): """ "Fixture status response""" return { "__typename": "iometer.status.v1", "device": { "bridge": {"rssi": -30, "version": "build-65"}, "id": "658c2b34-2017-45f2-a12b-731235f8bb97", "core": { "connectionStatus": "connected", "rssi": -30, "version": "build-58", "powerStatus": "battery", "batteryLevel": 100, "attachmentStatus": "attached", }, }, } @pytest.fixture(name="mock_aioresponse") def mock_aioresponse_fixture(): """ "Fixture mock session""" with aioresponses() as m: yield m @pytest.fixture(name="client_iometer") async def client_iometer_fixture(): """Fixture IOmeter client""" async with IOmeterClient(HOST) as client: yield client @pytest.mark.asyncio async def test_client_initialization(): """Test client initialization.""" client = IOmeterClient("test-host") assert client.host == "test-host" assert client.request_timeout == 60 assert client.session is None @pytest.mark.asyncio async def test_client_context_manager(): """Test client as context manager.""" async with IOmeterClient("test-host") as client: assert isinstance(client.session, ClientSession) assert not client.session.closed # Session should not be closed # After the context assert client.session is None # Verify session is closed @pytest.mark.asyncio async def test_get_current_reading(client_iometer, mock_aioresponse, reading_json): """Test getting current reading.""" mock_endpoint = f"http://{HOST}/v1/reading" mock_aioresponse.get(mock_endpoint, status=200, payload=reading_json) reading = await client_iometer.get_current_reading() assert isinstance(reading, Reading) assert reading.meter.number == "1ISK0000000000" assert reading.get_total_consumption() == 1234.5 assert reading.get_total_production() == 5432.1 assert reading.get_current_power() == 100 @pytest.mark.asyncio async def test_get_current_reading_alt_obis( client_iometer, mock_aioresponse, reading_alt_obis_json ): """Test current power using alternate OBIS when primary is missing.""" mock_endpoint = f"http://{HOST}/v1/reading" mock_aioresponse.get(mock_endpoint, status=200, payload=reading_alt_obis_json) reading = await client_iometer.get_current_reading() assert isinstance(reading, Reading) assert reading.get_current_power() == 100 @pytest.mark.asyncio async def test_get_current_reading_no_power_obis( client_iometer, mock_aioresponse, reading_no_power_obis_json ): """Test current power returns None when no power OBIS registers are present.""" mock_endpoint = f"http://{HOST}/v1/reading" mock_aioresponse.get(mock_endpoint, status=200, payload=reading_no_power_obis_json) reading = await client_iometer.get_current_reading() assert isinstance(reading, Reading) assert reading.get_current_power() is None assert reading.get_total_production() == 5432.1 assert reading.get_total_consumption() == 1234.5 @pytest.mark.asyncio async def test_get_current_reading_no_power_no_production_obis( client_iometer, mock_aioresponse, reading_no_power_no_production_obis_json ): """Test current power returns None when no power OBIS registers are present.""" mock_endpoint = f"http://{HOST}/v1/reading" mock_aioresponse.get( mock_endpoint, status=200, payload=reading_no_power_no_production_obis_json ) reading = await client_iometer.get_current_reading() assert isinstance(reading, Reading) assert reading.get_current_power() is None assert reading.get_total_production() is None assert reading.get_total_consumption() == 1234.5 @pytest.mark.asyncio async def test_get_current_status(client_iometer, mock_aioresponse, status_json): """Test getting device status.""" mock_endpoint = f"http://{HOST}/v1/status" mock_aioresponse.get(mock_endpoint, status=200, payload=status_json) status = await client_iometer.get_current_status() assert isinstance(status, Status) assert status.meter.number == "1ISK0000000000" assert status.device.bridge.rssi == -30 assert status.device.bridge.version == "build-65" assert status.device.id == "658c2b34-2017-45f2-a12b-731235f8bb97" assert status.device.core.connection_status == "connected" assert status.device.core.rssi == -30 assert status.device.core.version == "build-58" assert status.device.core.power_status == "battery" assert status.device.core.battery_level == 100 assert status.device.core.attachment_status == "attached" assert status.device.core.pin_status == "entered" @pytest.mark.asyncio async def test_get_current_status_wired( client_iometer, mock_aioresponse, status_wired_json ): """Test getting device status.""" mock_endpoint = f"http://{HOST}/v1/status" mock_aioresponse.get(mock_endpoint, status=200, payload=status_wired_json) status = await client_iometer.get_current_status() assert isinstance(status, Status) assert status.meter.number == "1ISK0000000000" assert status.device.bridge.rssi == -30 assert status.device.bridge.version == "build-65" assert status.device.id == "658c2b34-2017-45f2-a12b-731235f8bb97" assert status.device.core.connection_status == "connected" assert status.device.core.rssi == -30 assert status.device.core.version == "build-58" assert status.device.core.power_status == "wired" assert status.device.core.battery_level is None assert status.device.core.attachment_status == "attached" assert status.device.core.pin_status == "entered" @pytest.mark.asyncio async def test_get_current_status_detached( client_iometer, mock_aioresponse, status_detached_json ): """Test getting device status.""" mock_endpoint = f"http://{HOST}/v1/status" mock_aioresponse.get(mock_endpoint, status=200, payload=status_detached_json) status = await client_iometer.get_current_status() assert isinstance(status, Status) assert status.meter.number == "1ISK0000000000" assert status.device.bridge.rssi == -30 assert status.device.bridge.version == "build-65" assert status.device.id == "658c2b34-2017-45f2-a12b-731235f8bb97" assert status.device.core.connection_status == "connected" assert status.device.core.rssi == -30 assert status.device.core.version == "build-58" assert status.device.core.power_status == "wired" assert status.device.core.battery_level is None assert status.device.core.attachment_status == "detached" assert status.device.core.pin_status is None @pytest.mark.asyncio async def test_get_current_status_disconnected( client_iometer, mock_aioresponse, status_disconnected_json ): """Test getting device status.""" mock_endpoint = f"http://{HOST}/v1/status" mock_aioresponse.get(mock_endpoint, status=200, payload=status_disconnected_json) status = await client_iometer.get_current_status() assert isinstance(status, Status) assert status.meter.number == "1ISK0000000000" assert status.device.bridge.rssi == -30 assert status.device.bridge.version == "build-65" assert status.device.id == "658c2b34-2017-45f2-a12b-731235f8bb97" assert status.device.core.connection_status == "disconnected" assert status.device.core.rssi is None assert status.device.core.version is None assert status.device.core.power_status is None assert status.device.core.battery_level is None assert status.device.core.attachment_status is None assert status.device.core.pin_status is None @pytest.mark.asyncio async def test_get_current_status_no_meter( client_iometer, mock_aioresponse, status_no_meter_json ): """Test getting device status.""" mock_endpoint = f"http://{HOST}/v1/status" mock_aioresponse.get(mock_endpoint, status=200, payload=status_no_meter_json) status = await client_iometer.get_current_status() assert isinstance(status, Status) assert isinstance(status.meter, NullMeter) assert status.meter.number is None assert status.device.bridge.rssi == -30 assert status.device.bridge.version == "build-65" assert status.device.id == "658c2b34-2017-45f2-a12b-731235f8bb97" assert status.device.core.connection_status == "connected" assert status.device.core.rssi == -30 assert status.device.core.version == "build-58" assert status.device.core.power_status == "battery" assert status.device.core.battery_level == 100 assert status.device.core.attachment_status == "attached" assert status.device.core.pin_status is None @pytest.mark.asyncio async def test_timeout_error(client_iometer, mock_aioresponse): """Test handling of timeout errors.""" mock_endpoint = f"http://{HOST}/v1/reading" mock_aioresponse.get(mock_endpoint, timeout=True) with pytest.raises(IOmeterTimeoutError, match="Timeout while communicating"): await client_iometer.get_current_reading() @pytest.mark.asyncio async def test_client_error(client_iometer, mock_aioresponse): """Test handling of client errors.""" mock_endpoint = f"http://{HOST}/v1/reading" mock_aioresponse.get(mock_endpoint, exception=ClientResponseError) with pytest.raises(IOmeterConnectionError, match="Error communicating"): await client_iometer.get_current_reading() @pytest.mark.asyncio async def test_reading_not_found(client_iometer, mock_aioresponse): """Test that a 404 on the reading endpoint raises IOmeterNoReadingsError.""" mock_endpoint = f"http://{HOST}/v1/reading" mock_aioresponse.get(mock_endpoint, status=404) with pytest.raises(IOmeterNoReadingsError, match="No readings available"): await client_iometer.get_current_reading() @pytest.mark.asyncio async def test_status_not_found(client_iometer, mock_aioresponse): """Test that a 404 on the status endpoint raises IOmeterNoStatusError.""" mock_endpoint = f"http://{HOST}/v1/status" mock_aioresponse.get(mock_endpoint, status=404) with pytest.raises(IOmeterNoStatusError, match="No status available"): await client_iometer.get_current_status()