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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: controlid-sdk
3
- Version: 0.3.1
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 full diagnostic data from the device including uptime, memory usage, network details, and firmware version.
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 full diagnostic data from the device including uptime, memory usage, network details, and firmware version.
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.1"
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 ControlIDError, AuthenticationError, SessionError, APIError
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.1"
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.rstrip("/")
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
- response = await self._client.post(url, json=payload or {}, timeout=timeout)
180
- response.raise_for_status()
181
- return response.json()
182
- raise ex.APIError(
183
- f"API Error: {e.response.text}",
184
- status_code=e.response.status_code,
185
- response=e.response.text,
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.1
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 full diagnostic data from the device including uptime, memory usage, network details, and firmware version.
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,6 @@
1
+ httpx>=0.24.0
2
+ pydantic>=2.0.0
3
+
4
+ [dev]
5
+ pytest>=7.0.0
6
+ pytest-asyncio>=0.21.0
@@ -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
@@ -1,2 +0,0 @@
1
- httpx>=0.24.0
2
- pydantic>=2.0.0
File without changes