pyaxencoapi 1.0.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.
- pyaxencoapi-1.0.0/LICENSE +21 -0
- pyaxencoapi-1.0.0/PKG-INFO +30 -0
- pyaxencoapi-1.0.0/README.md +8 -0
- pyaxencoapi-1.0.0/pyproject.toml +40 -0
- pyaxencoapi-1.0.0/setup.cfg +4 -0
- pyaxencoapi-1.0.0/src/pyaxencoapi/__init__.py +3 -0
- pyaxencoapi-1.0.0/src/pyaxencoapi/api.py +488 -0
- pyaxencoapi-1.0.0/src/pyaxencoapi/utils.py +37 -0
- pyaxencoapi-1.0.0/src/pyaxencoapi.egg-info/PKG-INFO +30 -0
- pyaxencoapi-1.0.0/src/pyaxencoapi.egg-info/SOURCES.txt +12 -0
- pyaxencoapi-1.0.0/src/pyaxencoapi.egg-info/dependency_links.txt +1 -0
- pyaxencoapi-1.0.0/src/pyaxencoapi.egg-info/requires.txt +12 -0
- pyaxencoapi-1.0.0/src/pyaxencoapi.egg-info/top_level.txt +1 -0
- pyaxencoapi-1.0.0/tests/test_api.py +97 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 AXENCO
|
|
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,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyaxencoapi
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Librairie d'utilisation de l'API REST/Websocket d'Axenco
|
|
5
|
+
Author-email: Léo Periou <leo.periou@axenco.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
11
|
+
Requires-Dist: python-socketio[asyncio_client]>=5.0.0
|
|
12
|
+
Requires-Dist: async-timeout>=4.0.2
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
16
|
+
Requires-Dist: aiohttp; extra == "dev"
|
|
17
|
+
Requires-Dist: aioresponses; extra == "dev"
|
|
18
|
+
Requires-Dist: python-socketio[asyncio_client]; extra == "dev"
|
|
19
|
+
Requires-Dist: coverage; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# PyAxencoAPI
|
|
24
|
+
|
|
25
|
+
Client Python asynchrone pour interagir avec les dispositifs connectés MyNeomitis via API REST/Websocket.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install pyaxencoapi
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyaxencoapi"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Librairie d'utilisation de l'API REST/Websocket d'Axenco"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [ "aiohttp>=3.8.0", "python-socketio[asyncio_client]>=5.0.0", "async-timeout>=4.0.2",]
|
|
8
|
+
[[project.authors]]
|
|
9
|
+
name = "Léo Periou"
|
|
10
|
+
email = "leo.periou@axenco.com"
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = [ "setuptools>=61.0", "wheel",]
|
|
14
|
+
build-backend = "setuptools.build_meta"
|
|
15
|
+
|
|
16
|
+
[project.license]
|
|
17
|
+
text = "MIT"
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [ "pytest", "pytest-asyncio", "aiohttp", "aioresponses", "python-socketio[asyncio_client]", "coverage", "pytest-cov",]
|
|
21
|
+
|
|
22
|
+
[tool.mypy]
|
|
23
|
+
strict = true
|
|
24
|
+
|
|
25
|
+
[tool.isort]
|
|
26
|
+
profile = "black"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.package-dir]
|
|
29
|
+
"" = "src"
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
asyncio_mode = "auto"
|
|
33
|
+
addopts = "--tb=short -q"
|
|
34
|
+
|
|
35
|
+
[tool.coverage.run]
|
|
36
|
+
branch = true
|
|
37
|
+
source = [ "pyaxencoapi",]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = [ "src",]
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""API client for interacting with the Axenco API."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
import socketio
|
|
9
|
+
import async_timeout
|
|
10
|
+
|
|
11
|
+
from .utils import find_childs, get_rfid_by_id
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
API_BASE = "https://user-ep.imhotepcreation.com"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PyAxencoAPI:
|
|
19
|
+
"""API client for interacting with the Axenco API."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, source_id: str, session: aiohttp.ClientSession) -> None:
|
|
22
|
+
"""Initialize the client.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
source_id: The unique id for request.
|
|
26
|
+
session: The aiohttp session for making HTTP requests.
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
self.session = session
|
|
30
|
+
self.source_id = source_id
|
|
31
|
+
self.token: str | None = None
|
|
32
|
+
self.refresh_token: str | None = None
|
|
33
|
+
self.user_id: str | None = None
|
|
34
|
+
self.sio = socketio.AsyncClient()
|
|
35
|
+
|
|
36
|
+
# Cache
|
|
37
|
+
self._devices_cache: list[dict] = []
|
|
38
|
+
self._last_fetch: float = 0
|
|
39
|
+
|
|
40
|
+
# Websocket listeners
|
|
41
|
+
self._listeners: dict[str, Callable[[dict], None]] = {}
|
|
42
|
+
self._discovery_callbacks: list[Callable[[dict], None]] = []
|
|
43
|
+
self._removal_callbacks: list[Callable[[str], None]] = []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def connect_websocket(self) -> None:
|
|
47
|
+
"""Connect the Axenco WebSocket and handle events."""
|
|
48
|
+
@self.sio.event
|
|
49
|
+
async def connect() -> None:
|
|
50
|
+
_LOGGER.debug("WebSocket connected to Axenco")
|
|
51
|
+
|
|
52
|
+
@self.sio.event
|
|
53
|
+
async def disconnect() -> None:
|
|
54
|
+
_LOGGER.debug("WebSocket disconnected from Axenco")
|
|
55
|
+
|
|
56
|
+
@self.sio.on("setState")
|
|
57
|
+
async def on_set_state(data: dict) -> None:
|
|
58
|
+
device_id = data.get("objectId")
|
|
59
|
+
_LOGGER.debug("WS SETSTATE received: %s", data)
|
|
60
|
+
await self.notify_update(device_id, data.get("data"))
|
|
61
|
+
|
|
62
|
+
@self.sio.on("setProgram")
|
|
63
|
+
async def on_set_program(data: dict) -> None:
|
|
64
|
+
device_id = data.get("objectId")
|
|
65
|
+
_LOGGER.debug("WS SETPROGRAM received: %s", data)
|
|
66
|
+
await self.notify_update(device_id, {"program": data.get("data")})
|
|
67
|
+
|
|
68
|
+
@self.sio.event
|
|
69
|
+
async def connect_error(data: dict) -> None:
|
|
70
|
+
_LOGGER.error("WebSocket connection error: %s", data)
|
|
71
|
+
|
|
72
|
+
@self.sio.on("update")
|
|
73
|
+
async def on_update(data: dict) -> None:
|
|
74
|
+
device_id = data.get("objectId")
|
|
75
|
+
_LOGGER.debug("WS UPDATE received: %s", data)
|
|
76
|
+
await self.notify_update(device_id, data.get("data"))
|
|
77
|
+
|
|
78
|
+
@self.sio.on("discover")
|
|
79
|
+
async def on_discover(data: dict) -> None:
|
|
80
|
+
_LOGGER.debug("WS DISCOVER received: %s", data)
|
|
81
|
+
|
|
82
|
+
@self.sio.on("updateExtDevState")
|
|
83
|
+
async def on_update_ext(data: dict) -> None:
|
|
84
|
+
device_id = data.get("objectId")
|
|
85
|
+
_LOGGER.debug("WS UPDATE SUB-SERVICE received: %s", data)
|
|
86
|
+
await self.notify_update(device_id, data.get("data"))
|
|
87
|
+
|
|
88
|
+
@self.sio.on("setExtDevState")
|
|
89
|
+
async def on_set_ext(data: dict) -> None:
|
|
90
|
+
device_id = data.get("objectId")
|
|
91
|
+
_LOGGER.debug("WS SETSTATE SUB-SERVICE received: %s", data)
|
|
92
|
+
await self.notify_update(device_id, data.get("data"))
|
|
93
|
+
|
|
94
|
+
@self.sio.on("link")
|
|
95
|
+
async def on_link(data: dict) -> None:
|
|
96
|
+
devices = data.get("data", {}).get("devices", [])
|
|
97
|
+
_LOGGER.debug("WS LINK received: %s", data)
|
|
98
|
+
await self.notify_discovery(devices)
|
|
99
|
+
|
|
100
|
+
@self.sio.on("unlink")
|
|
101
|
+
async def on_unlink(data: dict) -> None:
|
|
102
|
+
device_id = data.get("objectId")
|
|
103
|
+
_LOGGER.debug("WS UNLINK received: %s", data)
|
|
104
|
+
await self.notify_removal(device_id)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
ws_url = f"{API_BASE}?userId={self.user_id}"
|
|
108
|
+
|
|
109
|
+
await self.sio.connect(
|
|
110
|
+
ws_url,
|
|
111
|
+
socketio_path="socket.io-v2",
|
|
112
|
+
transports=["websocket"],
|
|
113
|
+
headers=self.get_auth_headers()
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
except (socketio.exceptions.ConnectionError, aiohttp.ClientError) as e:
|
|
117
|
+
_LOGGER.error("WebSocket connection failed: %s", e)
|
|
118
|
+
|
|
119
|
+
async def disconnect_websocket(self) -> None:
|
|
120
|
+
"""Disconnect the Axenco WebSocket connection."""
|
|
121
|
+
await self.sio.disconnect()
|
|
122
|
+
|
|
123
|
+
def register_listener(self, device_id: str, callback: Callable[[dict], None]) -> None:
|
|
124
|
+
"""Register a listener for WebSocket updates.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
device_id: The ID of the device to listen for updates.
|
|
128
|
+
callback: The callback function to invoke on updates.
|
|
129
|
+
|
|
130
|
+
"""
|
|
131
|
+
self._listeners[device_id] = callback
|
|
132
|
+
|
|
133
|
+
def register_discovery_callback(self, callback: Callable[[dict], None]) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Register a callback to be invoked when a new device is discovered.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
callback (Callable[[dict], None]): A function that takes a dictionary
|
|
139
|
+
containing device information as its argument and returns None.
|
|
140
|
+
"""
|
|
141
|
+
self._discovery_callbacks.append(callback)
|
|
142
|
+
|
|
143
|
+
def register_removal_callback(self, callback: Callable[[str], None]) -> None:
|
|
144
|
+
"""
|
|
145
|
+
Registers a callback function to be invoked when a device is unlinked.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
callback (Callable[[str], None]): A function that takes a single string argument
|
|
149
|
+
(representing the device ID) and returns None. This function will be called
|
|
150
|
+
whenever a device is removed.
|
|
151
|
+
"""
|
|
152
|
+
self._removal_callbacks.append(callback)
|
|
153
|
+
|
|
154
|
+
async def notify_discovery(self, device: dict) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Notify registered listeners about the discovery of a new device.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
device (dict): A dictionary containing information about the discovered device.
|
|
160
|
+
"""
|
|
161
|
+
for cb in self._discovery_callbacks:
|
|
162
|
+
cb(device)
|
|
163
|
+
|
|
164
|
+
async def notify_removal(self, device_id: str) -> None:
|
|
165
|
+
"""Notify listeners that a device was removed (unlinked).
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
device_id (str): The ID of the device to remove.
|
|
169
|
+
"""
|
|
170
|
+
for cb in self._removal_callbacks:
|
|
171
|
+
cb(device_id)
|
|
172
|
+
|
|
173
|
+
async def notify_update(self, device_id: str, new_state: dict) -> None:
|
|
174
|
+
"""Notify an entity of a WebSocket update and propagate to child devices.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
device_id: The ID of the device to notify.
|
|
178
|
+
new_state: The new state to notify.
|
|
179
|
+
|
|
180
|
+
"""
|
|
181
|
+
# Notify the main device
|
|
182
|
+
if cb := self._listeners.get(device_id):
|
|
183
|
+
cb(new_state)
|
|
184
|
+
|
|
185
|
+
devices = await self.get_devices()
|
|
186
|
+
|
|
187
|
+
# Find child devices
|
|
188
|
+
device_rfid = get_rfid_by_id(devices, device_id)
|
|
189
|
+
if device_rfid != "":
|
|
190
|
+
child_ids = find_childs(devices, device_rfid)
|
|
191
|
+
|
|
192
|
+
# Notify child devices
|
|
193
|
+
for child_id in child_ids:
|
|
194
|
+
if cb := self._listeners.get(child_id):
|
|
195
|
+
cb(new_state)
|
|
196
|
+
|
|
197
|
+
async def login(self, email: str, password: str) -> None:
|
|
198
|
+
"""Authenticate with the Axenco API and retrieve authentication tokens.
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
email: The email address used for authentication.
|
|
202
|
+
password: The password used for authentication.
|
|
203
|
+
aiohttp.ClientError: If there is an HTTP error during the request.
|
|
204
|
+
TimeoutError: If the request times out.
|
|
205
|
+
ValueError: If the response is invalid.
|
|
206
|
+
|
|
207
|
+
"""
|
|
208
|
+
url = f"{API_BASE}/v1/auth/login"
|
|
209
|
+
data = {"email": email, "password": password}
|
|
210
|
+
headers = {
|
|
211
|
+
"application": "home-assistant",
|
|
212
|
+
"application-version": "1.0.0",
|
|
213
|
+
"source-type": "plugin",
|
|
214
|
+
"source-id": self.source_id
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
async with async_timeout.timeout(10):
|
|
219
|
+
response = await self.session.post(url, json=data, headers=headers)
|
|
220
|
+
response.raise_for_status()
|
|
221
|
+
result = await response.json()
|
|
222
|
+
|
|
223
|
+
if "token" not in result or "refresh_token" not in result or "id" not in result:
|
|
224
|
+
raise ValueError("Unexpected response format") # noqa: TRY301
|
|
225
|
+
|
|
226
|
+
self.token = result["token"]
|
|
227
|
+
self.refresh_token = result["refresh_token"]
|
|
228
|
+
if not self.user_id:
|
|
229
|
+
self.user_id = result["id"]
|
|
230
|
+
|
|
231
|
+
except aiohttp.ClientError as err:
|
|
232
|
+
_LOGGER.error("HTTP error during login: %s", err)
|
|
233
|
+
raise
|
|
234
|
+
except TimeoutError as err:
|
|
235
|
+
_LOGGER.error("Timeout error during login: %s", err)
|
|
236
|
+
raise
|
|
237
|
+
except ValueError as err:
|
|
238
|
+
_LOGGER.error("Invalid response during login: %s", err)
|
|
239
|
+
raise
|
|
240
|
+
|
|
241
|
+
async def logout(self) -> None:
|
|
242
|
+
"""Log out from the Axenco API and clear session data."""
|
|
243
|
+
await self.sio.disconnect()
|
|
244
|
+
self.token = None
|
|
245
|
+
self.refresh_token = None
|
|
246
|
+
self.user_id = None
|
|
247
|
+
self._listeners.clear()
|
|
248
|
+
|
|
249
|
+
def get_auth_headers(self) -> dict[str, str]:
|
|
250
|
+
"""Generate and return the authentication headers for API requests.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
A dictionary containing the authentication headers.
|
|
254
|
+
|
|
255
|
+
"""
|
|
256
|
+
return {
|
|
257
|
+
"Authorization": f"Bearer {self.token}",
|
|
258
|
+
"application": "home-assistant",
|
|
259
|
+
"source-type": "plugin",
|
|
260
|
+
"source-id": self.source_id
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async def get_devices(self, force: bool = False) -> list[dict]:
|
|
264
|
+
"""Retrieve the list of devices associated with the authenticated user.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
force: Whether to force a refresh of the device list.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
A list of devices if successful, or an empty list if an error occurs.
|
|
271
|
+
|
|
272
|
+
"""
|
|
273
|
+
if not force and time.time() - self._last_fetch < 300:
|
|
274
|
+
return self._devices_cache
|
|
275
|
+
|
|
276
|
+
url = f"{API_BASE}/v1/users/{self.user_id}/devices"
|
|
277
|
+
headers = self.get_auth_headers()
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
async with self.session.get(url, headers=headers) as response:
|
|
281
|
+
response.raise_for_status()
|
|
282
|
+
data = await response.json()
|
|
283
|
+
self._devices_cache = data
|
|
284
|
+
self._last_fetch = time.time()
|
|
285
|
+
return data
|
|
286
|
+
except aiohttp.ClientError as e:
|
|
287
|
+
_LOGGER.error("HTTP error while retrieving devices: %s", e)
|
|
288
|
+
return []
|
|
289
|
+
except TimeoutError as e:
|
|
290
|
+
_LOGGER.error("Timeout error while retrieving devices: %s", e)
|
|
291
|
+
return []
|
|
292
|
+
except ValueError as e:
|
|
293
|
+
_LOGGER.error("Invalid response while retrieving devices: %s", e)
|
|
294
|
+
return []
|
|
295
|
+
|
|
296
|
+
async def get_device_state(self, device_id: str) -> dict | None:
|
|
297
|
+
"""Retrieve the state of a specific device.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
device_id: The unique identifier of the device.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
The state of the device if successful, or None if an error occurs.
|
|
304
|
+
|
|
305
|
+
"""
|
|
306
|
+
url = f"{API_BASE}/v1/devices/{device_id}"
|
|
307
|
+
headers = self.get_auth_headers()
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
async with self.session.get(url, headers=headers) as response:
|
|
311
|
+
response.raise_for_status()
|
|
312
|
+
return await response.json()
|
|
313
|
+
except aiohttp.ClientError as e:
|
|
314
|
+
_LOGGER.error("HTTP error while retrieving device %s: %s", device_id, e)
|
|
315
|
+
return None
|
|
316
|
+
except TimeoutError as e:
|
|
317
|
+
_LOGGER.error("Timeout error while retrieving device %s: %s", device_id, e)
|
|
318
|
+
return None
|
|
319
|
+
except ValueError as e:
|
|
320
|
+
_LOGGER.error("Invalid response while retrieving device %s: %s", device_id, e)
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
async def get_sub_device_state(self, gateway_id: str) -> dict | None:
|
|
324
|
+
"""Retrieve the state of a specific sub device.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
gateway_id: The unique identifier of the gateway device.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
The state of the device if successful, or None if an error occurs.
|
|
331
|
+
|
|
332
|
+
"""
|
|
333
|
+
url = f"{API_BASE}/v1/devices/{gateway_id}/sub-devices"
|
|
334
|
+
headers = self.get_auth_headers()
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
async with self.session.get(url, headers=headers) as response:
|
|
338
|
+
response.raise_for_status()
|
|
339
|
+
return await response.json()
|
|
340
|
+
except aiohttp.ClientError as e:
|
|
341
|
+
_LOGGER.error("HTTP error while retrieving device %s: %s", gateway_id, e)
|
|
342
|
+
return None
|
|
343
|
+
except TimeoutError as e:
|
|
344
|
+
_LOGGER.error("Timeout error while retrieving device %s: %s", gateway_id, e)
|
|
345
|
+
return None
|
|
346
|
+
except ValueError as e:
|
|
347
|
+
_LOGGER.error("Invalid response while retrieving device %s: %s", gateway_id, e)
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
async def set_device_temperature(self, device_id: str, temperature: float) -> None:
|
|
351
|
+
"""Set the temperature of a specific device.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
device_id: The unique identifier of the device.
|
|
355
|
+
temperature: The desired temperature to set for the device.
|
|
356
|
+
|
|
357
|
+
Raises:
|
|
358
|
+
aiohttp.ClientError: If there is an HTTP error during the request.
|
|
359
|
+
aiohttp.ClientResponseError: If the server returns an error response.
|
|
360
|
+
|
|
361
|
+
"""
|
|
362
|
+
url = f"{API_BASE}/v1/devices/{device_id}/state"
|
|
363
|
+
headers = self.get_auth_headers()
|
|
364
|
+
payload = {
|
|
365
|
+
"parameters": {
|
|
366
|
+
"overrideTemp": temperature
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async with self.session.patch(url, json=payload, headers=headers) as resp:
|
|
370
|
+
resp.raise_for_status()
|
|
371
|
+
|
|
372
|
+
async def set_sub_device_temperature(self, gateway_id: str, device_rfid: str, temperature: float) -> None:
|
|
373
|
+
"""Set the temperature of a specific sub device.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
gateway_id: The unique identifier of the gateway device.
|
|
377
|
+
device_rfid: The RFID of the sub device.
|
|
378
|
+
temperature: The desired temperature to set for the sub device.
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
aiohttp.ClientError: If there is an HTTP error during the request.
|
|
382
|
+
aiohttp.ClientResponseError: If the server returns an error response.
|
|
383
|
+
|
|
384
|
+
"""
|
|
385
|
+
url = f"{API_BASE}/v1/devices/{gateway_id}/sub-devices/state"
|
|
386
|
+
headers = self.get_auth_headers()
|
|
387
|
+
payload = {
|
|
388
|
+
"parameters": {
|
|
389
|
+
device_rfid: {
|
|
390
|
+
"targetTemp": temperature
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
async with self.session.patch(url, json=payload, headers=headers) as resp:
|
|
395
|
+
resp.raise_for_status()
|
|
396
|
+
|
|
397
|
+
async def set_device_mode(self, device_id: str, mode_code: int) -> None:
|
|
398
|
+
"""Set the mode of a specific device.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
device_id: The unique identifier of the device.
|
|
402
|
+
mode_code: The code representing the desired mode to set for the device.
|
|
403
|
+
|
|
404
|
+
Raises:
|
|
405
|
+
aiohttp.ClientError: If there is an HTTP error during the request.
|
|
406
|
+
aiohttp.ClientResponseError: If the server returns an error response.
|
|
407
|
+
|
|
408
|
+
"""
|
|
409
|
+
url = f"{API_BASE}/v1/devices/{device_id}/state"
|
|
410
|
+
headers = self.get_auth_headers()
|
|
411
|
+
payload = {
|
|
412
|
+
"parameters": {
|
|
413
|
+
"targetMode": mode_code
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async with self.session.patch(url, json=payload, headers=headers) as resp:
|
|
417
|
+
resp.raise_for_status()
|
|
418
|
+
|
|
419
|
+
async def set_sub_device_mode(self, gateway_id: str, device_rfid: str, mode_code: int) -> None:
|
|
420
|
+
"""Set the mode of a specific sub device.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
gateway_id: The unique identifier of the gateway device.
|
|
424
|
+
device_rfid: The RFID of the sub device.
|
|
425
|
+
mode_code: The code representing the desired mode to set for the device.
|
|
426
|
+
|
|
427
|
+
Raises:
|
|
428
|
+
aiohttp.ClientError: If there is an HTTP error during the request.
|
|
429
|
+
aiohttp.ClientResponseError: If the server returns an error response.
|
|
430
|
+
|
|
431
|
+
"""
|
|
432
|
+
url = f"{API_BASE}/v1/devices/{gateway_id}/sub-devices/state"
|
|
433
|
+
headers = self.get_auth_headers()
|
|
434
|
+
payload = {
|
|
435
|
+
"parameters": {
|
|
436
|
+
device_rfid: {
|
|
437
|
+
"targetMode": mode_code
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
async with self.session.patch(url, json=payload, headers=headers) as resp:
|
|
442
|
+
resp.raise_for_status()
|
|
443
|
+
|
|
444
|
+
async def set_sub_device_mode_ufh(self, gateway_id: str, device_rfid: str, mode_code: int) -> None:
|
|
445
|
+
"""Set the mode of a specific sub device.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
gateway_id: The unique identifier of the gateway device.
|
|
449
|
+
device_rfid: The RFID of the sub device.
|
|
450
|
+
mode_code: The code representing the desired mode to set for the device.
|
|
451
|
+
|
|
452
|
+
Raises:
|
|
453
|
+
aiohttp.ClientError: If there is an HTTP error during the request.
|
|
454
|
+
aiohttp.ClientResponseError: If the server returns an error response.
|
|
455
|
+
|
|
456
|
+
"""
|
|
457
|
+
url = f"{API_BASE}/v1/devices/{gateway_id}/sub-devices/state"
|
|
458
|
+
headers = self.get_auth_headers()
|
|
459
|
+
payload = {
|
|
460
|
+
"parameters": {
|
|
461
|
+
device_rfid: {
|
|
462
|
+
"changeOverUser": mode_code
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async with self.session.patch(url, json=payload, headers=headers) as resp:
|
|
467
|
+
resp.raise_for_status()
|
|
468
|
+
|
|
469
|
+
async def set_device_program(self, device_id: str, program_data: dict) -> None:
|
|
470
|
+
"""Set the weekly program of a specific device.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
device_id: The unique identifier of the device.
|
|
474
|
+
program_data: The program data to set for the device.
|
|
475
|
+
|
|
476
|
+
Raises:
|
|
477
|
+
aiohttp.ClientError: If there is an HTTP error during the request.
|
|
478
|
+
aiohttp.ClientResponseError: If the server returns an error response.
|
|
479
|
+
|
|
480
|
+
"""
|
|
481
|
+
url = f"{API_BASE}/v1/devices/{device_id}/program"
|
|
482
|
+
headers = self.get_auth_headers()
|
|
483
|
+
payload = {
|
|
484
|
+
"data": program_data,
|
|
485
|
+
"redundancy": "weekly"
|
|
486
|
+
}
|
|
487
|
+
async with self.session.patch(url, json=payload, headers=headers) as resp:
|
|
488
|
+
resp.raise_for_status()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Utility functions for PyAxencoAPI"""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
def find_childs(devices: list[dict[str, Any]], parent_rfid: str) -> list[str]:
|
|
6
|
+
"""Find child devices by parent RFID.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
devices (list[dict[str, Any]]): List of device dictionaries.
|
|
10
|
+
parent_rfid (str): RFID of the parent device.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
list[str]: List of `_id` values of child devices.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
return [
|
|
17
|
+
device["_id"]
|
|
18
|
+
for device in devices
|
|
19
|
+
if "parents" in device and parent_rfid in device["parents"].split(",")
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_rfid_by_id(devices: list[dict[str, Any]], id: str) -> str:
|
|
24
|
+
"""Return the RFID of the device matching the given ID.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
devices (list[dict[str, Any]]): List of device dictionaries.
|
|
28
|
+
id (str): The ID of the device to search for.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
str: The RFID of the matching device, or an empty string if not found.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
for device in devices:
|
|
35
|
+
if device.get("_id") == id:
|
|
36
|
+
return device.get("rfid", "")
|
|
37
|
+
return ""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyaxencoapi
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Librairie d'utilisation de l'API REST/Websocket d'Axenco
|
|
5
|
+
Author-email: Léo Periou <leo.periou@axenco.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
11
|
+
Requires-Dist: python-socketio[asyncio_client]>=5.0.0
|
|
12
|
+
Requires-Dist: async-timeout>=4.0.2
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
16
|
+
Requires-Dist: aiohttp; extra == "dev"
|
|
17
|
+
Requires-Dist: aioresponses; extra == "dev"
|
|
18
|
+
Requires-Dist: python-socketio[asyncio_client]; extra == "dev"
|
|
19
|
+
Requires-Dist: coverage; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# PyAxencoAPI
|
|
24
|
+
|
|
25
|
+
Client Python asynchrone pour interagir avec les dispositifs connectés MyNeomitis via API REST/Websocket.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install pyaxencoapi
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/pyaxencoapi/__init__.py
|
|
5
|
+
src/pyaxencoapi/api.py
|
|
6
|
+
src/pyaxencoapi/utils.py
|
|
7
|
+
src/pyaxencoapi.egg-info/PKG-INFO
|
|
8
|
+
src/pyaxencoapi.egg-info/SOURCES.txt
|
|
9
|
+
src/pyaxencoapi.egg-info/dependency_links.txt
|
|
10
|
+
src/pyaxencoapi.egg-info/requires.txt
|
|
11
|
+
src/pyaxencoapi.egg-info/top_level.txt
|
|
12
|
+
tests/test_api.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyaxencoapi
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import pytest
|
|
3
|
+
import aiohttp
|
|
4
|
+
from aioresponses import aioresponses
|
|
5
|
+
from pyaxencoapi.api import PyAxencoAPI
|
|
6
|
+
from unittest.mock import AsyncMock, patch
|
|
7
|
+
|
|
8
|
+
API_BASE = "https://user-ep.imhotepcreation.com"
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
async def api_client():
|
|
12
|
+
async with aiohttp.ClientSession() as session:
|
|
13
|
+
api = PyAxencoAPI("sourceid_test", session)
|
|
14
|
+
yield api
|
|
15
|
+
|
|
16
|
+
@pytest.mark.asyncio
|
|
17
|
+
async def test_login_success(api_client):
|
|
18
|
+
with aioresponses() as m:
|
|
19
|
+
m.post(f"{API_BASE}/v1/auth/login", payload={
|
|
20
|
+
"token": "abc123",
|
|
21
|
+
"refresh_token": "def456",
|
|
22
|
+
"id": "user_001"
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
await api_client.login(email="test@example.com",password="securepassword")
|
|
26
|
+
assert api_client.token == "abc123"
|
|
27
|
+
assert api_client.refresh_token == "def456"
|
|
28
|
+
assert api_client.user_id == "user_001"
|
|
29
|
+
|
|
30
|
+
@pytest.mark.asyncio
|
|
31
|
+
async def test_login_invalid_response(api_client):
|
|
32
|
+
with aioresponses() as m:
|
|
33
|
+
m.post(f"{API_BASE}/v1/auth/login", payload={})
|
|
34
|
+
|
|
35
|
+
with pytest.raises(ValueError):
|
|
36
|
+
await api_client.login(email="test@example.com",password="securepassword")
|
|
37
|
+
|
|
38
|
+
@pytest.mark.asyncio
|
|
39
|
+
async def test_get_devices_cache(api_client):
|
|
40
|
+
api_client._devices_cache = [{"id": "1"}]
|
|
41
|
+
api_client._last_fetch = time.time()
|
|
42
|
+
devices = await api_client.get_devices()
|
|
43
|
+
assert devices == [{"id": "1"}]
|
|
44
|
+
|
|
45
|
+
@pytest.mark.asyncio
|
|
46
|
+
async def test_get_devices_http(api_client):
|
|
47
|
+
api_client.user_id = "user_001"
|
|
48
|
+
with aioresponses() as m:
|
|
49
|
+
m.get(f"{API_BASE}/v1/users/user_001/devices", payload=[{"id": "dev1"}])
|
|
50
|
+
devices = await api_client.get_devices(force=True)
|
|
51
|
+
assert devices == [{"id": "dev1"}]
|
|
52
|
+
|
|
53
|
+
@pytest.mark.asyncio
|
|
54
|
+
async def test_set_device_temperature(api_client):
|
|
55
|
+
api_client.token = "abc"
|
|
56
|
+
with aioresponses() as m:
|
|
57
|
+
m.patch(f"{API_BASE}/v1/devices/device123/state", status=200)
|
|
58
|
+
await api_client.set_device_temperature("device123", 22.5)
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_get_device_state_success(api_client):
|
|
62
|
+
api_client.token = "abc"
|
|
63
|
+
with aioresponses() as m:
|
|
64
|
+
m.get(f"{API_BASE}/v1/devices/device123", payload={"state": "on"})
|
|
65
|
+
state = await api_client.get_device_state("device123")
|
|
66
|
+
assert state == {"state": "on"}
|
|
67
|
+
|
|
68
|
+
@pytest.mark.asyncio
|
|
69
|
+
async def test_get_device_state_error(api_client):
|
|
70
|
+
api_client.token = "abc"
|
|
71
|
+
with aioresponses() as m:
|
|
72
|
+
m.get(f"{API_BASE}/v1/devices/device123", status=500)
|
|
73
|
+
result = await api_client.get_device_state("device123")
|
|
74
|
+
assert result is None
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_logout(api_client):
|
|
78
|
+
api_client.token = "abc"
|
|
79
|
+
api_client.refresh_token = "ref"
|
|
80
|
+
api_client.user_id = "uid"
|
|
81
|
+
with aioresponses() as m:
|
|
82
|
+
m.delete(f"{API_BASE}/v1/auth/logout", status=200)
|
|
83
|
+
await api_client.logout()
|
|
84
|
+
assert api_client.token is None
|
|
85
|
+
assert api_client.refresh_token is None
|
|
86
|
+
assert api_client.user_id is None
|
|
87
|
+
|
|
88
|
+
@pytest.mark.asyncio
|
|
89
|
+
async def test_notify_update_with_listener(api_client):
|
|
90
|
+
mock_cb = AsyncMock()
|
|
91
|
+
api_client._listeners["dev123"] = mock_cb
|
|
92
|
+
with patch("pyaxencoapi.api.get_rfid_by_id", return_value="rfid123"), \
|
|
93
|
+
patch("pyaxencoapi.api.find_childs", return_value=["child1"]), \
|
|
94
|
+
patch.object(api_client, "get_devices", return_value=[{"id": "dev123"}]):
|
|
95
|
+
api_client._listeners["child1"] = mock_cb
|
|
96
|
+
await api_client.notify_update("dev123", {"temp": 21})
|
|
97
|
+
assert mock_cb.call_count == 2
|