Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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.
73 changes: 73 additions & 0 deletions mqtt_playground.ipynb
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 8 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"
Expand Down
102 changes: 101 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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)."""
Expand Down Expand Up @@ -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")
38 changes: 36 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion victron_vrm/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Victron Energy VRM API client."""

from .client import VictronVRMClient
from .mqtt import VRMMQTTClient

__all__ = ["VictronVRMClient"]
__all__ = ["VictronVRMClient", "VRMMQTTClient"]
Loading