daikin-onecta 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: daikin-onecta
3
+ Version: 0.1.0
4
+ Summary: Async Python client for the Daikin Onecta cloud API
5
+ Author: Johnny Willemsen
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jwillemsen/daikin-onecta
8
+ Project-URL: Repository, https://github.com/jwillemsen/daikin-onecta
9
+ Project-URL: Issues, https://github.com/jwillemsen/daikin-onecta/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: aiohttp>=3.9.0
18
+ Provides-Extra: test
19
+ Requires-Dist: aiohttp<3.14; extra == "test"
20
+ Requires-Dist: aioresponses>=0.7.8; extra == "test"
21
+ Requires-Dist: pytest>=8.0; extra == "test"
22
+ Requires-Dist: pytest-asyncio>=0.24; extra == "test"
23
+ Requires-Dist: pytest-cov>=5.0; extra == "test"
24
+ Provides-Extra: dev
25
+ Requires-Dist: daikin-onecta[test]; extra == "dev"
26
+ Requires-Dist: ruff>=0.13; extra == "dev"
27
+
28
+ # daikin-onecta
29
+
30
+ Async Python client for the Daikin Onecta cloud API.
31
+
32
+ This library contains the Daikin-specific API layer used by the Home Assistant
33
+ Daikin Onecta integration. It deliberately has no dependency on Home Assistant.
34
+
35
+ ## Usage
36
+
37
+ ```python
38
+ import aiohttp
39
+
40
+ from daikin_onecta import OnectaClient
41
+
42
+
43
+ async def token_provider() -> str:
44
+ return "access-token"
45
+
46
+
47
+ async with aiohttp.ClientSession() as session:
48
+ client = OnectaClient(session, token_provider)
49
+ devices = await client.get_gateway_devices()
50
+ ```
51
+
52
+ Authentication is supplied by an asynchronous token callback so applications
53
+ remain responsible for their own OAuth flow and token refresh.
54
+
55
+ ## Status
56
+
57
+ The API is currently alpha. The first release intentionally exposes gateway
58
+ devices as raw dictionaries while the stable typed device model is developed.
@@ -0,0 +1,31 @@
1
+ # daikin-onecta
2
+
3
+ Async Python client for the Daikin Onecta cloud API.
4
+
5
+ This library contains the Daikin-specific API layer used by the Home Assistant
6
+ Daikin Onecta integration. It deliberately has no dependency on Home Assistant.
7
+
8
+ ## Usage
9
+
10
+ ```python
11
+ import aiohttp
12
+
13
+ from daikin_onecta import OnectaClient
14
+
15
+
16
+ async def token_provider() -> str:
17
+ return "access-token"
18
+
19
+
20
+ async with aiohttp.ClientSession() as session:
21
+ client = OnectaClient(session, token_provider)
22
+ devices = await client.get_gateway_devices()
23
+ ```
24
+
25
+ Authentication is supplied by an asynchronous token callback so applications
26
+ remain responsible for their own OAuth flow and token refresh.
27
+
28
+ ## Status
29
+
30
+ The API is currently alpha. The first release intentionally exposes gateway
31
+ devices as raw dictionaries while the stable typed device model is developed.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "daikin-onecta"
7
+ version = "0.1.0"
8
+ description = "Async Python client for the Daikin Onecta cloud API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Johnny Willemsen" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ ]
22
+ dependencies = [
23
+ "aiohttp>=3.9.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/jwillemsen/daikin-onecta"
28
+ Repository = "https://github.com/jwillemsen/daikin-onecta"
29
+ Issues = "https://github.com/jwillemsen/daikin-onecta/issues"
30
+
31
+ [project.optional-dependencies]
32
+ test = [
33
+ "aiohttp<3.14",
34
+ "aioresponses>=0.7.8",
35
+ "pytest>=8.0",
36
+ "pytest-asyncio>=0.24",
37
+ "pytest-cov>=5.0",
38
+ ]
39
+ dev = [
40
+ "daikin-onecta[test]",
41
+ "ruff>=0.13",
42
+ ]
43
+
44
+ [tool.pytest.ini_options]
45
+ asyncio_mode = "auto"
46
+ addopts = "--cov=daikin_onecta --cov-report=term-missing"
47
+
48
+ [tool.ruff]
49
+ line-length = 120
50
+ target-version = "py312"
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "I", "UP"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,23 @@
1
+ """Async client for the Daikin Onecta cloud API."""
2
+
3
+ from .client import OnectaClient
4
+ from .exceptions import (
5
+ OnectaApiError,
6
+ OnectaAuthenticationError,
7
+ OnectaConnectionError,
8
+ OnectaError,
9
+ OnectaRateLimitError,
10
+ )
11
+ from .models import GatewayDevice
12
+ from .rate_limit import RateLimit
13
+
14
+ __all__ = [
15
+ "GatewayDevice",
16
+ "OnectaApiError",
17
+ "OnectaAuthenticationError",
18
+ "OnectaClient",
19
+ "OnectaConnectionError",
20
+ "OnectaError",
21
+ "OnectaRateLimitError",
22
+ "RateLimit",
23
+ ]
@@ -0,0 +1,138 @@
1
+ """Client for the Daikin Onecta cloud API."""
2
+
3
+ import json
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Any
6
+
7
+ import aiohttp
8
+
9
+ from .exceptions import (
10
+ OnectaApiError,
11
+ OnectaAuthenticationError,
12
+ OnectaConnectionError,
13
+ OnectaRateLimitError,
14
+ )
15
+ from .models import GatewayDevice
16
+ from .rate_limit import RateLimit
17
+
18
+ ONECTA_API_URL = "https://api.onecta.daikineurope.com"
19
+
20
+
21
+ class OnectaClient:
22
+ """Async client for the Daikin Onecta cloud API."""
23
+
24
+ def __init__(
25
+ self,
26
+ session: aiohttp.ClientSession,
27
+ token_provider: Callable[[], Awaitable[str]],
28
+ *,
29
+ base_url: str = ONECTA_API_URL,
30
+ ) -> None:
31
+ """Initialize the client."""
32
+ self._session = session
33
+ self._token_provider = token_provider
34
+ self._base_url = base_url.rstrip("/")
35
+ self.rate_limit = RateLimit()
36
+
37
+ async def _request(
38
+ self,
39
+ method: str,
40
+ path: str,
41
+ *,
42
+ json_data: Any = None,
43
+ ) -> Any:
44
+ """Perform an authenticated request to the Daikin Onecta API."""
45
+ token = await self._token_provider()
46
+ headers = {
47
+ "Accept-Encoding": "gzip",
48
+ "Authorization": f"Bearer {token}",
49
+ "Content-Type": "application/json",
50
+ }
51
+
52
+ try:
53
+ async with self._session.request(
54
+ method,
55
+ f"{self._base_url}{path}",
56
+ headers=headers,
57
+ json=json_data,
58
+ ) as response:
59
+ self.rate_limit = RateLimit.from_headers(response.headers)
60
+ response_text = await response.text()
61
+
62
+ if response.status in (401, 403):
63
+ raise OnectaAuthenticationError(
64
+ f"Daikin Onecta API authentication failed with HTTP {response.status}"
65
+ )
66
+
67
+ if response.status == 429:
68
+ raise OnectaRateLimitError(self.rate_limit.retry_after)
69
+
70
+ if response.status >= 400:
71
+ raise OnectaApiError(response.status, response_text)
72
+
73
+ if response.status == 204 or not response_text:
74
+ return None
75
+
76
+ try:
77
+ return json.loads(response_text)
78
+ except json.JSONDecodeError as err:
79
+ raise OnectaApiError(response.status, "Invalid JSON response") from err
80
+ except (OnectaApiError, OnectaAuthenticationError, OnectaRateLimitError):
81
+ raise
82
+ except (TimeoutError, aiohttp.ClientError) as err:
83
+ raise OnectaConnectionError(str(err)) from err
84
+
85
+ async def get_gateway_devices(self) -> list[GatewayDevice]:
86
+ """Return all gateway devices available to the account."""
87
+ data = await self._request("GET", "/v1/gateway-devices")
88
+ if not isinstance(data, list):
89
+ raise OnectaApiError(200, "Expected a list of gateway devices")
90
+ return data
91
+
92
+ async def patch_characteristic(
93
+ self,
94
+ gateway_id: str,
95
+ management_point_id: str,
96
+ characteristic: str,
97
+ value: Any,
98
+ *,
99
+ path: str | None = None,
100
+ ) -> None:
101
+ """Set a management-point characteristic."""
102
+ body: dict[str, Any] = {"value": value}
103
+ if path:
104
+ body["path"] = path
105
+ await self._request(
106
+ "PATCH",
107
+ f"/v1/gateway-devices/{gateway_id}/management-points/{management_point_id}"
108
+ f"/characteristics/{characteristic}",
109
+ json_data=body,
110
+ )
111
+
112
+ async def post_management_point(
113
+ self,
114
+ gateway_id: str,
115
+ management_point_id: str,
116
+ resource: str,
117
+ value: Any,
118
+ ) -> Any:
119
+ """POST data to a management-point resource."""
120
+ return await self._request(
121
+ "POST",
122
+ f"/v1/gateway-devices/{gateway_id}/management-points/{management_point_id}/{resource}",
123
+ json_data=value,
124
+ )
125
+
126
+ async def put_management_point(
127
+ self,
128
+ gateway_id: str,
129
+ management_point_id: str,
130
+ resource: str,
131
+ value: Any = None,
132
+ ) -> Any:
133
+ """PUT data to a management-point resource."""
134
+ return await self._request(
135
+ "PUT",
136
+ f"/v1/gateway-devices/{gateway_id}/management-points/{management_point_id}/{resource}",
137
+ json_data=value,
138
+ )
@@ -0,0 +1,32 @@
1
+ """Exceptions raised by the Daikin Onecta client."""
2
+
3
+
4
+ class OnectaError(Exception):
5
+ """Base exception for Daikin Onecta errors."""
6
+
7
+
8
+ class OnectaConnectionError(OnectaError):
9
+ """Raised when communication with the Daikin Onecta API fails."""
10
+
11
+
12
+ class OnectaAuthenticationError(OnectaError):
13
+ """Raised when authentication with the Daikin Onecta API fails."""
14
+
15
+
16
+ class OnectaRateLimitError(OnectaError):
17
+ """Raised when the Daikin Onecta API rate limit is exceeded."""
18
+
19
+ def __init__(self, retry_after: int | None, message: str = "Daikin Onecta API rate limit exceeded") -> None:
20
+ """Initialize the rate limit error."""
21
+ super().__init__(message)
22
+ self.retry_after = retry_after
23
+
24
+
25
+ class OnectaApiError(OnectaError):
26
+ """Raised when the Daikin Onecta API returns an unexpected response."""
27
+
28
+ def __init__(self, status: int, message: str) -> None:
29
+ """Initialize the API error."""
30
+ super().__init__(f"Daikin Onecta API returned HTTP {status}: {message}")
31
+ self.status = status
32
+ self.message = message
@@ -0,0 +1,5 @@
1
+ """Models returned by the Daikin Onecta API."""
2
+
3
+ from typing import Any
4
+
5
+ type GatewayDevice = dict[str, Any]
@@ -0,0 +1,39 @@
1
+ """Daikin Onecta API rate-limit information."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+
6
+
7
+ def _header_int(headers: Mapping[str, str], name: str) -> int | None:
8
+ """Return an integer response header when it contains a valid value."""
9
+ value = headers.get(name)
10
+ if value is None:
11
+ return None
12
+ try:
13
+ return int(value)
14
+ except ValueError:
15
+ return None
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class RateLimit:
20
+ """Rate-limit state returned by the Daikin Onecta API."""
21
+
22
+ minute_limit: int | None = None
23
+ day_limit: int | None = None
24
+ minute_remaining: int | None = None
25
+ day_remaining: int | None = None
26
+ retry_after: int | None = None
27
+ reset: int | None = None
28
+
29
+ @classmethod
30
+ def from_headers(cls, headers: Mapping[str, str]) -> "RateLimit":
31
+ """Create rate-limit state from Daikin response headers."""
32
+ return cls(
33
+ minute_limit=_header_int(headers, "X-RateLimit-Limit-minute"),
34
+ day_limit=_header_int(headers, "X-RateLimit-Limit-day"),
35
+ minute_remaining=_header_int(headers, "X-RateLimit-Remaining-minute"),
36
+ day_remaining=_header_int(headers, "X-RateLimit-Remaining-day"),
37
+ retry_after=_header_int(headers, "Retry-After"),
38
+ reset=_header_int(headers, "RateLimit-Reset"),
39
+ )
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: daikin-onecta
3
+ Version: 0.1.0
4
+ Summary: Async Python client for the Daikin Onecta cloud API
5
+ Author: Johnny Willemsen
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jwillemsen/daikin-onecta
8
+ Project-URL: Repository, https://github.com/jwillemsen/daikin-onecta
9
+ Project-URL: Issues, https://github.com/jwillemsen/daikin-onecta/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: aiohttp>=3.9.0
18
+ Provides-Extra: test
19
+ Requires-Dist: aiohttp<3.14; extra == "test"
20
+ Requires-Dist: aioresponses>=0.7.8; extra == "test"
21
+ Requires-Dist: pytest>=8.0; extra == "test"
22
+ Requires-Dist: pytest-asyncio>=0.24; extra == "test"
23
+ Requires-Dist: pytest-cov>=5.0; extra == "test"
24
+ Provides-Extra: dev
25
+ Requires-Dist: daikin-onecta[test]; extra == "dev"
26
+ Requires-Dist: ruff>=0.13; extra == "dev"
27
+
28
+ # daikin-onecta
29
+
30
+ Async Python client for the Daikin Onecta cloud API.
31
+
32
+ This library contains the Daikin-specific API layer used by the Home Assistant
33
+ Daikin Onecta integration. It deliberately has no dependency on Home Assistant.
34
+
35
+ ## Usage
36
+
37
+ ```python
38
+ import aiohttp
39
+
40
+ from daikin_onecta import OnectaClient
41
+
42
+
43
+ async def token_provider() -> str:
44
+ return "access-token"
45
+
46
+
47
+ async with aiohttp.ClientSession() as session:
48
+ client = OnectaClient(session, token_provider)
49
+ devices = await client.get_gateway_devices()
50
+ ```
51
+
52
+ Authentication is supplied by an asynchronous token callback so applications
53
+ remain responsible for their own OAuth flow and token refresh.
54
+
55
+ ## Status
56
+
57
+ The API is currently alpha. The first release intentionally exposes gateway
58
+ devices as raw dictionaries while the stable typed device model is developed.
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/daikin_onecta/__init__.py
4
+ src/daikin_onecta/client.py
5
+ src/daikin_onecta/exceptions.py
6
+ src/daikin_onecta/models.py
7
+ src/daikin_onecta/rate_limit.py
8
+ src/daikin_onecta.egg-info/PKG-INFO
9
+ src/daikin_onecta.egg-info/SOURCES.txt
10
+ src/daikin_onecta.egg-info/dependency_links.txt
11
+ src/daikin_onecta.egg-info/requires.txt
12
+ src/daikin_onecta.egg-info/top_level.txt
13
+ tests/test_client.py
14
+ tests/test_rate_limit.py
@@ -0,0 +1,12 @@
1
+ aiohttp>=3.9.0
2
+
3
+ [dev]
4
+ daikin-onecta[test]
5
+ ruff>=0.13
6
+
7
+ [test]
8
+ aiohttp<3.14
9
+ aioresponses>=0.7.8
10
+ pytest>=8.0
11
+ pytest-asyncio>=0.24
12
+ pytest-cov>=5.0
@@ -0,0 +1 @@
1
+ daikin_onecta
@@ -0,0 +1,82 @@
1
+ """Tests for the Daikin Onecta client."""
2
+
3
+ import aiohttp
4
+ import pytest
5
+ from aioresponses import aioresponses
6
+
7
+ from daikin_onecta import (
8
+ OnectaApiError,
9
+ OnectaAuthenticationError,
10
+ OnectaClient,
11
+ OnectaRateLimitError,
12
+ )
13
+
14
+ BASE_URL = "https://api.onecta.daikineurope.com"
15
+
16
+
17
+ async def token_provider() -> str:
18
+ """Return a test access token."""
19
+ return "token"
20
+
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_get_gateway_devices() -> None:
24
+ """Return gateway devices from the API."""
25
+ payload = [{"id": "gateway-1", "deviceModel": "test"}]
26
+
27
+ async with aiohttp.ClientSession() as session:
28
+ with aioresponses() as mocked:
29
+ mocked.get(
30
+ f"{BASE_URL}/v1/gateway-devices",
31
+ payload=payload,
32
+ headers={"X-RateLimit-Remaining-day": "123"},
33
+ )
34
+ client = OnectaClient(session, token_provider)
35
+
36
+ assert await client.get_gateway_devices() == payload
37
+ assert client.rate_limit.day_remaining == 123
38
+
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_rate_limit_error() -> None:
42
+ """Expose Daikin retry-after information on HTTP 429."""
43
+ async with aiohttp.ClientSession() as session:
44
+ with aioresponses() as mocked:
45
+ mocked.get(
46
+ f"{BASE_URL}/v1/gateway-devices",
47
+ status=429,
48
+ headers={"Retry-After": "60"},
49
+ )
50
+ client = OnectaClient(session, token_provider)
51
+
52
+ with pytest.raises(OnectaRateLimitError) as exc_info:
53
+ await client.get_gateway_devices()
54
+
55
+ assert exc_info.value.retry_after == 60
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ @pytest.mark.parametrize("status", [401, 403])
60
+ async def test_authentication_error(status: int) -> None:
61
+ """Translate authentication failures to a dedicated exception."""
62
+ async with aiohttp.ClientSession() as session:
63
+ with aioresponses() as mocked:
64
+ mocked.get(f"{BASE_URL}/v1/gateway-devices", status=status)
65
+ client = OnectaClient(session, token_provider)
66
+
67
+ with pytest.raises(OnectaAuthenticationError):
68
+ await client.get_gateway_devices()
69
+
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_unexpected_api_error() -> None:
73
+ """Raise an API error instead of returning an empty device list."""
74
+ async with aiohttp.ClientSession() as session:
75
+ with aioresponses() as mocked:
76
+ mocked.get(f"{BASE_URL}/v1/gateway-devices", status=500, body="server error")
77
+ client = OnectaClient(session, token_provider)
78
+
79
+ with pytest.raises(OnectaApiError) as exc_info:
80
+ await client.get_gateway_devices()
81
+
82
+ assert exc_info.value.status == 500
@@ -0,0 +1,32 @@
1
+ """Tests for Daikin Onecta rate-limit parsing."""
2
+
3
+ from daikin_onecta import RateLimit
4
+
5
+
6
+ def test_rate_limit_from_headers() -> None:
7
+ """Parse all supported Daikin rate-limit headers."""
8
+ rate_limit = RateLimit.from_headers(
9
+ {
10
+ "X-RateLimit-Limit-minute": "20",
11
+ "X-RateLimit-Limit-day": "200",
12
+ "X-RateLimit-Remaining-minute": "7",
13
+ "X-RateLimit-Remaining-day": "123",
14
+ "Retry-After": "42",
15
+ "RateLimit-Reset": "99",
16
+ }
17
+ )
18
+
19
+ assert rate_limit.minute_limit == 20
20
+ assert rate_limit.day_limit == 200
21
+ assert rate_limit.minute_remaining == 7
22
+ assert rate_limit.day_remaining == 123
23
+ assert rate_limit.retry_after == 42
24
+ assert rate_limit.reset == 99
25
+
26
+
27
+ def test_rate_limit_ignores_invalid_headers() -> None:
28
+ """Invalid or absent headers should not break a request."""
29
+ rate_limit = RateLimit.from_headers({"Retry-After": "invalid"})
30
+
31
+ assert rate_limit.retry_after is None
32
+ assert rate_limit.minute_limit is None