python-homely 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.
- python_homely-0.1.0/LICENSE +21 -0
- python_homely-0.1.0/PKG-INFO +132 -0
- python_homely-0.1.0/README.md +99 -0
- python_homely-0.1.0/pyproject.toml +64 -0
- python_homely-0.1.0/setup.cfg +4 -0
- python_homely-0.1.0/src/homely/__init__.py +34 -0
- python_homely-0.1.0/src/homely/client.py +209 -0
- python_homely-0.1.0/src/homely/exceptions.py +34 -0
- python_homely-0.1.0/src/homely/models.py +31 -0
- python_homely-0.1.0/src/homely/websocket.py +407 -0
- python_homely-0.1.0/src/python_homely.egg-info/PKG-INFO +132 -0
- python_homely-0.1.0/src/python_homely.egg-info/SOURCES.txt +14 -0
- python_homely-0.1.0/src/python_homely.egg-info/dependency_links.txt +1 -0
- python_homely-0.1.0/src/python_homely.egg-info/requires.txt +9 -0
- python_homely-0.1.0/src/python_homely.egg-info/top_level.txt +1 -0
- python_homely-0.1.0/tests/test_sdk.py +195 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ludvik Blichfeldt Rød
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-homely
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for the Homely cloud API, built for Home Assistant but usable anywhere.
|
|
5
|
+
Author: Ludvik Blichfeldt Rød
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ludvikroed/python-homely
|
|
8
|
+
Project-URL: Documentation, https://github.com/ludvikroed/python-homely#readme
|
|
9
|
+
Project-URL: Source, https://github.com/ludvikroed/python-homely
|
|
10
|
+
Project-URL: Issues, https://github.com/ludvikroed/python-homely/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/ludvikroed/python-homely/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: homely,api,websocket,asyncio,home-automation
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Home Automation
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: aiohttp<4.0.0,>=3.9.0
|
|
25
|
+
Requires-Dist: python-socketio<6.0.0,>=5.11.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: build>=1.2.2; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest>=8.3.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.8.0; extra == "dev"
|
|
31
|
+
Requires-Dist: twine>=6.0.0; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# Python-homely
|
|
35
|
+
|
|
36
|
+
Async Python client for the Homely cloud API and realtime websocket updates.
|
|
37
|
+
|
|
38
|
+
This package was created for the Homely Home Assistant integration, but it is framework-independent and can be used in any Python project that needs to talk to Homely.
|
|
39
|
+
|
|
40
|
+
## Features
|
|
41
|
+
|
|
42
|
+
- Login and token refresh
|
|
43
|
+
- Location and home-data fetches
|
|
44
|
+
- Realtime websocket updates
|
|
45
|
+
- Typed exceptions
|
|
46
|
+
- Async API built on `aiohttp`
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python3 -m pip install python-homely
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Quick Start
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import aiohttp
|
|
58
|
+
from homely import HomelyClient
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def main() -> None:
|
|
62
|
+
async with aiohttp.ClientSession() as session:
|
|
63
|
+
client = HomelyClient(session)
|
|
64
|
+
|
|
65
|
+
token = await client.authenticate("user@example.com", "password")
|
|
66
|
+
locations = await client.get_locations_or_raise(token.access_token)
|
|
67
|
+
location_id = locations[0]["locationId"]
|
|
68
|
+
|
|
69
|
+
data = await client.get_home_data_or_raise(token.access_token, location_id)
|
|
70
|
+
print(data["name"])
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Websocket Example
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import aiohttp
|
|
77
|
+
from homely import HomelyClient, HomelyWebSocket
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def on_update(event: dict) -> None:
|
|
81
|
+
print(event)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def main() -> None:
|
|
85
|
+
async with aiohttp.ClientSession() as session:
|
|
86
|
+
client = HomelyClient(session)
|
|
87
|
+
token = await client.authenticate("user@example.com", "password")
|
|
88
|
+
locations = await client.get_locations_or_raise(token.access_token)
|
|
89
|
+
location_id = locations[0]["locationId"]
|
|
90
|
+
|
|
91
|
+
websocket = HomelyWebSocket(
|
|
92
|
+
location_id=location_id,
|
|
93
|
+
token=token.access_token,
|
|
94
|
+
on_data_update=on_update,
|
|
95
|
+
context_id="example",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
await websocket.connect_or_raise()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Main API
|
|
102
|
+
|
|
103
|
+
- `authenticate(username, password) -> TokenResponse`
|
|
104
|
+
- `refresh_access_token(refresh_token) -> TokenResponse`
|
|
105
|
+
- `get_locations_or_raise(token) -> list[dict]`
|
|
106
|
+
- `get_home_data_or_raise(token, location_id) -> dict`
|
|
107
|
+
- `HomelyWebSocket(...).connect_or_raise()`
|
|
108
|
+
|
|
109
|
+
Main exports:
|
|
110
|
+
|
|
111
|
+
- `HomelyClient`
|
|
112
|
+
- `HomelyWebSocket`
|
|
113
|
+
- `TokenResponse`
|
|
114
|
+
- `HomelyConnectionError`
|
|
115
|
+
- `HomelyAuthError`
|
|
116
|
+
- `HomelyResponseError`
|
|
117
|
+
- `HomelyWebSocketError`
|
|
118
|
+
|
|
119
|
+
## Exceptions
|
|
120
|
+
|
|
121
|
+
- `HomelyConnectionError`: network or service unavailable
|
|
122
|
+
- `HomelyAuthError`: invalid credentials or rejected token
|
|
123
|
+
- `HomelyResponseError`: unexpected response or HTTP failure
|
|
124
|
+
- `HomelyWebSocketError`: websocket could not be established
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT. See [LICENSE](LICENSE).
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
⭐ If you find this integration useful, please consider giving it a star on [GitHub](https://github.com/ludvikroed/python-homely)! ⭐
|
|
132
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Python-homely
|
|
2
|
+
|
|
3
|
+
Async Python client for the Homely cloud API and realtime websocket updates.
|
|
4
|
+
|
|
5
|
+
This package was created for the Homely Home Assistant integration, but it is framework-independent and can be used in any Python project that needs to talk to Homely.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Login and token refresh
|
|
10
|
+
- Location and home-data fetches
|
|
11
|
+
- Realtime websocket updates
|
|
12
|
+
- Typed exceptions
|
|
13
|
+
- Async API built on `aiohttp`
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python3 -m pip install python-homely
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import aiohttp
|
|
25
|
+
from homely import HomelyClient
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def main() -> None:
|
|
29
|
+
async with aiohttp.ClientSession() as session:
|
|
30
|
+
client = HomelyClient(session)
|
|
31
|
+
|
|
32
|
+
token = await client.authenticate("user@example.com", "password")
|
|
33
|
+
locations = await client.get_locations_or_raise(token.access_token)
|
|
34
|
+
location_id = locations[0]["locationId"]
|
|
35
|
+
|
|
36
|
+
data = await client.get_home_data_or_raise(token.access_token, location_id)
|
|
37
|
+
print(data["name"])
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Websocket Example
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import aiohttp
|
|
44
|
+
from homely import HomelyClient, HomelyWebSocket
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def on_update(event: dict) -> None:
|
|
48
|
+
print(event)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def main() -> None:
|
|
52
|
+
async with aiohttp.ClientSession() as session:
|
|
53
|
+
client = HomelyClient(session)
|
|
54
|
+
token = await client.authenticate("user@example.com", "password")
|
|
55
|
+
locations = await client.get_locations_or_raise(token.access_token)
|
|
56
|
+
location_id = locations[0]["locationId"]
|
|
57
|
+
|
|
58
|
+
websocket = HomelyWebSocket(
|
|
59
|
+
location_id=location_id,
|
|
60
|
+
token=token.access_token,
|
|
61
|
+
on_data_update=on_update,
|
|
62
|
+
context_id="example",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
await websocket.connect_or_raise()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Main API
|
|
69
|
+
|
|
70
|
+
- `authenticate(username, password) -> TokenResponse`
|
|
71
|
+
- `refresh_access_token(refresh_token) -> TokenResponse`
|
|
72
|
+
- `get_locations_or_raise(token) -> list[dict]`
|
|
73
|
+
- `get_home_data_or_raise(token, location_id) -> dict`
|
|
74
|
+
- `HomelyWebSocket(...).connect_or_raise()`
|
|
75
|
+
|
|
76
|
+
Main exports:
|
|
77
|
+
|
|
78
|
+
- `HomelyClient`
|
|
79
|
+
- `HomelyWebSocket`
|
|
80
|
+
- `TokenResponse`
|
|
81
|
+
- `HomelyConnectionError`
|
|
82
|
+
- `HomelyAuthError`
|
|
83
|
+
- `HomelyResponseError`
|
|
84
|
+
- `HomelyWebSocketError`
|
|
85
|
+
|
|
86
|
+
## Exceptions
|
|
87
|
+
|
|
88
|
+
- `HomelyConnectionError`: network or service unavailable
|
|
89
|
+
- `HomelyAuthError`: invalid credentials or rejected token
|
|
90
|
+
- `HomelyResponseError`: unexpected response or HTTP failure
|
|
91
|
+
- `HomelyWebSocketError`: websocket could not be established
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT. See [LICENSE](LICENSE).
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
⭐ If you find this integration useful, please consider giving it a star on [GitHub](https://github.com/ludvikroed/python-homely)! ⭐
|
|
99
|
+
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-homely"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async Python client for the Homely cloud API, built for Home Assistant but usable anywhere."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Ludvik Blichfeldt Rød" }
|
|
15
|
+
]
|
|
16
|
+
keywords = ["homely", "api", "websocket", "asyncio", "home-automation"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Home Automation",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"aiohttp>=3.9.0,<4.0.0",
|
|
29
|
+
"python-socketio>=5.11.0,<6.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/ludvikroed/python-homely"
|
|
34
|
+
Documentation = "https://github.com/ludvikroed/python-homely#readme"
|
|
35
|
+
Source = "https://github.com/ludvikroed/python-homely"
|
|
36
|
+
Issues = "https://github.com/ludvikroed/python-homely/issues"
|
|
37
|
+
Changelog = "https://github.com/ludvikroed/python-homely/blob/main/CHANGELOG.md"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
dev = [
|
|
41
|
+
"build>=1.2.2",
|
|
42
|
+
"pytest>=8.3.0",
|
|
43
|
+
"pytest-asyncio>=0.24.0",
|
|
44
|
+
"ruff>=0.8.0",
|
|
45
|
+
"twine>=6.0.0",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.setuptools]
|
|
49
|
+
package-dir = {"" = "src"}
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.packages.find]
|
|
52
|
+
where = ["src"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
asyncio_mode = "auto"
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
cache_dir = "/tmp/python-homely-pytest-cache"
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 100
|
|
61
|
+
target-version = "py312"
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Reusable Homely client package extracted from the integration."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .client import (
|
|
6
|
+
BASE_URL,
|
|
7
|
+
REQUEST_TIMEOUT,
|
|
8
|
+
HomelyClient,
|
|
9
|
+
auth_header_value,
|
|
10
|
+
)
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
HomelyAuthError,
|
|
13
|
+
HomelyConnectionError,
|
|
14
|
+
HomelyError,
|
|
15
|
+
HomelyResponseError,
|
|
16
|
+
HomelyWebSocketError,
|
|
17
|
+
)
|
|
18
|
+
from .models import TokenResponse
|
|
19
|
+
from .websocket import HomelyWebSocket
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"__version__",
|
|
23
|
+
"BASE_URL",
|
|
24
|
+
"REQUEST_TIMEOUT",
|
|
25
|
+
"HomelyClient",
|
|
26
|
+
"HomelyWebSocket",
|
|
27
|
+
"HomelyError",
|
|
28
|
+
"HomelyConnectionError",
|
|
29
|
+
"HomelyAuthError",
|
|
30
|
+
"HomelyResponseError",
|
|
31
|
+
"HomelyWebSocketError",
|
|
32
|
+
"TokenResponse",
|
|
33
|
+
"auth_header_value",
|
|
34
|
+
]
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Async Homely API client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
HomelyAuthError,
|
|
11
|
+
HomelyConnectionError,
|
|
12
|
+
HomelyResponseError,
|
|
13
|
+
)
|
|
14
|
+
from .models import TokenResponse
|
|
15
|
+
|
|
16
|
+
_LOGGER = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
BASE_URL = "https://sdk.iotiliti.cloud/homely/"
|
|
19
|
+
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=20)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def auth_header_value(token: str | None) -> str:
|
|
23
|
+
"""Return normalized Authorization header value."""
|
|
24
|
+
normalized = (token or "").strip()
|
|
25
|
+
if normalized.lower().startswith("bearer "):
|
|
26
|
+
return normalized
|
|
27
|
+
return f"Bearer {normalized}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HomelyClient:
|
|
31
|
+
"""Small reusable async client for the Homely cloud API."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
session: aiohttp.ClientSession,
|
|
36
|
+
*,
|
|
37
|
+
base_url: str = BASE_URL,
|
|
38
|
+
timeout: aiohttp.ClientTimeout = REQUEST_TIMEOUT,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Initialize the client with a caller-managed aiohttp session."""
|
|
41
|
+
self._session = session
|
|
42
|
+
self._base_url = base_url
|
|
43
|
+
self._timeout = timeout
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def base_url(self) -> str:
|
|
47
|
+
"""Return the configured API base URL."""
|
|
48
|
+
return self._base_url
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def timeout(self) -> aiohttp.ClientTimeout:
|
|
52
|
+
"""Return the configured request timeout."""
|
|
53
|
+
return self._timeout
|
|
54
|
+
|
|
55
|
+
async def authenticate(
|
|
56
|
+
self,
|
|
57
|
+
username: str,
|
|
58
|
+
password: str,
|
|
59
|
+
) -> TokenResponse:
|
|
60
|
+
"""Authenticate and return a typed token response.
|
|
61
|
+
|
|
62
|
+
Raises a typed SDK exception on failure.
|
|
63
|
+
"""
|
|
64
|
+
response, reason = await self.fetch_token_with_reason(username, password)
|
|
65
|
+
if response:
|
|
66
|
+
return TokenResponse.from_dict(response)
|
|
67
|
+
if reason == "invalid_auth":
|
|
68
|
+
raise HomelyAuthError("Invalid Homely username or password")
|
|
69
|
+
raise HomelyConnectionError("Could not connect to Homely")
|
|
70
|
+
|
|
71
|
+
async def fetch_token_with_reason(
|
|
72
|
+
self,
|
|
73
|
+
username: str,
|
|
74
|
+
password: str,
|
|
75
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
76
|
+
"""Fetch access token and return optional reason key on failure."""
|
|
77
|
+
url = f"{self._base_url}oauth/token"
|
|
78
|
+
payload = {
|
|
79
|
+
"username": username,
|
|
80
|
+
"password": password,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
async with self._session.post(url, json=payload, timeout=self._timeout) as response:
|
|
85
|
+
if response.status in (200, 201):
|
|
86
|
+
_LOGGER.debug("Token fetch successful")
|
|
87
|
+
return await response.json(), None
|
|
88
|
+
|
|
89
|
+
if response.status in (400, 401, 403):
|
|
90
|
+
_LOGGER.debug("Token fetch rejected with status=%s", response.status)
|
|
91
|
+
return None, "invalid_auth"
|
|
92
|
+
|
|
93
|
+
_LOGGER.warning("Token fetch failed with status=%s", response.status)
|
|
94
|
+
return None, "cannot_connect"
|
|
95
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
96
|
+
_LOGGER.warning("Token fetch network error: %s", err)
|
|
97
|
+
return None, "cannot_connect"
|
|
98
|
+
|
|
99
|
+
async def fetch_token(
|
|
100
|
+
self,
|
|
101
|
+
username: str,
|
|
102
|
+
password: str,
|
|
103
|
+
) -> dict[str, Any] | None:
|
|
104
|
+
"""Fetch access token from API."""
|
|
105
|
+
response, _reason = await self.fetch_token_with_reason(username, password)
|
|
106
|
+
return response
|
|
107
|
+
|
|
108
|
+
async def fetch_refresh_token(self, refresh_token: str) -> dict[str, Any] | None:
|
|
109
|
+
"""Refresh access token using refresh token."""
|
|
110
|
+
url = f"{self._base_url}oauth/refresh-token"
|
|
111
|
+
payload = {
|
|
112
|
+
"refresh_token": refresh_token,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
async with self._session.post(url, json=payload, timeout=self._timeout) as response:
|
|
117
|
+
if response.status in (200, 201):
|
|
118
|
+
_LOGGER.debug("Token refresh successful")
|
|
119
|
+
return await response.json()
|
|
120
|
+
_LOGGER.debug("Token refresh failed with status=%s", response.status)
|
|
121
|
+
return None
|
|
122
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
123
|
+
_LOGGER.debug("Token refresh network error: %s", err)
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
async def refresh_access_token(self, refresh_token: str) -> TokenResponse:
|
|
127
|
+
"""Refresh access token and return a typed token response."""
|
|
128
|
+
response = await self.fetch_refresh_token(refresh_token)
|
|
129
|
+
if response:
|
|
130
|
+
return TokenResponse.from_dict(response)
|
|
131
|
+
raise HomelyConnectionError("Could not refresh Homely access token")
|
|
132
|
+
|
|
133
|
+
async def get_locations(self, token: str) -> list[dict[str, Any]] | None:
|
|
134
|
+
"""Get locations from API."""
|
|
135
|
+
url = f"{self._base_url}locations"
|
|
136
|
+
headers = {"Authorization": auth_header_value(token)}
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
|
|
140
|
+
if response.status == 200:
|
|
141
|
+
_LOGGER.debug("Locations fetch successful")
|
|
142
|
+
return await response.json()
|
|
143
|
+
_LOGGER.debug("Locations fetch failed with status=%s", response.status)
|
|
144
|
+
return None
|
|
145
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
146
|
+
_LOGGER.debug("Locations fetch network error: %s", err)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
async def get_locations_or_raise(self, token: str) -> list[dict[str, Any]]:
|
|
150
|
+
"""Get locations from API or raise a typed exception."""
|
|
151
|
+
locations = await self.get_locations(token)
|
|
152
|
+
if locations is not None:
|
|
153
|
+
return locations
|
|
154
|
+
raise HomelyConnectionError("Could not fetch Homely locations")
|
|
155
|
+
|
|
156
|
+
async def get_home_data(
|
|
157
|
+
self,
|
|
158
|
+
token: str,
|
|
159
|
+
location_id: str | int,
|
|
160
|
+
) -> dict[str, Any] | None:
|
|
161
|
+
"""Get location data from API."""
|
|
162
|
+
data, _status = await self.get_home_data_with_status(token, location_id)
|
|
163
|
+
return data
|
|
164
|
+
|
|
165
|
+
async def get_home_data_with_status(
|
|
166
|
+
self,
|
|
167
|
+
token: str,
|
|
168
|
+
location_id: str | int,
|
|
169
|
+
) -> tuple[dict[str, Any] | None, int | None]:
|
|
170
|
+
"""Get location data from API and include HTTP status when available."""
|
|
171
|
+
url = f"{self._base_url}home/{location_id}"
|
|
172
|
+
headers = {"Authorization": auth_header_value(token)}
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
|
|
176
|
+
if response.status == 200:
|
|
177
|
+
return await response.json(), response.status
|
|
178
|
+
body = await response.text()
|
|
179
|
+
body_preview = body.replace("\n", " ")[:200]
|
|
180
|
+
_LOGGER.debug(
|
|
181
|
+
"Location data fetch failed with status=%s location_id=%s body_preview=%r",
|
|
182
|
+
response.status,
|
|
183
|
+
location_id,
|
|
184
|
+
body_preview,
|
|
185
|
+
)
|
|
186
|
+
return None, response.status
|
|
187
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
188
|
+
_LOGGER.debug(
|
|
189
|
+
"Location data fetch network error location_id=%s: %s",
|
|
190
|
+
location_id,
|
|
191
|
+
err,
|
|
192
|
+
)
|
|
193
|
+
return None, None
|
|
194
|
+
|
|
195
|
+
async def get_home_data_or_raise(
|
|
196
|
+
self,
|
|
197
|
+
token: str,
|
|
198
|
+
location_id: str | int,
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
"""Get location data from API or raise a typed exception."""
|
|
201
|
+
data, status = await self.get_home_data_with_status(token, location_id)
|
|
202
|
+
if data is not None:
|
|
203
|
+
return data
|
|
204
|
+
if status in (401, 403):
|
|
205
|
+
raise HomelyAuthError("Homely rejected the supplied access token")
|
|
206
|
+
raise HomelyResponseError(
|
|
207
|
+
"Could not fetch Homely location data",
|
|
208
|
+
status=status,
|
|
209
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Exceptions exposed by the Homely SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class HomelyError(Exception):
|
|
6
|
+
"""Base exception for Homely SDK failures."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HomelyConnectionError(HomelyError):
|
|
10
|
+
"""Raised when the Homely service cannot be reached."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HomelyAuthError(HomelyError):
|
|
14
|
+
"""Raised when Homely rejects authentication or authorization."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HomelyResponseError(HomelyError):
|
|
18
|
+
"""Raised when Homely returns an unexpected response."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
message: str,
|
|
23
|
+
*,
|
|
24
|
+
status: int | None = None,
|
|
25
|
+
body: str | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Initialize the exception with optional response details."""
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.status = status
|
|
30
|
+
self.body = body
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HomelyWebSocketError(HomelyError):
|
|
34
|
+
"""Raised when the Homely websocket cannot be established."""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Public data models for the Homely SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class TokenResponse:
|
|
10
|
+
"""Typed token response returned by the Homely authentication endpoints."""
|
|
11
|
+
|
|
12
|
+
access_token: str
|
|
13
|
+
refresh_token: str | None = None
|
|
14
|
+
expires_in: int | None = None
|
|
15
|
+
raw: dict[str, Any] | None = None
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_dict(cls, data: dict[str, Any]) -> TokenResponse:
|
|
19
|
+
"""Build a typed token response from a raw API payload."""
|
|
20
|
+
expires_in = data.get("expires_in")
|
|
21
|
+
try:
|
|
22
|
+
parsed_expires_in = int(expires_in) if expires_in is not None else None
|
|
23
|
+
except (TypeError, ValueError):
|
|
24
|
+
parsed_expires_in = None
|
|
25
|
+
|
|
26
|
+
return cls(
|
|
27
|
+
access_token=str(data["access_token"]),
|
|
28
|
+
refresh_token=data.get("refresh_token"),
|
|
29
|
+
expires_in=parsed_expires_in,
|
|
30
|
+
raw=dict(data),
|
|
31
|
+
)
|