controlid-sdk 0.3.1__tar.gz → 0.3.3__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.1 → controlid_sdk-0.3.3}/PKG-INFO +26 -10
- {controlid_sdk-0.3.1 → controlid_sdk-0.3.3}/README.md +22 -9
- {controlid_sdk-0.3.1 → controlid_sdk-0.3.3}/pyproject.toml +11 -1
- {controlid_sdk-0.3.1 → controlid_sdk-0.3.3}/src/controlid/__init__.py +20 -2
- {controlid_sdk-0.3.1 → controlid_sdk-0.3.3}/src/controlid/client.py +60 -17
- controlid_sdk-0.3.3/src/controlid/exceptions.py +47 -0
- {controlid_sdk-0.3.1 → controlid_sdk-0.3.3}/src/controlid_sdk.egg-info/PKG-INFO +26 -10
- {controlid_sdk-0.3.1 → controlid_sdk-0.3.3}/src/controlid_sdk.egg-info/SOURCES.txt +3 -1
- controlid_sdk-0.3.3/src/controlid_sdk.egg-info/requires.txt +6 -0
- controlid_sdk-0.3.3/tests/test_client.py +98 -0
- controlid_sdk-0.3.3/tests/test_models.py +30 -0
- controlid_sdk-0.3.1/src/controlid/exceptions.py +0 -22
- controlid_sdk-0.3.1/src/controlid_sdk.egg-info/requires.txt +0 -2
- {controlid_sdk-0.3.1 → controlid_sdk-0.3.3}/setup.cfg +0 -0
- {controlid_sdk-0.3.1 → controlid_sdk-0.3.3}/src/controlid/constants.py +0 -0
- {controlid_sdk-0.3.1 → controlid_sdk-0.3.3}/src/controlid/models.py +0 -0
- {controlid_sdk-0.3.1 → controlid_sdk-0.3.3}/src/controlid_sdk.egg-info/dependency_links.txt +0 -0
- {controlid_sdk-0.3.1 → controlid_sdk-0.3.3}/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.3
|
|
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
|
|
@@ -20,6 +20,9 @@ Requires-Python: >=3.9
|
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
Requires-Dist: httpx>=0.24.0
|
|
22
22
|
Requires-Dist: pydantic>=2.0.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
23
26
|
|
|
24
27
|
# ControlID Python SDK (Async)
|
|
25
28
|
|
|
@@ -37,8 +40,7 @@ Built on top of `httpx` and `pydantic` v2, this SDK provides robust abstractions
|
|
|
37
40
|
- 📱 **QR Code Support**: Generate and register CRC-32 QR Code credentials safely.
|
|
38
41
|
- 🚪 **Advanced Hardware Control**: Interact with relays, GPIO pins, SecBoxes, turnstiles (catras), and ballot boxes.
|
|
39
42
|
- 📋 **Dynamic Schemas**: Native support for creating and interacting with custom device fields (`c_users` table).
|
|
40
|
-
|
|
41
|
-
## Installation
|
|
43
|
+
- 🔄 **Safe UUID Handling**: Built-in methods to serialize data seamlessly into strictly typed JSON structures (stringifying non-standard primitives) preventing pipeline execution failures in loosely coupled components.
|
|
42
44
|
|
|
43
45
|
```bash
|
|
44
46
|
pip install controlid-sdk
|
|
@@ -157,18 +159,22 @@ custom_data = await client.get_custom_user_data(user_id=10)
|
|
|
157
159
|
print(custom_data) # {'cpf': '123.456.789-00', 'user_id': 10}
|
|
158
160
|
```
|
|
159
161
|
|
|
160
|
-
### ⚙️ System Monitoring
|
|
161
|
-
Get
|
|
162
|
+
### ⚙️ System Monitoring & Control
|
|
163
|
+
Get diagnostic data and perform administrative hardware actions.
|
|
162
164
|
|
|
163
165
|
```python
|
|
164
|
-
# Fetch extensive system telemetry
|
|
166
|
+
# 1. Fetch extensive system telemetry
|
|
165
167
|
info = await client.get_system_information()
|
|
166
|
-
|
|
167
|
-
print(f"Uptime: {info['uptime']['days']} days")
|
|
168
|
-
print(f"Memory: {info['memory']['ram']['free']} / {info['memory']['ram']['total']} bytes")
|
|
169
|
-
print(f"MAC: {info['network']['mac']}")
|
|
170
168
|
print(f"Serial: {info['serial']}")
|
|
171
169
|
print(f"Version: {info['version']}")
|
|
170
|
+
|
|
171
|
+
# 2. Synchronize Clock (force device to use current UTC)
|
|
172
|
+
import time
|
|
173
|
+
await client.set_system_time(int(time.time()))
|
|
174
|
+
|
|
175
|
+
# 3. Administrative Actions
|
|
176
|
+
# await client.reboot()
|
|
177
|
+
# await client.generate_device_id() # Force cloud ID refresh
|
|
172
178
|
```
|
|
173
179
|
|
|
174
180
|
### 🕒 Time Zones & Access Schedules
|
|
@@ -189,6 +195,16 @@ await client.add_time_spans([
|
|
|
189
195
|
|
|
190
196
|
### 🧩 Generic Object API
|
|
191
197
|
|
|
198
|
+
## Firmware Compatibility Matrix
|
|
199
|
+
|
|
200
|
+
Since ControlID access control terminals run varying firmware versions, some API fields and tables may not be supported on all devices:
|
|
201
|
+
|
|
202
|
+
| Feature / Model | iDFace | iDAccess / iDFlex | iDBlock / Turnstiles | Notes |
|
|
203
|
+
| --- | --- | --- | --- | --- |
|
|
204
|
+
| **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`. |
|
|
205
|
+
| **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()`. |
|
|
206
|
+
| **Facial Templates** | ✅ Fully Supported | N/A | N/A | Enrollment and image upload functions require a device camera sensor. |
|
|
207
|
+
|
|
192
208
|
## Directory Structure
|
|
193
209
|
- `src/controlid/client.py`: Core asynchronous logic and endpoints.
|
|
194
210
|
- `src/controlid/models.py`: Pydantic V2 definitions.
|
|
@@ -14,8 +14,7 @@ Built on top of `httpx` and `pydantic` v2, this SDK provides robust abstractions
|
|
|
14
14
|
- 📱 **QR Code Support**: Generate and register CRC-32 QR Code credentials safely.
|
|
15
15
|
- 🚪 **Advanced Hardware Control**: Interact with relays, GPIO pins, SecBoxes, turnstiles (catras), and ballot boxes.
|
|
16
16
|
- 📋 **Dynamic Schemas**: Native support for creating and interacting with custom device fields (`c_users` table).
|
|
17
|
-
|
|
18
|
-
## Installation
|
|
17
|
+
- 🔄 **Safe UUID Handling**: Built-in methods to serialize data seamlessly into strictly typed JSON structures (stringifying non-standard primitives) preventing pipeline execution failures in loosely coupled components.
|
|
19
18
|
|
|
20
19
|
```bash
|
|
21
20
|
pip install controlid-sdk
|
|
@@ -134,18 +133,22 @@ custom_data = await client.get_custom_user_data(user_id=10)
|
|
|
134
133
|
print(custom_data) # {'cpf': '123.456.789-00', 'user_id': 10}
|
|
135
134
|
```
|
|
136
135
|
|
|
137
|
-
### ⚙️ System Monitoring
|
|
138
|
-
Get
|
|
136
|
+
### ⚙️ System Monitoring & Control
|
|
137
|
+
Get diagnostic data and perform administrative hardware actions.
|
|
139
138
|
|
|
140
139
|
```python
|
|
141
|
-
# Fetch extensive system telemetry
|
|
140
|
+
# 1. Fetch extensive system telemetry
|
|
142
141
|
info = await client.get_system_information()
|
|
143
|
-
|
|
144
|
-
print(f"Uptime: {info['uptime']['days']} days")
|
|
145
|
-
print(f"Memory: {info['memory']['ram']['free']} / {info['memory']['ram']['total']} bytes")
|
|
146
|
-
print(f"MAC: {info['network']['mac']}")
|
|
147
142
|
print(f"Serial: {info['serial']}")
|
|
148
143
|
print(f"Version: {info['version']}")
|
|
144
|
+
|
|
145
|
+
# 2. Synchronize Clock (force device to use current UTC)
|
|
146
|
+
import time
|
|
147
|
+
await client.set_system_time(int(time.time()))
|
|
148
|
+
|
|
149
|
+
# 3. Administrative Actions
|
|
150
|
+
# await client.reboot()
|
|
151
|
+
# await client.generate_device_id() # Force cloud ID refresh
|
|
149
152
|
```
|
|
150
153
|
|
|
151
154
|
### 🕒 Time Zones & Access Schedules
|
|
@@ -166,6 +169,16 @@ await client.add_time_spans([
|
|
|
166
169
|
|
|
167
170
|
### 🧩 Generic Object API
|
|
168
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
|
+
|
|
169
182
|
## Directory Structure
|
|
170
183
|
- `src/controlid/client.py`: Core asynchronous logic and endpoints.
|
|
171
184
|
- `src/controlid/models.py`: Pydantic V2 definitions.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "controlid-sdk"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.3"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Tulio Amancio", email="root@tsuriu.com.br" },
|
|
10
10
|
]
|
|
@@ -30,5 +30,15 @@ dependencies = [
|
|
|
30
30
|
"pydantic>=2.0.0",
|
|
31
31
|
]
|
|
32
32
|
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=7.0.0",
|
|
36
|
+
"pytest-asyncio>=0.21.0",
|
|
37
|
+
]
|
|
38
|
+
|
|
33
39
|
[project.urls]
|
|
34
40
|
"Homepage" = "https://github.com/tulioamancio/controlid-python-sdk"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
where = ["src"]
|
|
44
|
+
|
|
@@ -18,10 +18,21 @@ from .models import (
|
|
|
18
18
|
GPIOStatus,
|
|
19
19
|
CustomField,
|
|
20
20
|
)
|
|
21
|
-
from .exceptions import
|
|
21
|
+
from .exceptions import (
|
|
22
|
+
ControlIDError,
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
SessionError,
|
|
25
|
+
APIError,
|
|
26
|
+
BadRequestError,
|
|
27
|
+
UnauthorizedError,
|
|
28
|
+
ForbiddenError,
|
|
29
|
+
NotFoundError,
|
|
30
|
+
RateLimitError,
|
|
31
|
+
DeviceUnreachableError,
|
|
32
|
+
)
|
|
22
33
|
from . import constants
|
|
23
34
|
|
|
24
|
-
__version__ = "0.3.
|
|
35
|
+
__version__ = "0.3.3"
|
|
25
36
|
|
|
26
37
|
__all__ = [
|
|
27
38
|
"ControlIDClient",
|
|
@@ -46,6 +57,13 @@ __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
|
+
|
|
@@ -2,12 +2,31 @@ 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, urlparse
|
|
6
6
|
from . import constants as const
|
|
7
7
|
from . import exceptions as ex
|
|
8
8
|
from .models import User, Card, Door, AccessLog, UserRole, QRCard, CustomField, TimeZone, TimeSpan
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
def _normalize_host(host: str) -> str:
|
|
12
|
+
host = host.strip().rstrip("/")
|
|
13
|
+
proto = ""
|
|
14
|
+
if "://" in host:
|
|
15
|
+
proto, host = host.split("://", 1)
|
|
16
|
+
proto = proto + "://"
|
|
17
|
+
|
|
18
|
+
if host.startswith("["):
|
|
19
|
+
pass
|
|
20
|
+
elif host.count(":") > 1:
|
|
21
|
+
# IPv6 address without brackets
|
|
22
|
+
host = f"[{host}]"
|
|
23
|
+
|
|
24
|
+
if not proto:
|
|
25
|
+
proto = "https://"
|
|
26
|
+
|
|
27
|
+
return f"{proto}{host}"
|
|
28
|
+
|
|
29
|
+
|
|
11
30
|
class ControlIDClient:
|
|
12
31
|
"""
|
|
13
32
|
Asynchronous Python client for the ControlID Access Control REST API.
|
|
@@ -27,11 +46,7 @@ class ControlIDClient:
|
|
|
27
46
|
verify: bool = True,
|
|
28
47
|
on_host_change: Optional[Any] = None,
|
|
29
48
|
):
|
|
30
|
-
self.host = host
|
|
31
|
-
if "://" not in self.host:
|
|
32
|
-
# Default to https, fallback will handle it
|
|
33
|
-
self.host = f"https://{self.host}"
|
|
34
|
-
|
|
49
|
+
self.host = _normalize_host(host)
|
|
35
50
|
self.user = user
|
|
36
51
|
self.password = password
|
|
37
52
|
self.session: Optional[str] = None
|
|
@@ -43,7 +58,7 @@ class ControlIDClient:
|
|
|
43
58
|
# ─── Context Manager ───────────────────────────────────────────────────────
|
|
44
59
|
|
|
45
60
|
async def __aenter__(self) -> "ControlIDClient":
|
|
46
|
-
self._client = httpx.AsyncClient(timeout=self.timeout, verify=self.verify)
|
|
61
|
+
self._client = httpx.AsyncClient(timeout=self.timeout, verify=self.verify, trust_env=False)
|
|
47
62
|
return self
|
|
48
63
|
|
|
49
64
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
@@ -63,7 +78,7 @@ class ControlIDClient:
|
|
|
63
78
|
async def _ensure_client(self):
|
|
64
79
|
"""Create the httpx client if it doesn't exist or was closed."""
|
|
65
80
|
if self._client is None or self._client.is_closed:
|
|
66
|
-
self._client = httpx.AsyncClient(timeout=self.timeout, verify=self.verify)
|
|
81
|
+
self._client = httpx.AsyncClient(timeout=self.timeout, verify=self.verify, trust_env=False)
|
|
67
82
|
|
|
68
83
|
# ─── Authentication ────────────────────────────────────────────────────────
|
|
69
84
|
|
|
@@ -176,16 +191,44 @@ class ControlIDClient:
|
|
|
176
191
|
# Session expired — re-login and retry once
|
|
177
192
|
await self.login()
|
|
178
193
|
url = self._get_url(endpoint)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
194
|
+
try:
|
|
195
|
+
response = await self._client.post(url, json=payload or {}, timeout=timeout)
|
|
196
|
+
response.raise_for_status()
|
|
197
|
+
return response.json()
|
|
198
|
+
except httpx.HTTPStatusError as retry_e:
|
|
199
|
+
status_code = retry_e.response.status_code
|
|
200
|
+
msg = f"API Error: {retry_e.response.text}"
|
|
201
|
+
if status_code == 400:
|
|
202
|
+
raise ex.BadRequestError(msg, status_code=status_code, response=retry_e.response.text)
|
|
203
|
+
elif status_code == 401:
|
|
204
|
+
raise ex.UnauthorizedError(msg, status_code=status_code, response=retry_e.response.text)
|
|
205
|
+
elif status_code == 403:
|
|
206
|
+
raise ex.ForbiddenError(msg, status_code=status_code, response=retry_e.response.text)
|
|
207
|
+
elif status_code == 404:
|
|
208
|
+
raise ex.NotFoundError(msg, status_code=status_code, response=retry_e.response.text)
|
|
209
|
+
elif status_code == 429:
|
|
210
|
+
raise ex.RateLimitError(msg, status_code=status_code, response=retry_e.response.text)
|
|
211
|
+
else:
|
|
212
|
+
raise ex.APIError(msg, status_code=status_code, response=retry_e.response.text)
|
|
213
|
+
|
|
214
|
+
status_code = e.response.status_code
|
|
215
|
+
msg = f"API Error: {e.response.text}"
|
|
216
|
+
if status_code == 400:
|
|
217
|
+
raise ex.BadRequestError(msg, status_code=status_code, response=e.response.text)
|
|
218
|
+
elif status_code == 401:
|
|
219
|
+
raise ex.UnauthorizedError(msg, status_code=status_code, response=e.response.text)
|
|
220
|
+
elif status_code == 403:
|
|
221
|
+
raise ex.ForbiddenError(msg, status_code=status_code, response=e.response.text)
|
|
222
|
+
elif status_code == 404:
|
|
223
|
+
raise ex.NotFoundError(msg, status_code=status_code, response=e.response.text)
|
|
224
|
+
elif status_code == 429:
|
|
225
|
+
raise ex.RateLimitError(msg, status_code=status_code, response=e.response.text)
|
|
226
|
+
else:
|
|
227
|
+
raise ex.APIError(msg, status_code=status_code, response=e.response.text)
|
|
228
|
+
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e:
|
|
229
|
+
raise ex.DeviceUnreachableError(f"Device unreachable: {type(e).__name__} {e}")
|
|
187
230
|
except Exception as e:
|
|
188
|
-
if isinstance(e, (ex.APIError, ex.AuthenticationError)): raise
|
|
231
|
+
if isinstance(e, (ex.APIError, ex.AuthenticationError, ex.DeviceUnreachableError)): raise
|
|
189
232
|
raise ex.ControlIDError(f"Request failed: {type(e).__name__} {e}")
|
|
190
233
|
|
|
191
234
|
async def get_system_information(self) -> Dict[str, Any]:
|
|
@@ -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.3
|
|
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
|
|
@@ -20,6 +20,9 @@ Requires-Python: >=3.9
|
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
Requires-Dist: httpx>=0.24.0
|
|
22
22
|
Requires-Dist: pydantic>=2.0.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
23
26
|
|
|
24
27
|
# ControlID Python SDK (Async)
|
|
25
28
|
|
|
@@ -37,8 +40,7 @@ Built on top of `httpx` and `pydantic` v2, this SDK provides robust abstractions
|
|
|
37
40
|
- 📱 **QR Code Support**: Generate and register CRC-32 QR Code credentials safely.
|
|
38
41
|
- 🚪 **Advanced Hardware Control**: Interact with relays, GPIO pins, SecBoxes, turnstiles (catras), and ballot boxes.
|
|
39
42
|
- 📋 **Dynamic Schemas**: Native support for creating and interacting with custom device fields (`c_users` table).
|
|
40
|
-
|
|
41
|
-
## Installation
|
|
43
|
+
- 🔄 **Safe UUID Handling**: Built-in methods to serialize data seamlessly into strictly typed JSON structures (stringifying non-standard primitives) preventing pipeline execution failures in loosely coupled components.
|
|
42
44
|
|
|
43
45
|
```bash
|
|
44
46
|
pip install controlid-sdk
|
|
@@ -157,18 +159,22 @@ custom_data = await client.get_custom_user_data(user_id=10)
|
|
|
157
159
|
print(custom_data) # {'cpf': '123.456.789-00', 'user_id': 10}
|
|
158
160
|
```
|
|
159
161
|
|
|
160
|
-
### ⚙️ System Monitoring
|
|
161
|
-
Get
|
|
162
|
+
### ⚙️ System Monitoring & Control
|
|
163
|
+
Get diagnostic data and perform administrative hardware actions.
|
|
162
164
|
|
|
163
165
|
```python
|
|
164
|
-
# Fetch extensive system telemetry
|
|
166
|
+
# 1. Fetch extensive system telemetry
|
|
165
167
|
info = await client.get_system_information()
|
|
166
|
-
|
|
167
|
-
print(f"Uptime: {info['uptime']['days']} days")
|
|
168
|
-
print(f"Memory: {info['memory']['ram']['free']} / {info['memory']['ram']['total']} bytes")
|
|
169
|
-
print(f"MAC: {info['network']['mac']}")
|
|
170
168
|
print(f"Serial: {info['serial']}")
|
|
171
169
|
print(f"Version: {info['version']}")
|
|
170
|
+
|
|
171
|
+
# 2. Synchronize Clock (force device to use current UTC)
|
|
172
|
+
import time
|
|
173
|
+
await client.set_system_time(int(time.time()))
|
|
174
|
+
|
|
175
|
+
# 3. Administrative Actions
|
|
176
|
+
# await client.reboot()
|
|
177
|
+
# await client.generate_device_id() # Force cloud ID refresh
|
|
172
178
|
```
|
|
173
179
|
|
|
174
180
|
### 🕒 Time Zones & Access Schedules
|
|
@@ -189,6 +195,16 @@ await client.add_time_spans([
|
|
|
189
195
|
|
|
190
196
|
### 🧩 Generic Object API
|
|
191
197
|
|
|
198
|
+
## Firmware Compatibility Matrix
|
|
199
|
+
|
|
200
|
+
Since ControlID access control terminals run varying firmware versions, some API fields and tables may not be supported on all devices:
|
|
201
|
+
|
|
202
|
+
| Feature / Model | iDFace | iDAccess / iDFlex | iDBlock / Turnstiles | Notes |
|
|
203
|
+
| --- | --- | --- | --- | --- |
|
|
204
|
+
| **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`. |
|
|
205
|
+
| **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()`. |
|
|
206
|
+
| **Facial Templates** | ✅ Fully Supported | N/A | N/A | Enrollment and image upload functions require a device camera sensor. |
|
|
207
|
+
|
|
192
208
|
## Directory Structure
|
|
193
209
|
- `src/controlid/client.py`: Core asynchronous logic and endpoints.
|
|
194
210
|
- `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,98 @@
|
|
|
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")
|
|
@@ -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
|
|
File without changes
|