controlid-sdk 0.3.2__tar.gz → 0.3.4__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.
- {controlid_sdk-0.3.2 → controlid_sdk-0.3.4}/PKG-INFO +14 -2
- {controlid_sdk-0.3.2 → controlid_sdk-0.3.4}/README.md +10 -0
- {controlid_sdk-0.3.2 → controlid_sdk-0.3.4}/pyproject.toml +12 -3
- {controlid_sdk-0.3.2 → controlid_sdk-0.3.4}/src/controlid/__init__.py +22 -3
- {controlid_sdk-0.3.2 → controlid_sdk-0.3.4}/src/controlid/client.py +51 -17
- controlid_sdk-0.3.4/src/controlid/exceptions.py +47 -0
- {controlid_sdk-0.3.2 → controlid_sdk-0.3.4}/src/controlid/models.py +2 -2
- {controlid_sdk-0.3.2 → controlid_sdk-0.3.4}/src/controlid_sdk.egg-info/PKG-INFO +14 -2
- {controlid_sdk-0.3.2 → controlid_sdk-0.3.4}/src/controlid_sdk.egg-info/SOURCES.txt +3 -1
- controlid_sdk-0.3.4/src/controlid_sdk.egg-info/requires.txt +6 -0
- controlid_sdk-0.3.4/tests/test_client.py +109 -0
- controlid_sdk-0.3.4/tests/test_models.py +30 -0
- controlid_sdk-0.3.2/src/controlid/exceptions.py +0 -22
- controlid_sdk-0.3.2/src/controlid_sdk.egg-info/requires.txt +0 -2
- {controlid_sdk-0.3.2 → controlid_sdk-0.3.4}/setup.cfg +0 -0
- {controlid_sdk-0.3.2 → controlid_sdk-0.3.4}/src/controlid/constants.py +0 -0
- {controlid_sdk-0.3.2 → controlid_sdk-0.3.4}/src/controlid_sdk.egg-info/dependency_links.txt +0 -0
- {controlid_sdk-0.3.2 → controlid_sdk-0.3.4}/src/controlid_sdk.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlid-sdk
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: A production-ready asynchronous Python SDK for ControlID access control devices, supporting biometrics, networking, and complex time schedules.
|
|
5
5
|
Author-email: Tulio Amancio <root@tsuriu.com.br>
|
|
6
6
|
License: MIT
|
|
@@ -12,7 +12,6 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.10
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
16
15
|
Classifier: Operating System :: OS Independent
|
|
17
16
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
17
|
Classifier: Topic :: System :: Hardware
|
|
@@ -20,6 +19,9 @@ Requires-Python: >=3.9
|
|
|
20
19
|
Description-Content-Type: text/markdown
|
|
21
20
|
Requires-Dist: httpx>=0.24.0
|
|
22
21
|
Requires-Dist: pydantic>=2.0.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
23
25
|
|
|
24
26
|
# ControlID Python SDK (Async)
|
|
25
27
|
|
|
@@ -192,6 +194,16 @@ await client.add_time_spans([
|
|
|
192
194
|
|
|
193
195
|
### 🧩 Generic Object API
|
|
194
196
|
|
|
197
|
+
## Firmware Compatibility Matrix
|
|
198
|
+
|
|
199
|
+
Since ControlID access control terminals run varying firmware versions, some API fields and tables may not be supported on all devices:
|
|
200
|
+
|
|
201
|
+
| Feature / Model | iDFace | iDAccess / iDFlex | iDBlock / Turnstiles | Notes |
|
|
202
|
+
| --- | --- | --- | --- | --- |
|
|
203
|
+
| **Card.type** | ⚠️ Supported only on newer firmware | ⚠️ Supported only on newer firmware | N/A | Older firmwares lack the `type` column in the `cards` table. Exclude it by leaving `Card.type = None`. |
|
|
204
|
+
| **Custom Database Fields** | ✅ Fully Supported | ⚠️ Depends on firmware version | N/A | Add custom fields (e.g. `cpf` to `c_users`) via `client.add_custom_field()`. |
|
|
205
|
+
| **Facial Templates** | ✅ Fully Supported | N/A | N/A | Enrollment and image upload functions require a device camera sensor. |
|
|
206
|
+
|
|
195
207
|
## Directory Structure
|
|
196
208
|
- `src/controlid/client.py`: Core asynchronous logic and endpoints.
|
|
197
209
|
- `src/controlid/models.py`: Pydantic V2 definitions.
|
|
@@ -169,6 +169,16 @@ await client.add_time_spans([
|
|
|
169
169
|
|
|
170
170
|
### 🧩 Generic Object API
|
|
171
171
|
|
|
172
|
+
## Firmware Compatibility Matrix
|
|
173
|
+
|
|
174
|
+
Since ControlID access control terminals run varying firmware versions, some API fields and tables may not be supported on all devices:
|
|
175
|
+
|
|
176
|
+
| Feature / Model | iDFace | iDAccess / iDFlex | iDBlock / Turnstiles | Notes |
|
|
177
|
+
| --- | --- | --- | --- | --- |
|
|
178
|
+
| **Card.type** | ⚠️ Supported only on newer firmware | ⚠️ Supported only on newer firmware | N/A | Older firmwares lack the `type` column in the `cards` table. Exclude it by leaving `Card.type = None`. |
|
|
179
|
+
| **Custom Database Fields** | ✅ Fully Supported | ⚠️ Depends on firmware version | N/A | Add custom fields (e.g. `cpf` to `c_users`) via `client.add_custom_field()`. |
|
|
180
|
+
| **Facial Templates** | ✅ Fully Supported | N/A | N/A | Enrollment and image upload functions require a device camera sensor. |
|
|
181
|
+
|
|
172
182
|
## Directory Structure
|
|
173
183
|
- `src/controlid/client.py`: Core asynchronous logic and endpoints.
|
|
174
184
|
- `src/controlid/models.py`: Pydantic V2 definitions.
|
|
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "controlid-sdk"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Tulio Amancio", email="root@tsuriu.com.br" },
|
|
10
10
|
]
|
|
11
11
|
description = "A production-ready asynchronous Python SDK for ControlID access control devices, supporting biometrics, networking, and complex time schedules."
|
|
12
12
|
readme = "README.md"
|
|
13
|
-
license = {
|
|
13
|
+
license = {text = "MIT"}
|
|
14
14
|
requires-python = ">=3.9"
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 4 - Beta",
|
|
@@ -20,7 +20,6 @@ classifiers = [
|
|
|
20
20
|
"Programming Language :: Python :: 3.10",
|
|
21
21
|
"Programming Language :: Python :: 3.11",
|
|
22
22
|
"Programming Language :: Python :: 3.12",
|
|
23
|
-
"License :: OSI Approved :: MIT License",
|
|
24
23
|
"Operating System :: OS Independent",
|
|
25
24
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
25
|
"Topic :: System :: Hardware",
|
|
@@ -30,5 +29,15 @@ dependencies = [
|
|
|
30
29
|
"pydantic>=2.0.0",
|
|
31
30
|
]
|
|
32
31
|
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=7.0.0",
|
|
35
|
+
"pytest-asyncio>=0.21.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
33
38
|
[project.urls]
|
|
34
39
|
"Homepage" = "https://github.com/tulioamancio/controlid-python-sdk"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
43
|
+
|
|
@@ -11,17 +11,27 @@ from .models import (
|
|
|
11
11
|
Door,
|
|
12
12
|
AccessLog,
|
|
13
13
|
Device,
|
|
14
|
-
TimeZone,
|
|
15
14
|
TimeSpan,
|
|
16
15
|
FaceTemplate,
|
|
17
16
|
CatraInfo,
|
|
18
17
|
GPIOStatus,
|
|
19
18
|
CustomField,
|
|
20
19
|
)
|
|
21
|
-
from .exceptions import
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
ControlIDError,
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
SessionError,
|
|
24
|
+
APIError,
|
|
25
|
+
BadRequestError,
|
|
26
|
+
UnauthorizedError,
|
|
27
|
+
ForbiddenError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
RateLimitError,
|
|
30
|
+
DeviceUnreachableError,
|
|
31
|
+
)
|
|
22
32
|
from . import constants
|
|
23
33
|
|
|
24
|
-
__version__ = "0.3.
|
|
34
|
+
__version__ = "0.3.4"
|
|
25
35
|
|
|
26
36
|
__all__ = [
|
|
27
37
|
"ControlIDClient",
|
|
@@ -33,6 +43,7 @@ __all__ = [
|
|
|
33
43
|
"UserGroup",
|
|
34
44
|
"AccessRule",
|
|
35
45
|
"TimeZone",
|
|
46
|
+
"TimeSpan",
|
|
36
47
|
"Area",
|
|
37
48
|
"Door",
|
|
38
49
|
"AccessLog",
|
|
@@ -46,6 +57,14 @@ __all__ = [
|
|
|
46
57
|
"AuthenticationError",
|
|
47
58
|
"SessionError",
|
|
48
59
|
"APIError",
|
|
60
|
+
"BadRequestError",
|
|
61
|
+
"UnauthorizedError",
|
|
62
|
+
"ForbiddenError",
|
|
63
|
+
"NotFoundError",
|
|
64
|
+
"RateLimitError",
|
|
65
|
+
"DeviceUnreachableError",
|
|
49
66
|
# Constants module (handy for card type enums, table names, etc.)
|
|
50
67
|
"constants",
|
|
51
68
|
]
|
|
69
|
+
|
|
70
|
+
|
|
@@ -2,10 +2,11 @@ import httpx
|
|
|
2
2
|
import asyncio
|
|
3
3
|
import time as _time
|
|
4
4
|
from typing import List, Dict, Any, Optional, Union
|
|
5
|
-
from urllib.parse import quote
|
|
5
|
+
from urllib.parse import quote
|
|
6
6
|
from . import constants as const
|
|
7
7
|
from . import exceptions as ex
|
|
8
|
-
from .models import User, Card,
|
|
8
|
+
from .models import User, Card, AccessLog, UserRole, CustomField, TimeZone, TimeSpan
|
|
9
|
+
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def _normalize_host(host: str) -> str:
|
|
@@ -107,14 +108,12 @@ class ControlIDClient:
|
|
|
107
108
|
payload = {"login": self.user, "password": self.password}
|
|
108
109
|
|
|
109
110
|
try:
|
|
110
|
-
logger_ctx = {"host": try_host, "user": self.user}
|
|
111
111
|
response = await self._client.post(url, json=payload, timeout=5.0) # Short timeout for login attempt
|
|
112
112
|
response.raise_for_status()
|
|
113
113
|
data = response.json()
|
|
114
114
|
if "session" in data:
|
|
115
115
|
self.session = data["session"]
|
|
116
116
|
if self.host != try_host:
|
|
117
|
-
old_host = self.host
|
|
118
117
|
self.host = try_host # Update to working host
|
|
119
118
|
if self.on_host_change:
|
|
120
119
|
try:
|
|
@@ -122,14 +121,16 @@ class ControlIDClient:
|
|
|
122
121
|
asyncio.create_task(self.on_host_change(self.host))
|
|
123
122
|
else:
|
|
124
123
|
self.on_host_change(self.host)
|
|
125
|
-
except Exception:
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
126
|
return self.session
|
|
127
127
|
raise ex.AuthenticationError(f"Login failed: {data}")
|
|
128
128
|
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout, httpx.ProtocolError, httpx.RemoteProtocolError, httpx.ProxyError) as e:
|
|
129
129
|
last_err = f"{type(e).__name__} on {proto}"
|
|
130
130
|
continue
|
|
131
131
|
except Exception as e:
|
|
132
|
-
if isinstance(e, ex.AuthenticationError):
|
|
132
|
+
if isinstance(e, ex.AuthenticationError):
|
|
133
|
+
raise
|
|
133
134
|
last_err = f"{type(e).__name__}: {str(e)}"
|
|
134
135
|
continue
|
|
135
136
|
|
|
@@ -191,16 +192,45 @@ class ControlIDClient:
|
|
|
191
192
|
# Session expired — re-login and retry once
|
|
192
193
|
await self.login()
|
|
193
194
|
url = self._get_url(endpoint)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
195
|
+
try:
|
|
196
|
+
response = await self._client.post(url, json=payload or {}, timeout=timeout)
|
|
197
|
+
response.raise_for_status()
|
|
198
|
+
return response.json()
|
|
199
|
+
except httpx.HTTPStatusError as retry_e:
|
|
200
|
+
status_code = retry_e.response.status_code
|
|
201
|
+
msg = f"API Error: {retry_e.response.text}"
|
|
202
|
+
if status_code == 400:
|
|
203
|
+
raise ex.BadRequestError(msg, status_code=status_code, response=retry_e.response.text)
|
|
204
|
+
elif status_code == 401:
|
|
205
|
+
raise ex.UnauthorizedError(msg, status_code=status_code, response=retry_e.response.text)
|
|
206
|
+
elif status_code == 403:
|
|
207
|
+
raise ex.ForbiddenError(msg, status_code=status_code, response=retry_e.response.text)
|
|
208
|
+
elif status_code == 404:
|
|
209
|
+
raise ex.NotFoundError(msg, status_code=status_code, response=retry_e.response.text)
|
|
210
|
+
elif status_code == 429:
|
|
211
|
+
raise ex.RateLimitError(msg, status_code=status_code, response=retry_e.response.text)
|
|
212
|
+
else:
|
|
213
|
+
raise ex.APIError(msg, status_code=status_code, response=retry_e.response.text)
|
|
214
|
+
|
|
215
|
+
status_code = e.response.status_code
|
|
216
|
+
msg = f"API Error: {e.response.text}"
|
|
217
|
+
if status_code == 400:
|
|
218
|
+
raise ex.BadRequestError(msg, status_code=status_code, response=e.response.text)
|
|
219
|
+
elif status_code == 401:
|
|
220
|
+
raise ex.UnauthorizedError(msg, status_code=status_code, response=e.response.text)
|
|
221
|
+
elif status_code == 403:
|
|
222
|
+
raise ex.ForbiddenError(msg, status_code=status_code, response=e.response.text)
|
|
223
|
+
elif status_code == 404:
|
|
224
|
+
raise ex.NotFoundError(msg, status_code=status_code, response=e.response.text)
|
|
225
|
+
elif status_code == 429:
|
|
226
|
+
raise ex.RateLimitError(msg, status_code=status_code, response=e.response.text)
|
|
227
|
+
else:
|
|
228
|
+
raise ex.APIError(msg, status_code=status_code, response=e.response.text)
|
|
229
|
+
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e:
|
|
230
|
+
raise ex.DeviceUnreachableError(f"Device unreachable: {type(e).__name__} {e}")
|
|
202
231
|
except Exception as e:
|
|
203
|
-
if isinstance(e, (ex.APIError, ex.AuthenticationError)):
|
|
232
|
+
if isinstance(e, (ex.APIError, ex.AuthenticationError, ex.DeviceUnreachableError)):
|
|
233
|
+
raise
|
|
204
234
|
raise ex.ControlIDError(f"Request failed: {type(e).__name__} {e}")
|
|
205
235
|
|
|
206
236
|
async def get_system_information(self) -> Dict[str, Any]:
|
|
@@ -342,8 +372,10 @@ class ControlIDClient:
|
|
|
342
372
|
async def get_cards(self, user_id: Optional[int] = None, value: Optional[int] = None) -> List[Card]:
|
|
343
373
|
"""Return credentials for all users, or filtered by user_id or card value."""
|
|
344
374
|
where = {}
|
|
345
|
-
if user_id:
|
|
346
|
-
|
|
375
|
+
if user_id:
|
|
376
|
+
where["user_id"] = user_id
|
|
377
|
+
if value:
|
|
378
|
+
where["value"] = value
|
|
347
379
|
return [Card(**c) for c in await self.load_objects(const.TABLE_CARDS, where=where or None)]
|
|
348
380
|
|
|
349
381
|
async def add_card(self, card: Card) -> int:
|
|
@@ -739,6 +771,8 @@ class ControlIDClient:
|
|
|
739
771
|
|
|
740
772
|
await client.set_configuration({"face_id": {"qrcode_legacy_mode_enabled": "1"}})
|
|
741
773
|
"""
|
|
774
|
+
return await self.request(const.SET_CONFIGURATION, config)
|
|
775
|
+
|
|
742
776
|
async def export_audit_logs(self, config: int = 1, api: int = 1, usb: int = 0, network: int = 0, time: int = 1, online: int = 0, menu: int = 1) -> List[Dict[str, Any]]:
|
|
743
777
|
"""
|
|
744
778
|
Export device audit logs (system events, config changes, etc.).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
class ControlIDError(Exception):
|
|
2
|
+
"""Base exception for ControlID SDK."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
class AuthenticationError(ControlIDError):
|
|
6
|
+
"""Raised when authentication fails."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class SessionError(ControlIDError):
|
|
10
|
+
"""Raised when there is an issue with the session."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class APIError(ControlIDError):
|
|
14
|
+
"""Raised when the API returns an error response."""
|
|
15
|
+
def __init__(self, message, status_code=None, response=None):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.response = response
|
|
19
|
+
|
|
20
|
+
class BadRequestError(APIError):
|
|
21
|
+
"""Raised when the API returns a 400 Bad Request error."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
class UnauthorizedError(APIError):
|
|
25
|
+
"""Raised when the API returns a 401 Unauthorized error."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
class ForbiddenError(APIError):
|
|
29
|
+
"""Raised when the API returns a 403 Forbidden error."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
class NotFoundError(APIError):
|
|
33
|
+
"""Raised when the API returns a 404 Not Found error."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
class RateLimitError(APIError):
|
|
37
|
+
"""Raised when the API returns a 429 Too Many Requests error."""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
class DeviceUnreachableError(ControlIDError):
|
|
41
|
+
"""Raised when the device is unreachable (e.g. connection timeout or dns failure)."""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
class ObjectError(ControlIDError):
|
|
45
|
+
"""Raised when an object operation fails."""
|
|
46
|
+
pass
|
|
47
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlid-sdk
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: A production-ready asynchronous Python SDK for ControlID access control devices, supporting biometrics, networking, and complex time schedules.
|
|
5
5
|
Author-email: Tulio Amancio <root@tsuriu.com.br>
|
|
6
6
|
License: MIT
|
|
@@ -12,7 +12,6 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.10
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
16
15
|
Classifier: Operating System :: OS Independent
|
|
17
16
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
17
|
Classifier: Topic :: System :: Hardware
|
|
@@ -20,6 +19,9 @@ Requires-Python: >=3.9
|
|
|
20
19
|
Description-Content-Type: text/markdown
|
|
21
20
|
Requires-Dist: httpx>=0.24.0
|
|
22
21
|
Requires-Dist: pydantic>=2.0.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
23
25
|
|
|
24
26
|
# ControlID Python SDK (Async)
|
|
25
27
|
|
|
@@ -192,6 +194,16 @@ await client.add_time_spans([
|
|
|
192
194
|
|
|
193
195
|
### 🧩 Generic Object API
|
|
194
196
|
|
|
197
|
+
## Firmware Compatibility Matrix
|
|
198
|
+
|
|
199
|
+
Since ControlID access control terminals run varying firmware versions, some API fields and tables may not be supported on all devices:
|
|
200
|
+
|
|
201
|
+
| Feature / Model | iDFace | iDAccess / iDFlex | iDBlock / Turnstiles | Notes |
|
|
202
|
+
| --- | --- | --- | --- | --- |
|
|
203
|
+
| **Card.type** | ⚠️ Supported only on newer firmware | ⚠️ Supported only on newer firmware | N/A | Older firmwares lack the `type` column in the `cards` table. Exclude it by leaving `Card.type = None`. |
|
|
204
|
+
| **Custom Database Fields** | ✅ Fully Supported | ⚠️ Depends on firmware version | N/A | Add custom fields (e.g. `cpf` to `c_users`) via `client.add_custom_field()`. |
|
|
205
|
+
| **Facial Templates** | ✅ Fully Supported | N/A | N/A | Enrollment and image upload functions require a device camera sensor. |
|
|
206
|
+
|
|
195
207
|
## Directory Structure
|
|
196
208
|
- `src/controlid/client.py`: Core asynchronous logic and endpoints.
|
|
197
209
|
- `src/controlid/models.py`: Pydantic V2 definitions.
|
|
@@ -9,4 +9,6 @@ src/controlid_sdk.egg-info/PKG-INFO
|
|
|
9
9
|
src/controlid_sdk.egg-info/SOURCES.txt
|
|
10
10
|
src/controlid_sdk.egg-info/dependency_links.txt
|
|
11
11
|
src/controlid_sdk.egg-info/requires.txt
|
|
12
|
-
src/controlid_sdk.egg-info/top_level.txt
|
|
12
|
+
src/controlid_sdk.egg-info/top_level.txt
|
|
13
|
+
tests/test_client.py
|
|
14
|
+
tests/test_models.py
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unittest.mock import MagicMock, AsyncMock, patch
|
|
3
|
+
import httpx
|
|
4
|
+
from controlid import ControlIDClient
|
|
5
|
+
from controlid.exceptions import (
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
BadRequestError,
|
|
8
|
+
UnauthorizedError,
|
|
9
|
+
ForbiddenError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
DeviceUnreachableError,
|
|
13
|
+
APIError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
@pytest.mark.asyncio
|
|
17
|
+
async def test_client_login_success():
|
|
18
|
+
client = ControlIDClient(host="https://192.168.0.100", user="admin", password="password")
|
|
19
|
+
|
|
20
|
+
mock_response = MagicMock(spec=httpx.Response)
|
|
21
|
+
mock_response.status_code = 200
|
|
22
|
+
mock_response.json = MagicMock(return_value={"session": "test-session-token"})
|
|
23
|
+
mock_response.raise_for_status = MagicMock()
|
|
24
|
+
|
|
25
|
+
with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post:
|
|
26
|
+
session = await client.login()
|
|
27
|
+
assert session == "test-session-token"
|
|
28
|
+
assert client.session == "test-session-token"
|
|
29
|
+
mock_post.assert_called_once()
|
|
30
|
+
|
|
31
|
+
@pytest.mark.asyncio
|
|
32
|
+
async def test_client_login_failure():
|
|
33
|
+
client = ControlIDClient(host="https://192.168.0.100", user="admin", password="password")
|
|
34
|
+
|
|
35
|
+
mock_response = MagicMock(spec=httpx.Response)
|
|
36
|
+
mock_response.status_code = 401
|
|
37
|
+
mock_response.json = MagicMock(return_value={"error": "Invalid credentials"})
|
|
38
|
+
mock_response.raise_for_status = MagicMock()
|
|
39
|
+
|
|
40
|
+
with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response):
|
|
41
|
+
with pytest.raises(AuthenticationError):
|
|
42
|
+
await client.login()
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_client_request_injects_session():
|
|
46
|
+
client = ControlIDClient(host="https://192.168.0.100", user="admin", password="password")
|
|
47
|
+
client.session = "active-session"
|
|
48
|
+
|
|
49
|
+
mock_response = MagicMock(spec=httpx.Response)
|
|
50
|
+
mock_response.status_code = 200
|
|
51
|
+
mock_response.json = MagicMock(return_value={"status": "success"})
|
|
52
|
+
mock_response.raise_for_status = MagicMock()
|
|
53
|
+
|
|
54
|
+
await client._ensure_client()
|
|
55
|
+
|
|
56
|
+
with patch.object(client._client, "post", new_callable=AsyncMock, return_value=mock_response) as mock_post:
|
|
57
|
+
res = await client.request("/some-endpoint", {"param": "val"})
|
|
58
|
+
assert res == {"status": "success"}
|
|
59
|
+
called_url = mock_post.call_args[0][0]
|
|
60
|
+
assert "session=active-session" in called_url
|
|
61
|
+
|
|
62
|
+
@pytest.mark.asyncio
|
|
63
|
+
async def test_client_request_handles_status_errors():
|
|
64
|
+
client = ControlIDClient(host="https://192.168.0.100")
|
|
65
|
+
client.session = "active-session"
|
|
66
|
+
await client._ensure_client()
|
|
67
|
+
|
|
68
|
+
error_cases = [
|
|
69
|
+
(400, BadRequestError),
|
|
70
|
+
(401, UnauthorizedError),
|
|
71
|
+
(403, ForbiddenError),
|
|
72
|
+
(404, NotFoundError),
|
|
73
|
+
(429, RateLimitError),
|
|
74
|
+
(500, APIError),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
for code, exc_class in error_cases:
|
|
78
|
+
mock_response = httpx.Response(status_code=code, text=f"Error {code}")
|
|
79
|
+
|
|
80
|
+
if code == 401:
|
|
81
|
+
with patch.object(client, "login", new_callable=AsyncMock, return_value="new-token"):
|
|
82
|
+
with patch.object(client._client, "post", new_callable=AsyncMock, side_effect=httpx.HTTPStatusError("Err", request=None, response=mock_response)):
|
|
83
|
+
with pytest.raises(exc_class):
|
|
84
|
+
await client.request("/some-endpoint")
|
|
85
|
+
else:
|
|
86
|
+
with patch.object(client._client, "post", new_callable=AsyncMock, side_effect=httpx.HTTPStatusError("Err", request=None, response=mock_response)):
|
|
87
|
+
with pytest.raises(exc_class):
|
|
88
|
+
await client.request("/some-endpoint")
|
|
89
|
+
|
|
90
|
+
@pytest.mark.asyncio
|
|
91
|
+
async def test_client_request_device_unreachable():
|
|
92
|
+
client = ControlIDClient(host="https://192.168.0.100")
|
|
93
|
+
client.session = "active-session"
|
|
94
|
+
await client._ensure_client()
|
|
95
|
+
|
|
96
|
+
with patch.object(client._client, "post", new_callable=AsyncMock, side_effect=httpx.ConnectError("Connection refused")):
|
|
97
|
+
with pytest.raises(DeviceUnreachableError):
|
|
98
|
+
await client.request("/some-endpoint")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.asyncio
|
|
102
|
+
async def test_set_configuration_calls_hardware_endpoint():
|
|
103
|
+
client = ControlIDClient(host="https://192.168.0.100")
|
|
104
|
+
payload = {"snmp_agent": {"snmp_enabled": "1"}}
|
|
105
|
+
with patch.object(client, "request", new_callable=AsyncMock, return_value={"status": "ok"}) as request:
|
|
106
|
+
result = await client.set_configuration(payload)
|
|
107
|
+
|
|
108
|
+
request.assert_awaited_once_with("/set_configuration.fcgi", payload)
|
|
109
|
+
assert result == {"status": "ok"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from controlid.models import User, Card, TimeZone, TimeSpan
|
|
2
|
+
|
|
3
|
+
def test_user_model():
|
|
4
|
+
user = User(id=1, name="John Doe", registration="12345")
|
|
5
|
+
assert user.id == 1
|
|
6
|
+
assert user.name == "John Doe"
|
|
7
|
+
assert user.registration == "12345"
|
|
8
|
+
assert user.password == ""
|
|
9
|
+
|
|
10
|
+
def test_card_model():
|
|
11
|
+
card1 = Card(id=10, value=123456, user_id=1, type=0)
|
|
12
|
+
assert card1.id == 10
|
|
13
|
+
assert card1.value == 123456
|
|
14
|
+
assert card1.user_id == 1
|
|
15
|
+
assert card1.type == 0
|
|
16
|
+
|
|
17
|
+
# Exclude_none test
|
|
18
|
+
card2 = Card(value=654321, user_id=2)
|
|
19
|
+
assert card2.type is None
|
|
20
|
+
dumped = card2.model_dump(exclude_none=True)
|
|
21
|
+
assert "type" not in dumped
|
|
22
|
+
|
|
23
|
+
def test_timezone_and_timespan():
|
|
24
|
+
tz = TimeZone(id=1, name="Standard")
|
|
25
|
+
assert tz.name == "Standard"
|
|
26
|
+
|
|
27
|
+
span = TimeSpan(time_zone_id=1, start=28800, end=64800, mon=1, tue=1)
|
|
28
|
+
assert span.start == 28800
|
|
29
|
+
assert span.mon == 1
|
|
30
|
+
assert span.sun == 0
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
class ControlIDError(Exception):
|
|
2
|
-
"""Base exception for ControlID SDK."""
|
|
3
|
-
pass
|
|
4
|
-
|
|
5
|
-
class AuthenticationError(ControlIDError):
|
|
6
|
-
"""Raised when authentication fails."""
|
|
7
|
-
pass
|
|
8
|
-
|
|
9
|
-
class SessionError(ControlIDError):
|
|
10
|
-
"""Raised when there is an issue with the session."""
|
|
11
|
-
pass
|
|
12
|
-
|
|
13
|
-
class APIError(ControlIDError):
|
|
14
|
-
"""Raised when the API returns an error response."""
|
|
15
|
-
def __init__(self, message, status_code=None, response=None):
|
|
16
|
-
super().__init__(message)
|
|
17
|
-
self.status_code = status_code
|
|
18
|
-
self.response = response
|
|
19
|
-
|
|
20
|
-
class ObjectError(ControlIDError):
|
|
21
|
-
"""Raised when an object operation fails."""
|
|
22
|
-
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|