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.
@@ -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,8 @@
1
+ # PyAxencoAPI
2
+
3
+ Client Python asynchrone pour interagir avec les dispositifs connectés MyNeomitis via API REST/Websocket.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from .api import PyAxencoAPI
2
+
3
+ __all__ = ["PyAxencoAPI"]
@@ -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,12 @@
1
+ aiohttp>=3.8.0
2
+ python-socketio[asyncio_client]>=5.0.0
3
+ async-timeout>=4.0.2
4
+
5
+ [dev]
6
+ pytest
7
+ pytest-asyncio
8
+ aiohttp
9
+ aioresponses
10
+ python-socketio[asyncio_client]
11
+ coverage
12
+ pytest-cov
@@ -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