diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..bfb80c6 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,34 @@ +# Copilot instructions for vrm-client + +## Architecture overview +- Core HTTP client is `VictronVRMClient` in [victron_vrm/client.py](../victron_vrm/client.py). It owns auth/token refresh, retry/backoff, error mapping, and builds absolute URLs from [victron_vrm/consts.py](../victron_vrm/consts.py). +- API surface is modular: each module in [victron_vrm/modules](../victron_vrm/modules) subclasses `BaseClientModule` and calls `client._request(...)` (see [victron_vrm/modules/_base.py](../victron_vrm/modules/_base.py)). Keep new endpoints in a module, not in the client directly. +- Data parsing uses Pydantic models in [victron_vrm/models](../victron_vrm/models). `BaseModel` converts empty strings to `None` and serializes `datetime` to ISO (see [victron_vrm/models/base.py](../victron_vrm/models/base.py)). Follow existing `Field(..., alias=...)` patterns as in [victron_vrm/models/site.py](../victron_vrm/models/site.py). +- MQTT support is a thin wrapper around `victron-mqtt` in [victron_vrm/mqtt.py](../victron_vrm/mqtt.py); `VictronVRMClient.get_mqtt_client_for_installation()` wires auth + installation details (see [victron_vrm/client.py](../victron_vrm/client.py)). + +## Request/response conventions (project-specific) +- All API calls go through `VictronVRMClient._request()` which: + - Adds `X-Authorization` header using `AuthToken.authorization_header` and `User-Agent`. + - Retries on 401/429/5xx/timeout with backoff (see [victron_vrm/client.py](../victron_vrm/client.py)). + - Treats `{ "success": false }` payloads as errors unless `skip_success_check=True`. +- `InstallationsModule.stats()` normalizes API `False` values to `None` in `records`/`totals` and optionally builds `ForecastAggregations` (see [victron_vrm/modules/installations.py](../victron_vrm/modules/installations.py)). +- This library backs the Home Assistant “Victron Remote Monitoring” integration, so prefer conservative changes: preserve error mapping behavior in `VictronVRMClient._request()` and avoid breaking public APIs without tests. + +## Stability expectations for Home Assistant usage +- Keep exception mapping stable in [victron_vrm/exceptions.py](../victron_vrm/exceptions.py) and the status-code handling in [victron_vrm/client.py](../victron_vrm/client.py); HA relies on those types for error handling. +- Prefer additive changes (new module methods/models) over modifying existing response shapes; update or add tests in [tests/test_client.py](../tests/test_client.py) when behavior changes. +- Be mindful of rate limits: tests and demo scripts use the demo token and can 429, so avoid extra API calls in hot paths (see [tests/README.md](../tests/README.md)). +- When handling inconsistent API payloads, normalize in module methods (like `stats()` in [victron_vrm/modules/installations.py](../victron_vrm/modules/installations.py)) rather than altering `_request()`. + +## Developer workflows +- Install dev/test deps: `pip install -e ".[test,dev]"` (or `uv pip install -e ".[test,dev]"`). See [README.md](../README.md). +- Run demo against VRM demo account: `python examples/demo.py` (uses `/auth/loginAsDemo`). +- Run tests: `pytest` or `pytest --cov=victron_vrm` (tests use demo token, so no credentials required). See [tests/README.md](../tests/README.md). + +## Integration points & dependencies +- HTTP layer uses `aiohttp` (async) and Pydantic v2 models. MQTT uses `victron-mqtt` (Hub-based client). +- External API endpoints are in [victron_vrm/consts.py](../victron_vrm/consts.py); add new endpoints there when expanding modules. + +## Examples to follow +- New module method pattern: check [victron_vrm/modules/users.py](../victron_vrm/modules/users.py) for `_request` usage and returning typed models. +- Error handling expectations: map status codes to custom exceptions in [victron_vrm/exceptions.py](../victron_vrm/exceptions.py), and let `_request` raise them. diff --git a/mqtt_playground.ipynb b/mqtt_playground.ipynb new file mode 100644 index 0000000..ee77dd3 --- /dev/null +++ b/mqtt_playground.ipynb @@ -0,0 +1,73 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "bfb3ae4d", + "metadata": {}, + "source": [ + "# VRM MQTT Playground\n", + "\n", + "This notebook quickly explains basic VRM MQTT functionality of the VRM client. MQTT client is provided by amazing [victron_mqtt](https://github.com/tomer-w/victron_mqtt) client by [@tomer-w](https://github.com/tomer-w)." + ] + }, + { + "cell_type": "markdown", + "id": "8cec1716", + "metadata": {}, + "source": [ + "### Async setup and client config" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d9a47125", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from dotenv import load_dotenv\n", + "from victron_vrm import VictronVRMClient\n", + "\n", + "load_dotenv()\n", + "\n", + "client = VictronVRMClient(token=os.getenv(\"VRM_TOKEN\"))\n", + "site_id = int(os.getenv(\"VRM_SITE_ID\"))\n", + "mqtt_client = await client.get_mqtt_client_for_installation(site_id)\n", + "await mqtt_client.connect()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1f5ce055", + "metadata": {}, + "outputs": [], + "source": [ + "mqtt_client.devices\n", + "mqtt_client._all_metrics" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml index aadab26..c04969c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,17 @@ build-backend = "hatchling.build" [project] name = "victron-vrm" version = "0.1.8" -description = "Async Python client for the Victron Energy VRM API" +description = "Async Python client for the Victron Energy VRM API and MQTT client for VRM" readme = "README.md" requires-python = ">=3.11" keywords = [ "victron", "vrm", "api", "client", "async", "home-assistant",] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Home Automation", "Topic :: Software Development :: Libraries",] -dependencies = [ "pydantic>=2.0.0", "aiohttp[speedups]>=3.8.0", "pytz>=2025.2",] +dependencies = [ + "pydantic>=2.0.0", + "aiohttp[speedups]>=3.8.0", + "pytz>=2025.2", + "victron-mqtt==2026.1.2", +] [[project.authors]] name = "AndyTempel" @@ -23,7 +28,7 @@ text = "MIT" [project.optional-dependencies] test = [ "pytest>=7.0.0", "pytest-asyncio>=0.20.0", "pytest-cov>=4.0.0",] -dev = [ "black>=23.0.0", "isort>=5.0.0", "mypy>=1.0.0", "ruff>=0.0.0",] +dev = [ "black>=23.0.0", "isort>=5.0.0", "mypy>=1.0.0", "ruff>=0.0.0", "python-dotenv>=1.0.0",] [project.urls] Homepage = "https://github.com/KSoft-Si/vrm-client" diff --git a/tests/test_client.py b/tests/test_client.py index ce23cae..6722225 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,14 +1,17 @@ """Tests for the Victron Energy VRM API client.""" import logging +import unittest.mock import aiohttp import pytest import random from victron_vrm import VictronVRMClient -from victron_vrm.exceptions import VictronVRMError, AuthorizationError +from victron_vrm.exceptions import VictronVRMError, AuthorizationError, NotFoundError, ClientError from victron_vrm.models import Site +from victron_vrm.models.auth import AuthToken +from victron_vrm.mqtt import VRMMQTTClient # Set up logging logging.basicConfig( @@ -21,6 +24,34 @@ AUTH_DEMO_URL = "https://vrmapi.victronenergy.com/v2/auth/loginAsDemo" +# Helper classes for mocking +class _MockUser: + """Mock user for testing.""" + email = "test@example.com" + + +class _MockSite: + """Mock site for testing.""" + identifier = "test-vrm-id" + mqtt_hostname = "mqtt.victronenergy.com" + + +class _MockSiteNoHostname: + """Mock site without MQTT hostname for testing.""" + identifier = "test-vrm-id" + mqtt_hostname = None + + +def _create_mock_token(): + """Create a mock auth token for testing.""" + return AuthToken( + access_token="mock_access_token", + token_type="Bearer", + expires_in=3600, + scope="read" + ) + + @pytest.fixture(scope="session") async def demo_token(): """Get a demo token for testing (session-scoped to avoid 429s).""" @@ -373,3 +404,72 @@ async def test_stats_false_to_none_transformation(vrm_client): except VictronVRMError as e: logger.warning(f"Error in mock test: {e}") pytest.skip(f"Error in mock test: {e}") + + +@pytest.mark.asyncio +async def test_get_mqtt_client_for_installation_success(): + """Test getting MQTT client for a valid installation.""" + # Create a mock client without needing actual network access + async with VictronVRMClient(token="mock_token", token_type="Bearer") as client: + async def mock_gather(*args): + return _create_mock_token(), _MockUser(), [_MockSite()] + + # Mock asyncio.gather to avoid actual API calls + with unittest.mock.patch("asyncio.gather", side_effect=mock_gather): + # Get MQTT client + mqtt_client = await client.get_mqtt_client_for_installation(67890) + + # Verify the client is properly configured + assert mqtt_client is not None + assert isinstance(mqtt_client, VRMMQTTClient) + + # Verify client attributes + assert mqtt_client.host == "mqtt.victronenergy.com" + assert mqtt_client.username == "test@example.com" + assert mqtt_client.password == "Bearer mock_access_token" + assert mqtt_client.installation_id == "test-vrm-id" + + logger.info("Successfully created MQTT client with mocked data") + + +@pytest.mark.asyncio +async def test_get_mqtt_client_for_installation_not_found(): + """Test getting MQTT client for a non-existent installation.""" + # Create a mock client + async with VictronVRMClient(token="mock_token", token_type="Bearer") as client: + async def mock_gather(*args): + # Return empty list for installations + return _create_mock_token(), _MockUser(), [] + + # Mock asyncio.gather to return empty installations list + with unittest.mock.patch("asyncio.gather", side_effect=mock_gather): + # Should raise NotFoundError + with pytest.raises(NotFoundError) as exc_info: + await client.get_mqtt_client_for_installation(999999999) + + # Verify the error message + assert "not found" in str(exc_info.value).lower() + assert "999999999" in str(exc_info.value) + + logger.info("NotFoundError correctly raised for invalid installation ID") + + +@pytest.mark.asyncio +async def test_get_mqtt_client_for_installation_missing_hostname(): + """Test getting MQTT client for installation without MQTT hostname.""" + # Create a mock client + async with VictronVRMClient(token="mock_token", token_type="Bearer") as client: + async def mock_gather(*args): + return _create_mock_token(), _MockUser(), [_MockSiteNoHostname()] + + # Mock asyncio.gather + with unittest.mock.patch("asyncio.gather", side_effect=mock_gather): + # Should raise ClientError + with pytest.raises(ClientError) as exc_info: + await client.get_mqtt_client_for_installation(67890) + + # Verify the error message + assert "mqtt hostname" in str(exc_info.value).lower() + assert "67890" in str(exc_info.value) + + logger.info("ClientError correctly raised for installation without MQTT hostname") diff --git a/uv.lock b/uv.lock index 8e6cc75..88ffb11 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", @@ -604,6 +604,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "paho-mqtt" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, +] + [[package]] name = "pathspec" version = "0.12.1" @@ -894,6 +903,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + [[package]] name = "pytz" version = "2025.2" @@ -988,14 +1006,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125, upload-time = "2025-02-25T17:27:57.754Z" }, ] +[[package]] +name = "victron-mqtt" +version = "2026.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "paho-mqtt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/23d93a981cd2067446f5ac085a973d2c7575f8605b96e4a7ee8bac72817b/victron_mqtt-2026.1.2.tar.gz", hash = "sha256:dcf8f763c5e803191e270e72a2d51cc20f4457b8c331f10d05f7790ea3f91e2f", size = 113109, upload-time = "2026-01-08T15:29:16.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/78/2ab20828bad7caafb5279881ab3dbbfd82960128bd49f9050185d4d1ac08/victron_mqtt-2026.1.2-py3-none-any.whl", hash = "sha256:d21afbe0deab3b39377a8e0121c31d622ede78d87ce85e350ec6bbdcb77cdd3a", size = 66020, upload-time = "2026-01-08T15:29:15.162Z" }, +] + [[package]] name = "victron-vrm" -version = "0.1.7" +version = "0.1.8" source = { editable = "." } dependencies = [ { name = "aiohttp", extra = ["speedups"] }, { name = "pydantic" }, { name = "pytz" }, + { name = "victron-mqtt" }, ] [package.optional-dependencies] @@ -1003,6 +1034,7 @@ dev = [ { name = "black" }, { name = "isort" }, { name = "mypy" }, + { name = "python-dotenv" }, { name = "ruff" }, ] test = [ @@ -1021,8 +1053,10 @@ requires-dist = [ { name = "pytest", marker = "extra == 'test'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.20.0" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.0.0" }, + { name = "python-dotenv", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "pytz", specifier = ">=2025.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.0.0" }, + { name = "victron-mqtt", specifier = "==2026.1.2" }, ] provides-extras = ["test", "dev"] diff --git a/victron_vrm/__init__.py b/victron_vrm/__init__.py index 3f4e344..bf91f30 100644 --- a/victron_vrm/__init__.py +++ b/victron_vrm/__init__.py @@ -1,5 +1,6 @@ """Victron Energy VRM API client.""" from .client import VictronVRMClient +from .mqtt import VRMMQTTClient -__all__ = ["VictronVRMClient"] \ No newline at end of file +__all__ = ["VictronVRMClient", "VRMMQTTClient"] diff --git a/victron_vrm/client.py b/victron_vrm/client.py index 0bacf98..5ef16b6 100644 --- a/victron_vrm/client.py +++ b/victron_vrm/client.py @@ -10,6 +10,8 @@ import aiohttp from pydantic import ValidationError +from victron_vrm.mqtt import VRMMQTTClient + from .consts import AUTH_URL, USER_ME_URL, FILTERED_SORTED_ATTRIBUTES_URL, BASE_URL from .exceptions import ( AuthenticationError, @@ -497,3 +499,46 @@ def users(self) -> "UsersModule": def installations(self) -> "InstallationsModule": """Get the InstallationsModule.""" return InstallationsModule(self) + + async def get_mqtt_client_for_installation( + self, + installation_id: int, + ) -> "VRMMQTTClient": + """Get an MQTT client for the specified installation. + + Args: + installation_id: Installation ID + + Returns: + VRMMQTTClient: MQTT client for the installation + + Raises: + NotFoundError: If no installation with the given ID is found for the user. + ClientError: If the installation does not have an MQTT hostname configured. + """ + token, user_details, installations = await asyncio.gather( + self._get_auth_token(), + self.users.get_me(), + self.users.list_sites(extended=True, site_id=installation_id), + ) + mqtt_username = user_details.email + mqtt_password = token.authorization_header + + if len(installations) == 0: + raise NotFoundError( + f"Installation with ID {installation_id} not found for user.", + ) + installation = installations[0] + + # Check if MQTT hostname is available + if not installation.mqtt_hostname: + raise ClientError( + f"Installation with ID {installation_id} does not have MQTT hostname configured.", + ) + + return VRMMQTTClient( + host=installation.mqtt_hostname, + username=mqtt_username, + password=mqtt_password, + vrm_id=installation.identifier, + ) diff --git a/victron_vrm/models/site.py b/victron_vrm/models/site.py index 3c28a72..0b8d6c8 100644 --- a/victron_vrm/models/site.py +++ b/victron_vrm/models/site.py @@ -3,26 +3,38 @@ from datetime import datetime from typing import Dict, List, Optional, Any -from pydantic import Field, field_validator +from pydantic import ConfigDict, Field, field_validator from .base import BaseModel -class SitePerrmission(BaseModel): +class SitePermission(BaseModel): """Victron Energy site view permissions model.""" - update_settings: bool - settings: bool - diagnostics: bool - share: bool - vnc: bool - mqtt_rpc: bool - vebus: bool - two_way: bool = Field(..., alias="twoway") - exact_location: bool - nodered: bool - nodered_dash: bool - signalk: bool + model_config = ConfigDict(extra="allow") + + update_settings: bool | None = None + settings: bool | None = None + diagnostics: bool | None = None + share: bool | None = None + vnc: bool | None = None + rc_classic: bool | None = None + rc_gui_v2: bool | None = None + rc_gui_v2_extra_arguments: bool | None = None + mqtt_rpc: bool | None = None + vebus: bool | None = None + two_way: bool | None = Field(None, alias="twoway") + readonly_realtime: bool | None = None + exact_location: bool | None = None + nodered: bool | None = None + nodered_dash: bool | None = None + nodered_dash_v2: bool | None = None + signalk: bool | None = None + paygo: bool | None = None + dess_config: bool | None = None + dess_view: bool | None = None + can_alter_installation: bool | None = Field(None, alias="canAlterInstallation") + can_see_group_and_team_members: bool | None = None class SiteImage(BaseModel): @@ -38,7 +50,7 @@ class SiteTag(BaseModel): id: int = Field(..., alias="idTag", description="Tag ID") name: str = Field(..., description="Tag name") - automatic: str = Field(..., description="If tag is automatic") + automatic: bool | str = Field(..., description="If tag is automatic") class Site(BaseModel): @@ -112,7 +124,7 @@ class Site(BaseModel): None, alias="mqttWebhost", description="MQTT web host" ) mqtt_hostname: Optional[str] = Field( - None, alias="mqttHost", description="MQTT hostname" + None, alias="mqtt_host", description="MQTT hostname" ) high_workload: Optional[bool] = Field( None, alias="highWorkload", description="Whether high workload is enabled" @@ -129,10 +141,12 @@ class Site(BaseModel): tags: Optional[List[SiteTag]] = Field( [], description="List of tags associated with the site" ) - images: Optional[List[SiteImage]] = Field( + # API output inconsistency: sometimes a list is returned, sometimes a boolean + images: Optional[List[SiteImage] | bool] = Field( [], description="List of images associated with the site" ) - view_permissions: Optional[List[SitePerrmission]] = Field([]) + # API output inconsistency: sometimes a list is returned, sometimes a dict + view_permissions: Optional[List[SitePermission] | SitePermission] = Field([]) extended: Optional[List[Dict[str, Any]]] = Field( [], description="Extended information about the site" ) diff --git a/victron_vrm/mqtt.py b/victron_vrm/mqtt.py new file mode 100644 index 0000000..75cf073 --- /dev/null +++ b/victron_vrm/mqtt.py @@ -0,0 +1,24 @@ +from victron_mqtt import Hub as VictronMQTTHub + + +class VRMMQTTClient(VictronMQTTHub): + """VRM MQTT Client.""" + + def __init__( + self, + host: str, + username: str, + password: str, + vrm_id: str, + port: int = 8883, + use_ssl: bool = True, + ): + """Initialize VRM MQTT Client.""" + super().__init__( + host=host, + username=username, + password=password, + port=port, + use_ssl=use_ssl, + installation_id=vrm_id, + )