controlid-sdk 0.1.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.
- controlid_sdk-0.1.0/PKG-INFO +174 -0
- controlid_sdk-0.1.0/README.md +160 -0
- controlid_sdk-0.1.0/pyproject.toml +25 -0
- controlid_sdk-0.1.0/setup.cfg +4 -0
- controlid_sdk-0.1.0/src/controlid/__init__.py +49 -0
- controlid_sdk-0.1.0/src/controlid/client.py +514 -0
- controlid_sdk-0.1.0/src/controlid/constants.py +69 -0
- controlid_sdk-0.1.0/src/controlid/exceptions.py +22 -0
- controlid_sdk-0.1.0/src/controlid/models.py +132 -0
- controlid_sdk-0.1.0/src/controlid_sdk.egg-info/PKG-INFO +174 -0
- controlid_sdk-0.1.0/src/controlid_sdk.egg-info/SOURCES.txt +12 -0
- controlid_sdk-0.1.0/src/controlid_sdk.egg-info/dependency_links.txt +1 -0
- controlid_sdk-0.1.0/src/controlid_sdk.egg-info/requires.txt +2 -0
- controlid_sdk-0.1.0/src/controlid_sdk.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: controlid-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python SDK for ControlID access control devices.
|
|
5
|
+
Author-email: Tulio Amancio <root@tsuriu.com.br>
|
|
6
|
+
Project-URL: Homepage, https://github.com/tulioamancio/controlid-python-sdk
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: httpx>=0.24.0
|
|
13
|
+
Requires-Dist: pydantic>=2.0.0
|
|
14
|
+
|
|
15
|
+
# ControlID Python SDK (Async)
|
|
16
|
+
|
|
17
|
+
A production-ready, high-performance asynchronous Python SDK for ControlID access control devices (iDAccess, iDFace, iDFlex, iDBlock, etc.).
|
|
18
|
+
|
|
19
|
+
Built on top of `httpx` and `pydantic` v2, this SDK provides robust abstractions over the ControlID API, gracefully handling undocumented firmware quirks, session management, and complex data schemas.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- ⚡️ **Fully Asynchronous**: Built exclusively on `async/await` and `httpx` for high-concurrency non-blocking I/O.
|
|
24
|
+
- 🔑 **Automatic Session Management**: Handles logins and injects secure, URL-encoded tokens into requests.
|
|
25
|
+
- 🛡️ **Pydantic Validation**: Robust, type-hinted data models (`User`, `Card`, `AccessRule`, etc.).
|
|
26
|
+
- 🛠️ **Quirk Resolution**: Automatically manages device-specific API quirks (e.g., nested `where` clauses, strict `modify_objects` routing, implicit schema limitations).
|
|
27
|
+
- 📸 **Facial Recognition**: Push direct binary payload image uploads for iDFace enrollment.
|
|
28
|
+
- 📱 **QR Code Support**: Generate and register CRC-32 QR Code credentials safely.
|
|
29
|
+
- 🚪 **Advanced Hardware Control**: Interact with relays, GPIO pins, SecBoxes, turnstiles (catras), and ballot boxes.
|
|
30
|
+
- 📋 **Dynamic Schemas**: Native support for creating and interacting with custom device fields (`c_users` table).
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install .
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import asyncio
|
|
42
|
+
from controlid import ControlIDClient, User
|
|
43
|
+
|
|
44
|
+
async def main():
|
|
45
|
+
# Use as an async context manager for automatic session cleanup.
|
|
46
|
+
# We use verify=False typically because devices use self-signed SSL certificates.
|
|
47
|
+
async with ControlIDClient(
|
|
48
|
+
host="https://192.168.0.100",
|
|
49
|
+
user="admin",
|
|
50
|
+
password="password",
|
|
51
|
+
verify=False
|
|
52
|
+
) as client:
|
|
53
|
+
|
|
54
|
+
# 1. Fetch Users
|
|
55
|
+
users = await client.get_users()
|
|
56
|
+
print(f"Found {len(users)} users on device.")
|
|
57
|
+
|
|
58
|
+
# 2. Add a new user
|
|
59
|
+
user_id = await client.add_user(User(name="Jane Doe", registration="EMP-001"))
|
|
60
|
+
print(f"Created user with ID: {user_id}")
|
|
61
|
+
|
|
62
|
+
# 3. Open a Door (using internal relay)
|
|
63
|
+
await client.open_door(door_id=1)
|
|
64
|
+
|
|
65
|
+
# Or using an external SecBox (standard on iDFace/iDFlex installations)
|
|
66
|
+
# await client.open_sec_box(box_id=65793)
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
asyncio.run(main())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Supported Workflows
|
|
73
|
+
|
|
74
|
+
This SDK provides extensive high-level wrappers. Below are just a few examples.
|
|
75
|
+
|
|
76
|
+
*(For complete runnable scripts, check the `examples/` directory).*
|
|
77
|
+
|
|
78
|
+
### 📸 Facial Recognition & Photo Upload
|
|
79
|
+
Unlike standard JSON requests, facial photo enrollment requires raw binary streaming.
|
|
80
|
+
The iDFace firmware is famously strict: if you upload a high-resolution, dense portrait (like a 2MB iPhone photo) or images where the face isn't perfectly centered, the device will silently reject the neural-network validation with a `400 Bad Request`.
|
|
81
|
+
|
|
82
|
+
Our SDK examples handle this elegantly by using `Pillow` to instantly downscale huge images to `< 40KB` and `< 600x600px` before uploading:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from examples import helper
|
|
86
|
+
|
|
87
|
+
# Automatically loops through photos, shrinks them flawlessly, and registers the face!
|
|
88
|
+
await helper.enhance_user(client, user_id=10, user_ref="EMP")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Alternatively, you can skip local uploads entirely and trigger the device's physical screen so the person can enroll themselves live at the gate!
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
# The iDFace terminal will open its camera, display a 5-second countdown,
|
|
95
|
+
# analyze liveness, and automatically save the biometric hash!
|
|
96
|
+
await client.remote_enroll_face(user_id=10, auto=True, countdown=5)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 📱 QR Code Credentials
|
|
100
|
+
The device expects CRC-32 integers when scanning standard QR codes. The SDK can help you register them correctly.
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
import binascii
|
|
104
|
+
|
|
105
|
+
def qr_hash(text: str) -> int:
|
|
106
|
+
return binascii.crc32(text.encode()) & 0xFFFFFFFF
|
|
107
|
+
|
|
108
|
+
# Give a user a QR code credential that matches the text "VISITOR-99"
|
|
109
|
+
await client.add_qr_code(user_id=10, qr_value=qr_hash("VISITOR-99"))
|
|
110
|
+
```
|
|
111
|
+
*Note: Our `examples/helper.py` will automatically generate and save physical `.png` files of the QR Codes so you can test them instantly on your screen or phone.*
|
|
112
|
+
|
|
113
|
+
### 🏢 Departments, Groups, and Access Rules
|
|
114
|
+
Organize your users logically and restrict their access using Rules and Groups.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
# 1. Create a Department
|
|
118
|
+
group_id = await client.create_group("Engineering")
|
|
119
|
+
|
|
120
|
+
# 2. Create an Access Rule (e.g., 1 = Always Allowed, Priority = 0)
|
|
121
|
+
rule_id = await client.create_access_rule("24/7 Access", rule_type=1)
|
|
122
|
+
|
|
123
|
+
# 3. Link the Group to the Rule
|
|
124
|
+
await client.assign_group_access_rule(group_id, rule_id)
|
|
125
|
+
|
|
126
|
+
# 4. Add the user to the Group
|
|
127
|
+
await client.add_user_to_group(user_id=10, group_id=group_id)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 🧩 Custom Fields (`c_users`)
|
|
131
|
+
Extend the device's native database schema dynamically to store your own business logic (e.g., CPF, Department Code).
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from controlid import CustomField
|
|
135
|
+
|
|
136
|
+
# 1. Create the schema column (run once per device)
|
|
137
|
+
await client.add_custom_field(CustomField(
|
|
138
|
+
column_name="cpf",
|
|
139
|
+
name="CPF Number",
|
|
140
|
+
type="TEXT"
|
|
141
|
+
))
|
|
142
|
+
|
|
143
|
+
# 2. Write data for a specific user
|
|
144
|
+
await client.set_custom_user_data(user_id=10, cpf="123.456.789-00")
|
|
145
|
+
|
|
146
|
+
# 3. Read it back
|
|
147
|
+
custom_data = await client.get_custom_user_data(user_id=10)
|
|
148
|
+
print(custom_data) # {'cpf': '123.456.789-00', 'user_id': 10}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### ⚙️ Generic Object API
|
|
152
|
+
For device tables that lack specialized wrappers, you can always bypass the abstractions and use the highly resilient Generic Database API:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
# Fetch from any device table seamlessly
|
|
156
|
+
roles = await client.load_objects("user_roles")
|
|
157
|
+
|
|
158
|
+
# Safely update using Python dicts.
|
|
159
|
+
# The SDK automatically handles the internal 'where' namespace quirks.
|
|
160
|
+
await client.modify_objects(
|
|
161
|
+
"users",
|
|
162
|
+
values={"name": "New Name"},
|
|
163
|
+
where={"id": 10}
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Directory Structure
|
|
168
|
+
- `src/controlid/client.py`: Core asynchronous logic and endpoints.
|
|
169
|
+
- `src/controlid/models.py`: Pydantic V2 definitions.
|
|
170
|
+
- `src/controlid/constants.py`: Enums and endpoint tables.
|
|
171
|
+
- `examples/*.py`: Fully self-contained scripts demonstrating every major use-case. Including a comprehensive `run_tests.py` that validates the entire API sequentially against live hardware.
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
MIT
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# ControlID Python SDK (Async)
|
|
2
|
+
|
|
3
|
+
A production-ready, high-performance asynchronous Python SDK for ControlID access control devices (iDAccess, iDFace, iDFlex, iDBlock, etc.).
|
|
4
|
+
|
|
5
|
+
Built on top of `httpx` and `pydantic` v2, this SDK provides robust abstractions over the ControlID API, gracefully handling undocumented firmware quirks, session management, and complex data schemas.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- ⚡️ **Fully Asynchronous**: Built exclusively on `async/await` and `httpx` for high-concurrency non-blocking I/O.
|
|
10
|
+
- 🔑 **Automatic Session Management**: Handles logins and injects secure, URL-encoded tokens into requests.
|
|
11
|
+
- 🛡️ **Pydantic Validation**: Robust, type-hinted data models (`User`, `Card`, `AccessRule`, etc.).
|
|
12
|
+
- 🛠️ **Quirk Resolution**: Automatically manages device-specific API quirks (e.g., nested `where` clauses, strict `modify_objects` routing, implicit schema limitations).
|
|
13
|
+
- 📸 **Facial Recognition**: Push direct binary payload image uploads for iDFace enrollment.
|
|
14
|
+
- 📱 **QR Code Support**: Generate and register CRC-32 QR Code credentials safely.
|
|
15
|
+
- 🚪 **Advanced Hardware Control**: Interact with relays, GPIO pins, SecBoxes, turnstiles (catras), and ballot boxes.
|
|
16
|
+
- 📋 **Dynamic Schemas**: Native support for creating and interacting with custom device fields (`c_users` table).
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import asyncio
|
|
28
|
+
from controlid import ControlIDClient, User
|
|
29
|
+
|
|
30
|
+
async def main():
|
|
31
|
+
# Use as an async context manager for automatic session cleanup.
|
|
32
|
+
# We use verify=False typically because devices use self-signed SSL certificates.
|
|
33
|
+
async with ControlIDClient(
|
|
34
|
+
host="https://192.168.0.100",
|
|
35
|
+
user="admin",
|
|
36
|
+
password="password",
|
|
37
|
+
verify=False
|
|
38
|
+
) as client:
|
|
39
|
+
|
|
40
|
+
# 1. Fetch Users
|
|
41
|
+
users = await client.get_users()
|
|
42
|
+
print(f"Found {len(users)} users on device.")
|
|
43
|
+
|
|
44
|
+
# 2. Add a new user
|
|
45
|
+
user_id = await client.add_user(User(name="Jane Doe", registration="EMP-001"))
|
|
46
|
+
print(f"Created user with ID: {user_id}")
|
|
47
|
+
|
|
48
|
+
# 3. Open a Door (using internal relay)
|
|
49
|
+
await client.open_door(door_id=1)
|
|
50
|
+
|
|
51
|
+
# Or using an external SecBox (standard on iDFace/iDFlex installations)
|
|
52
|
+
# await client.open_sec_box(box_id=65793)
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
asyncio.run(main())
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Supported Workflows
|
|
59
|
+
|
|
60
|
+
This SDK provides extensive high-level wrappers. Below are just a few examples.
|
|
61
|
+
|
|
62
|
+
*(For complete runnable scripts, check the `examples/` directory).*
|
|
63
|
+
|
|
64
|
+
### 📸 Facial Recognition & Photo Upload
|
|
65
|
+
Unlike standard JSON requests, facial photo enrollment requires raw binary streaming.
|
|
66
|
+
The iDFace firmware is famously strict: if you upload a high-resolution, dense portrait (like a 2MB iPhone photo) or images where the face isn't perfectly centered, the device will silently reject the neural-network validation with a `400 Bad Request`.
|
|
67
|
+
|
|
68
|
+
Our SDK examples handle this elegantly by using `Pillow` to instantly downscale huge images to `< 40KB` and `< 600x600px` before uploading:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from examples import helper
|
|
72
|
+
|
|
73
|
+
# Automatically loops through photos, shrinks them flawlessly, and registers the face!
|
|
74
|
+
await helper.enhance_user(client, user_id=10, user_ref="EMP")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Alternatively, you can skip local uploads entirely and trigger the device's physical screen so the person can enroll themselves live at the gate!
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
# The iDFace terminal will open its camera, display a 5-second countdown,
|
|
81
|
+
# analyze liveness, and automatically save the biometric hash!
|
|
82
|
+
await client.remote_enroll_face(user_id=10, auto=True, countdown=5)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 📱 QR Code Credentials
|
|
86
|
+
The device expects CRC-32 integers when scanning standard QR codes. The SDK can help you register them correctly.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import binascii
|
|
90
|
+
|
|
91
|
+
def qr_hash(text: str) -> int:
|
|
92
|
+
return binascii.crc32(text.encode()) & 0xFFFFFFFF
|
|
93
|
+
|
|
94
|
+
# Give a user a QR code credential that matches the text "VISITOR-99"
|
|
95
|
+
await client.add_qr_code(user_id=10, qr_value=qr_hash("VISITOR-99"))
|
|
96
|
+
```
|
|
97
|
+
*Note: Our `examples/helper.py` will automatically generate and save physical `.png` files of the QR Codes so you can test them instantly on your screen or phone.*
|
|
98
|
+
|
|
99
|
+
### 🏢 Departments, Groups, and Access Rules
|
|
100
|
+
Organize your users logically and restrict their access using Rules and Groups.
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
# 1. Create a Department
|
|
104
|
+
group_id = await client.create_group("Engineering")
|
|
105
|
+
|
|
106
|
+
# 2. Create an Access Rule (e.g., 1 = Always Allowed, Priority = 0)
|
|
107
|
+
rule_id = await client.create_access_rule("24/7 Access", rule_type=1)
|
|
108
|
+
|
|
109
|
+
# 3. Link the Group to the Rule
|
|
110
|
+
await client.assign_group_access_rule(group_id, rule_id)
|
|
111
|
+
|
|
112
|
+
# 4. Add the user to the Group
|
|
113
|
+
await client.add_user_to_group(user_id=10, group_id=group_id)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### 🧩 Custom Fields (`c_users`)
|
|
117
|
+
Extend the device's native database schema dynamically to store your own business logic (e.g., CPF, Department Code).
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from controlid import CustomField
|
|
121
|
+
|
|
122
|
+
# 1. Create the schema column (run once per device)
|
|
123
|
+
await client.add_custom_field(CustomField(
|
|
124
|
+
column_name="cpf",
|
|
125
|
+
name="CPF Number",
|
|
126
|
+
type="TEXT"
|
|
127
|
+
))
|
|
128
|
+
|
|
129
|
+
# 2. Write data for a specific user
|
|
130
|
+
await client.set_custom_user_data(user_id=10, cpf="123.456.789-00")
|
|
131
|
+
|
|
132
|
+
# 3. Read it back
|
|
133
|
+
custom_data = await client.get_custom_user_data(user_id=10)
|
|
134
|
+
print(custom_data) # {'cpf': '123.456.789-00', 'user_id': 10}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### ⚙️ Generic Object API
|
|
138
|
+
For device tables that lack specialized wrappers, you can always bypass the abstractions and use the highly resilient Generic Database API:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
# Fetch from any device table seamlessly
|
|
142
|
+
roles = await client.load_objects("user_roles")
|
|
143
|
+
|
|
144
|
+
# Safely update using Python dicts.
|
|
145
|
+
# The SDK automatically handles the internal 'where' namespace quirks.
|
|
146
|
+
await client.modify_objects(
|
|
147
|
+
"users",
|
|
148
|
+
values={"name": "New Name"},
|
|
149
|
+
where={"id": 10}
|
|
150
|
+
)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Directory Structure
|
|
154
|
+
- `src/controlid/client.py`: Core asynchronous logic and endpoints.
|
|
155
|
+
- `src/controlid/models.py`: Pydantic V2 definitions.
|
|
156
|
+
- `src/controlid/constants.py`: Enums and endpoint tables.
|
|
157
|
+
- `examples/*.py`: Fully self-contained scripts demonstrating every major use-case. Including a comprehensive `run_tests.py` that validates the entire API sequentially against live hardware.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "controlid-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Tulio Amancio", email="root@tsuriu.com.br" },
|
|
10
|
+
]
|
|
11
|
+
description = "A Python SDK for ControlID access control devices."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"httpx>=0.24.0",
|
|
21
|
+
"pydantic>=2.0.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
"Homepage" = "https://github.com/tulioamancio/controlid-python-sdk"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from .client import ControlIDClient
|
|
2
|
+
from .models import (
|
|
3
|
+
User,
|
|
4
|
+
Card,
|
|
5
|
+
QRCard,
|
|
6
|
+
UserRole,
|
|
7
|
+
UserGroup,
|
|
8
|
+
AccessRule,
|
|
9
|
+
TimeZone,
|
|
10
|
+
Area,
|
|
11
|
+
Door,
|
|
12
|
+
AccessLog,
|
|
13
|
+
Device,
|
|
14
|
+
FaceTemplate,
|
|
15
|
+
CatraInfo,
|
|
16
|
+
GPIOStatus,
|
|
17
|
+
CustomField,
|
|
18
|
+
)
|
|
19
|
+
from .exceptions import ControlIDError, AuthenticationError, SessionError, APIError
|
|
20
|
+
from . import constants
|
|
21
|
+
|
|
22
|
+
__version__ = "0.2.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ControlIDClient",
|
|
26
|
+
# Models
|
|
27
|
+
"User",
|
|
28
|
+
"Card",
|
|
29
|
+
"QRCard",
|
|
30
|
+
"UserRole",
|
|
31
|
+
"UserGroup",
|
|
32
|
+
"AccessRule",
|
|
33
|
+
"TimeZone",
|
|
34
|
+
"Area",
|
|
35
|
+
"Door",
|
|
36
|
+
"AccessLog",
|
|
37
|
+
"Device",
|
|
38
|
+
"FaceTemplate",
|
|
39
|
+
"CatraInfo",
|
|
40
|
+
"GPIOStatus",
|
|
41
|
+
"CustomField",
|
|
42
|
+
# Exceptions
|
|
43
|
+
"ControlIDError",
|
|
44
|
+
"AuthenticationError",
|
|
45
|
+
"SessionError",
|
|
46
|
+
"APIError",
|
|
47
|
+
# Constants module (handy for card type enums, table names, etc.)
|
|
48
|
+
"constants",
|
|
49
|
+
]
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import time as _time
|
|
3
|
+
from typing import List, Dict, Any, Optional
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
from . import constants as const
|
|
6
|
+
from . import exceptions as ex
|
|
7
|
+
from .models import User, Card, Door, AccessLog, UserRole, QRCard, CustomField
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ControlIDClient:
|
|
11
|
+
"""
|
|
12
|
+
Asynchronous Python client for the ControlID Access Control REST API.
|
|
13
|
+
|
|
14
|
+
Usage::
|
|
15
|
+
|
|
16
|
+
async with ControlIDClient("https://192.168.1.100", verify=False) as client:
|
|
17
|
+
users = await client.get_users()
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
host: str,
|
|
23
|
+
user: str = "admin",
|
|
24
|
+
password: str = "admin",
|
|
25
|
+
timeout: float = 15.0,
|
|
26
|
+
verify: bool = True,
|
|
27
|
+
):
|
|
28
|
+
self.host = host.rstrip("/")
|
|
29
|
+
if not self.host.startswith("http"):
|
|
30
|
+
self.host = f"http://{self.host}"
|
|
31
|
+
|
|
32
|
+
self.user = user
|
|
33
|
+
self.password = password
|
|
34
|
+
self.session: Optional[str] = None
|
|
35
|
+
self.timeout = timeout
|
|
36
|
+
self.verify = verify
|
|
37
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
38
|
+
|
|
39
|
+
# ─── Context Manager ───────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
async def __aenter__(self) -> "ControlIDClient":
|
|
42
|
+
self._client = httpx.AsyncClient(timeout=self.timeout, verify=self.verify)
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
46
|
+
if self._client:
|
|
47
|
+
await self._client.aclose()
|
|
48
|
+
self._client = None
|
|
49
|
+
|
|
50
|
+
# ─── Internal Helpers ──────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
def _get_url(self, endpoint: str) -> str:
|
|
53
|
+
"""Build a full URL, appending the session token when available."""
|
|
54
|
+
url = f"{self.host}{endpoint}"
|
|
55
|
+
if self.session:
|
|
56
|
+
url += f"?session={quote(self.session, safe='')}"
|
|
57
|
+
return url
|
|
58
|
+
|
|
59
|
+
async def _ensure_client(self):
|
|
60
|
+
"""Create the httpx client if it doesn't exist or was closed."""
|
|
61
|
+
if self._client is None or self._client.is_closed:
|
|
62
|
+
self._client = httpx.AsyncClient(timeout=self.timeout, verify=self.verify)
|
|
63
|
+
|
|
64
|
+
# ─── Authentication ────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
async def login(self) -> str:
|
|
67
|
+
"""Authenticate with the device and store the session token."""
|
|
68
|
+
await self._ensure_client()
|
|
69
|
+
url = f"{self.host}{const.LOGIN}"
|
|
70
|
+
payload = {"login": self.user, "password": self.password}
|
|
71
|
+
try:
|
|
72
|
+
response = await self._client.post(url, json=payload)
|
|
73
|
+
response.raise_for_status()
|
|
74
|
+
data = response.json()
|
|
75
|
+
if "session" in data:
|
|
76
|
+
self.session = data["session"]
|
|
77
|
+
return self.session
|
|
78
|
+
raise ex.AuthenticationError(f"Login failed: {data}")
|
|
79
|
+
except ex.AuthenticationError:
|
|
80
|
+
raise
|
|
81
|
+
except Exception as e:
|
|
82
|
+
raise ex.AuthenticationError(f"Connection error: {e}")
|
|
83
|
+
|
|
84
|
+
async def logout(self):
|
|
85
|
+
"""Invalidate the current session on the device."""
|
|
86
|
+
if not self.session:
|
|
87
|
+
return
|
|
88
|
+
await self._ensure_client()
|
|
89
|
+
try:
|
|
90
|
+
await self._client.post(self._get_url(const.LOGOUT))
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
finally:
|
|
94
|
+
self.session = None
|
|
95
|
+
|
|
96
|
+
async def is_session_valid(self) -> bool:
|
|
97
|
+
"""Returns True if the current session token is still valid."""
|
|
98
|
+
if not self.session:
|
|
99
|
+
return False
|
|
100
|
+
await self._ensure_client()
|
|
101
|
+
try:
|
|
102
|
+
response = await self._client.post(self._get_url(const.SESSION_IS_VALID))
|
|
103
|
+
return response.json().get("session_is_valid", False)
|
|
104
|
+
except Exception:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# ─── Generic Request Handler ───────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
async def request(
|
|
110
|
+
self,
|
|
111
|
+
endpoint: str,
|
|
112
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
113
|
+
method: str = "POST",
|
|
114
|
+
) -> Dict[str, Any]:
|
|
115
|
+
"""
|
|
116
|
+
Send an authenticated request to any API endpoint.
|
|
117
|
+
Automatically logs in if no session is available, and re-authenticates
|
|
118
|
+
once on 401 errors (session expired).
|
|
119
|
+
"""
|
|
120
|
+
await self._ensure_client()
|
|
121
|
+
if not self.session:
|
|
122
|
+
await self.login()
|
|
123
|
+
|
|
124
|
+
url = self._get_url(endpoint)
|
|
125
|
+
try:
|
|
126
|
+
if method.upper() == "POST":
|
|
127
|
+
response = await self._client.post(url, json=payload or {})
|
|
128
|
+
else:
|
|
129
|
+
response = await self._client.get(url)
|
|
130
|
+
response.raise_for_status()
|
|
131
|
+
return response.json()
|
|
132
|
+
except httpx.HTTPStatusError as e:
|
|
133
|
+
if e.response.status_code == 401:
|
|
134
|
+
# Session expired — re-login and retry once
|
|
135
|
+
await self.login()
|
|
136
|
+
url = self._get_url(endpoint)
|
|
137
|
+
response = await self._client.post(url, json=payload or {})
|
|
138
|
+
response.raise_for_status()
|
|
139
|
+
return response.json()
|
|
140
|
+
raise ex.APIError(
|
|
141
|
+
f"API Error: {e}",
|
|
142
|
+
status_code=e.response.status_code,
|
|
143
|
+
response=e.response.text,
|
|
144
|
+
)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
raise ex.ControlIDError(f"Request failed: {e}")
|
|
147
|
+
|
|
148
|
+
# ─── Generic CRUD ──────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
async def create_objects(self, table: str, values: List[Dict[str, Any]]) -> List[int]:
|
|
151
|
+
"""Insert one or more records into *table*. Returns list of new IDs."""
|
|
152
|
+
payload = {"object": table, "values": values}
|
|
153
|
+
data = await self.request(const.CREATE_OBJECTS, payload)
|
|
154
|
+
return data.get("ids", [])
|
|
155
|
+
|
|
156
|
+
async def load_objects(
|
|
157
|
+
self,
|
|
158
|
+
table: str,
|
|
159
|
+
where: Optional[Dict[str, Any]] = None,
|
|
160
|
+
order: Optional[List[str]] = None,
|
|
161
|
+
limit: Optional[int] = None,
|
|
162
|
+
offset: Optional[int] = None,
|
|
163
|
+
) -> List[Dict[str, Any]]:
|
|
164
|
+
"""
|
|
165
|
+
Query records from *table*.
|
|
166
|
+
|
|
167
|
+
``where`` is a flat dict of field → value conditions, e.g.
|
|
168
|
+
``{"id": 5}`` or ``{"name": "Alice"}``.
|
|
169
|
+
The dict is automatically wrapped in the required ``{"table": {...}}``
|
|
170
|
+
envelope that the ControlID API expects.
|
|
171
|
+
"""
|
|
172
|
+
payload: Dict[str, Any] = {"object": table}
|
|
173
|
+
if where:
|
|
174
|
+
payload["where"] = {table: where}
|
|
175
|
+
if order:
|
|
176
|
+
payload["order"] = order
|
|
177
|
+
if limit is not None:
|
|
178
|
+
payload["limit"] = limit
|
|
179
|
+
if offset is not None:
|
|
180
|
+
payload["offset"] = offset
|
|
181
|
+
|
|
182
|
+
data = await self.request(const.LOAD_OBJECTS, payload)
|
|
183
|
+
return data.get(table, [])
|
|
184
|
+
|
|
185
|
+
async def update_objects(
|
|
186
|
+
self,
|
|
187
|
+
table: str,
|
|
188
|
+
values: Dict[str, Any],
|
|
189
|
+
where: Optional[Dict[str, Any]] = None,
|
|
190
|
+
) -> int:
|
|
191
|
+
"""
|
|
192
|
+
Update records in *table* that match *where*.
|
|
193
|
+
Returns the count of affected rows.
|
|
194
|
+
"""
|
|
195
|
+
payload: Dict[str, Any] = {"object": table, "values": values}
|
|
196
|
+
if where:
|
|
197
|
+
payload["where"] = {table: where}
|
|
198
|
+
data = await self.request(const.UPDATE_OBJECTS, payload)
|
|
199
|
+
return data.get("count", 0)
|
|
200
|
+
|
|
201
|
+
async def destroy_objects(
|
|
202
|
+
self, table: str, where: Optional[Dict[str, Any]] = None
|
|
203
|
+
) -> int:
|
|
204
|
+
"""
|
|
205
|
+
Delete records from *table* that match *where*.
|
|
206
|
+
Returns the count of deleted rows.
|
|
207
|
+
"""
|
|
208
|
+
payload: Dict[str, Any] = {"object": table}
|
|
209
|
+
if where:
|
|
210
|
+
payload["where"] = {table: where}
|
|
211
|
+
data = await self.request(const.DESTROY_OBJECTS, payload)
|
|
212
|
+
return data.get("count", 0)
|
|
213
|
+
|
|
214
|
+
async def upsert_objects(self, table: str, values: List[Dict[str, Any]]) -> List[int]:
|
|
215
|
+
"""
|
|
216
|
+
Create or update records (create_or_modify_objects.fcgi).
|
|
217
|
+
Useful for syncing: if a record with the same ID exists, it is updated;
|
|
218
|
+
otherwise it is created.
|
|
219
|
+
"""
|
|
220
|
+
payload = {"object": table, "values": values}
|
|
221
|
+
data = await self.request(const.CREATE_OR_UPDATE_OBJECTS, payload)
|
|
222
|
+
return data.get("ids", [])
|
|
223
|
+
|
|
224
|
+
# ─── Users ────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
async def get_users(self) -> List[User]:
|
|
227
|
+
"""Return all users registered on the device."""
|
|
228
|
+
return [User(**u) for u in await self.load_objects(const.TABLE_USERS)]
|
|
229
|
+
|
|
230
|
+
async def get_user(self, user_id: int) -> Optional[User]:
|
|
231
|
+
"""Return a single user by ID, or None if not found."""
|
|
232
|
+
rows = await self.load_objects(const.TABLE_USERS, where={"id": user_id})
|
|
233
|
+
return User(**rows[0]) if rows else None
|
|
234
|
+
|
|
235
|
+
async def add_user(self, user: User) -> int:
|
|
236
|
+
"""Create a user and return its new ID."""
|
|
237
|
+
user_dict = user.model_dump(exclude_none=True)
|
|
238
|
+
ids = await self.create_objects(const.TABLE_USERS, [user_dict])
|
|
239
|
+
return ids[0] if ids else None
|
|
240
|
+
|
|
241
|
+
async def update_user(self, user_id: int, **fields) -> int:
|
|
242
|
+
"""Patch specific fields on an existing user. Returns affected count."""
|
|
243
|
+
return await self.update_objects(const.TABLE_USERS, fields, where={"id": user_id})
|
|
244
|
+
|
|
245
|
+
async def delete_user(self, user_id: int):
|
|
246
|
+
"""Remove a user from the device."""
|
|
247
|
+
await self.destroy_objects(const.TABLE_USERS, where={"id": user_id})
|
|
248
|
+
|
|
249
|
+
# ─── Cards / Credentials ──────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
async def get_cards(self, user_id: Optional[int] = None) -> List[Card]:
|
|
252
|
+
"""Return credentials for all users, or just one user if *user_id* given."""
|
|
253
|
+
where = {"user_id": user_id} if user_id else None
|
|
254
|
+
return [Card(**c) for c in await self.load_objects(const.TABLE_CARDS, where=where)]
|
|
255
|
+
|
|
256
|
+
async def add_card(self, card: Card) -> int:
|
|
257
|
+
"""Register a credential (RFID, QR, etc.) for a user."""
|
|
258
|
+
return (await self.create_objects(const.TABLE_CARDS, [card.model_dump(exclude_none=True)]))[0]
|
|
259
|
+
|
|
260
|
+
async def delete_card(self, card_id: int):
|
|
261
|
+
"""Remove a credential by ID."""
|
|
262
|
+
await self.destroy_objects(const.TABLE_CARDS, where={"id": card_id})
|
|
263
|
+
|
|
264
|
+
async def add_qr_code(self, user_id: int, qr_value: int) -> int:
|
|
265
|
+
"""
|
|
266
|
+
Register a QR-code credential for *user_id*.
|
|
267
|
+
*qr_value* is the integer hash the device expects when the QR is scanned
|
|
268
|
+
(typically CRC-32 of the string content).
|
|
269
|
+
|
|
270
|
+
Note: the `type` field is omitted for compatibility with firmware versions
|
|
271
|
+
that only accept `value` + `user_id` on the cards table.
|
|
272
|
+
"""
|
|
273
|
+
return await self.add_card(Card(user_id=user_id, value=qr_value))
|
|
274
|
+
|
|
275
|
+
# ─── Groups / Departments ─────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
async def get_groups(self) -> List[Dict[str, Any]]:
|
|
278
|
+
"""Return all groups (departments) defined on the device."""
|
|
279
|
+
return await self.load_objects(const.TABLE_GROUPS)
|
|
280
|
+
|
|
281
|
+
async def create_group(self, name: str) -> int:
|
|
282
|
+
"""Create a new group and return its ID."""
|
|
283
|
+
ids = await self.create_objects(const.TABLE_GROUPS, [{"name": name}])
|
|
284
|
+
return ids[0] if ids else None
|
|
285
|
+
|
|
286
|
+
async def add_user_to_group(self, user_id: int, group_id: int):
|
|
287
|
+
"""Assign a user to a group/department."""
|
|
288
|
+
await self.create_objects(const.TABLE_USER_GROUPS, [
|
|
289
|
+
{"user_id": user_id, "group_id": group_id}
|
|
290
|
+
])
|
|
291
|
+
|
|
292
|
+
async def remove_user_from_group(self, user_id: int, group_id: int):
|
|
293
|
+
"""Remove a user from a group."""
|
|
294
|
+
await self.destroy_objects(
|
|
295
|
+
const.TABLE_USER_GROUPS,
|
|
296
|
+
where={"user_id": user_id, "group_id": group_id},
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# ─── User Roles / Types ───────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
async def get_user_roles(self) -> List[Dict[str, Any]]:
|
|
302
|
+
"""Return all user roles (types) defined on the device."""
|
|
303
|
+
return await self.load_objects(const.TABLE_USER_ROLES)
|
|
304
|
+
|
|
305
|
+
async def create_user_role(self, role: UserRole) -> int:
|
|
306
|
+
"""Create a user role (e.g. Employee, Visitor) and return its ID."""
|
|
307
|
+
ids = await self.create_objects(
|
|
308
|
+
const.TABLE_USER_ROLES, [role.model_dump(exclude_none=True)]
|
|
309
|
+
)
|
|
310
|
+
return ids[0] if ids else None
|
|
311
|
+
|
|
312
|
+
async def assign_user_role(self, user_id: int, role_id: int):
|
|
313
|
+
"""Assign a user to a role type."""
|
|
314
|
+
await self.upsert_objects(const.TABLE_USER_ROLES, [
|
|
315
|
+
{"user_id": user_id, "role_id": role_id}
|
|
316
|
+
])
|
|
317
|
+
|
|
318
|
+
# ─── Access Rules ─────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
async def get_access_rules(self) -> List[Dict[str, Any]]:
|
|
321
|
+
"""Return all access rules on the device."""
|
|
322
|
+
return await self.load_objects(const.TABLE_ACCESS_RULES)
|
|
323
|
+
|
|
324
|
+
async def create_access_rule(self, name: str, rule_type: int = 1) -> int:
|
|
325
|
+
"""
|
|
326
|
+
Create an access rule. rule_type 1 = always allowed.
|
|
327
|
+
Returns the new rule ID.
|
|
328
|
+
"""
|
|
329
|
+
ids = await self.create_objects(const.TABLE_ACCESS_RULES, [
|
|
330
|
+
{"name": name, "type": rule_type, "priority": 0}
|
|
331
|
+
])
|
|
332
|
+
return ids[0] if ids else None
|
|
333
|
+
|
|
334
|
+
async def assign_group_access_rule(self, group_id: int, rule_id: int):
|
|
335
|
+
"""Grant a group access to doors/areas defined by an access rule."""
|
|
336
|
+
await self.create_objects(const.TABLE_GROUP_ACCESS_RULES, [
|
|
337
|
+
{"group_id": group_id, "access_rule_id": rule_id}
|
|
338
|
+
])
|
|
339
|
+
|
|
340
|
+
# ─── Access Logs ──────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
async def get_logs(self, limit: int = 20) -> List[AccessLog]:
|
|
343
|
+
"""Return the most recent *limit* access log entries."""
|
|
344
|
+
rows = await self.load_objects(const.TABLE_ACCESS_LOGS, limit=limit)
|
|
345
|
+
return [AccessLog(**r) for r in rows]
|
|
346
|
+
|
|
347
|
+
# ─── Hardware Actions ─────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
async def execute_actions(self, actions: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
350
|
+
"""
|
|
351
|
+
Send one or more hardware action commands.
|
|
352
|
+
Each action is ``{"action": "<name>", "parameters": "<params>"}``
|
|
353
|
+
"""
|
|
354
|
+
return await self.request(const.EXECUTE_ACTIONS, {"actions": actions})
|
|
355
|
+
|
|
356
|
+
async def open_door(self, door_id: int = 1) -> Dict[str, Any]:
|
|
357
|
+
"""
|
|
358
|
+
Open a door via internal relay (iDAccess, iDFace Max, etc.).
|
|
359
|
+
Use ``open_sec_box()`` for devices with an external SecBox module.
|
|
360
|
+
"""
|
|
361
|
+
return await self.execute_actions([
|
|
362
|
+
{"action": "door", "parameters": f"door={door_id}"}
|
|
363
|
+
])
|
|
364
|
+
|
|
365
|
+
async def open_sec_box(self, box_id: int, reason: int = 3) -> Dict[str, Any]:
|
|
366
|
+
"""
|
|
367
|
+
Open a door via SecBox relay (standard iDFace / iDFlex).
|
|
368
|
+
*box_id* is the numeric ID of the SecBox as detected by the device.
|
|
369
|
+
"""
|
|
370
|
+
return await self.execute_actions([
|
|
371
|
+
{"action": "sec_box", "parameters": f"id={box_id}, reason={reason}"}
|
|
372
|
+
])
|
|
373
|
+
|
|
374
|
+
# ─── iDFace ───────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
async def remote_enroll_face(self, user_id: int, auto: bool = True, countdown: int = 5) -> Dict[str, Any]:
|
|
377
|
+
"""
|
|
378
|
+
Start a remote face-capture session on the iDFace screen.
|
|
379
|
+
The user must stand in front of the device while this runs.
|
|
380
|
+
"""
|
|
381
|
+
payload = {
|
|
382
|
+
"type": "face",
|
|
383
|
+
"user_id": user_id,
|
|
384
|
+
"auto": auto,
|
|
385
|
+
"save": True,
|
|
386
|
+
"countdown": countdown
|
|
387
|
+
}
|
|
388
|
+
return await self.request(const.REMOTE_ENROLL, payload)
|
|
389
|
+
|
|
390
|
+
async def set_user_face_image(self, user_id: int, image_data: bytes) -> Dict[str, Any]:
|
|
391
|
+
"""
|
|
392
|
+
Upload a JPEG for facial enrollment.
|
|
393
|
+
Sends raw binary data with the user_id as a query parameter.
|
|
394
|
+
"""
|
|
395
|
+
await self._ensure_client()
|
|
396
|
+
if not self.session:
|
|
397
|
+
await self.login()
|
|
398
|
+
|
|
399
|
+
timestamp = int(_time.time())
|
|
400
|
+
url = self._get_url(const.USER_SET_IMAGE)
|
|
401
|
+
url += f"&user_id={user_id}&match=1×tamp={timestamp}"
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
response = await self._client.post(
|
|
405
|
+
url,
|
|
406
|
+
content=image_data,
|
|
407
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
408
|
+
)
|
|
409
|
+
response.raise_for_status()
|
|
410
|
+
return response.json()
|
|
411
|
+
except Exception as e:
|
|
412
|
+
raise ex.ControlIDError(f"Image upload failed: {e}")
|
|
413
|
+
|
|
414
|
+
async def get_user_face_image(self, user_id: int) -> bytes:
|
|
415
|
+
"""Download the facial photo stored for a user as raw JPEG bytes."""
|
|
416
|
+
await self._ensure_client()
|
|
417
|
+
if not self.session:
|
|
418
|
+
await self.login()
|
|
419
|
+
|
|
420
|
+
url = self._get_url(const.USER_GET_IMAGE)
|
|
421
|
+
url += f"&user_id={user_id}"
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
response = await self._client.post(url)
|
|
425
|
+
response.raise_for_status()
|
|
426
|
+
return response.content
|
|
427
|
+
except Exception as e:
|
|
428
|
+
raise ex.ControlIDError(f"Image download failed: {e}")
|
|
429
|
+
|
|
430
|
+
# ─── iDBlock / Turnstile ──────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
async def open_turnstile(self, direction: str = "clockwise") -> Dict[str, Any]:
|
|
433
|
+
"""
|
|
434
|
+
Release the iDBlock turnstile.
|
|
435
|
+
direction: ``'clockwise'`` | ``'anticlockwise'`` | ``'both'``
|
|
436
|
+
"""
|
|
437
|
+
return await self.execute_actions([
|
|
438
|
+
{"action": "catra", "parameters": f"allow={direction}"}
|
|
439
|
+
])
|
|
440
|
+
|
|
441
|
+
async def open_ballot_box(self) -> Dict[str, Any]:
|
|
442
|
+
"""Release the card-collector slot on an iDBlock."""
|
|
443
|
+
return await self.execute_actions([
|
|
444
|
+
{"action": "open_collector", "parameters": ""}
|
|
445
|
+
])
|
|
446
|
+
|
|
447
|
+
async def get_turnstile_info(self) -> List[Dict[str, Any]]:
|
|
448
|
+
"""Return the turnstile hardware configuration list."""
|
|
449
|
+
return await self.load_objects(const.TABLE_CATRA_INFOS)
|
|
450
|
+
|
|
451
|
+
# ─── GPIO ─────────────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
async def get_gpio_status(self, gpio_pin: int = 1) -> Dict[str, Any]:
|
|
454
|
+
"""
|
|
455
|
+
Query the state of a specific GPIO pin.
|
|
456
|
+
*gpio_pin* — the pin number to query (default 1, e.g. a relay).
|
|
457
|
+
Returns a dict with the current high/low state.
|
|
458
|
+
Note: throws 400 Bad Request if the pin is not mapped on your hardware.
|
|
459
|
+
"""
|
|
460
|
+
return await self.request(const.READ_GPIO_STATUS, payload={"gpio": gpio_pin})
|
|
461
|
+
|
|
462
|
+
# ─── Custom Fields (c_users) ──────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
async def add_custom_field(self, field: CustomField) -> Dict[str, Any]:
|
|
465
|
+
"""
|
|
466
|
+
Add a new column to the ``c_users`` table.
|
|
467
|
+
|
|
468
|
+
Example::
|
|
469
|
+
|
|
470
|
+
field = CustomField(column_name="cpf", name="CPF", type="TEXT")
|
|
471
|
+
await client.add_custom_field(field)
|
|
472
|
+
"""
|
|
473
|
+
payload = field.model_dump()
|
|
474
|
+
return await self.request(const.OBJECT_ADD_FIELD, payload)
|
|
475
|
+
|
|
476
|
+
async def remove_custom_fields(self, field_ids: List[int]) -> Dict[str, Any]:
|
|
477
|
+
"""Remove custom columns from ``c_users`` by their field IDs."""
|
|
478
|
+
return await self.request(const.OBJECT_REMOVE_FIELDS, {"ids": field_ids})
|
|
479
|
+
|
|
480
|
+
async def get_custom_user_data(self, user_id: int) -> Dict[str, Any]:
|
|
481
|
+
"""Return the custom field values for a specific user."""
|
|
482
|
+
rows = await self.load_objects(const.TABLE_C_USERS, where={"user_id": user_id})
|
|
483
|
+
return rows[0] if rows else {}
|
|
484
|
+
|
|
485
|
+
async def set_custom_user_data(self, user_id: int, **fields) -> int:
|
|
486
|
+
"""
|
|
487
|
+
Write arbitrary custom fields for a user.
|
|
488
|
+
|
|
489
|
+
Example::
|
|
490
|
+
|
|
491
|
+
await client.set_custom_user_data(user_id=5, cpf="123.456.789-00", birthdate="1990-01-01")
|
|
492
|
+
"""
|
|
493
|
+
existing = await self.load_objects(const.TABLE_C_USERS, where={"user_id": user_id})
|
|
494
|
+
if existing:
|
|
495
|
+
return await self.update_objects(
|
|
496
|
+
const.TABLE_C_USERS, fields, where={"user_id": user_id}
|
|
497
|
+
)
|
|
498
|
+
else:
|
|
499
|
+
await self.create_objects(
|
|
500
|
+
const.TABLE_C_USERS, [{**fields, "user_id": user_id}]
|
|
501
|
+
)
|
|
502
|
+
return 1
|
|
503
|
+
|
|
504
|
+
# ─── Device Configuration ─────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
async def set_configuration(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
|
507
|
+
"""
|
|
508
|
+
Write device configuration settings.
|
|
509
|
+
|
|
510
|
+
Example::
|
|
511
|
+
|
|
512
|
+
await client.set_configuration({"face_id": {"qrcode_legacy_mode_enabled": "1"}})
|
|
513
|
+
"""
|
|
514
|
+
return await self.request(const.SET_CONFIGURATION, config)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Constants for ControlID API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# Endpoints
|
|
6
|
+
LOGIN = "/login.fcgi"
|
|
7
|
+
LOGOUT = "/logout.fcgi"
|
|
8
|
+
SESSION_IS_VALID = "/session_is_valid.fcgi"
|
|
9
|
+
|
|
10
|
+
# Object Management
|
|
11
|
+
CREATE_OBJECTS = "/create_objects.fcgi"
|
|
12
|
+
LOAD_OBJECTS = "/load_objects.fcgi"
|
|
13
|
+
UPDATE_OBJECTS = "/modify_objects.fcgi"
|
|
14
|
+
DESTROY_OBJECTS = "/destroy_objects.fcgi"
|
|
15
|
+
COUNT_OBJECTS = "/count_objects.fcgi"
|
|
16
|
+
CREATE_OR_UPDATE_OBJECTS = "/create_or_modify_objects.fcgi"
|
|
17
|
+
|
|
18
|
+
# Actions
|
|
19
|
+
REBOOT = "/reboot.fcgi"
|
|
20
|
+
SET_SYSTEM_TIME = "/set_system_time.fcgi"
|
|
21
|
+
GENERATE_DEVICE_ID = "/generate_device_id.fcgi"
|
|
22
|
+
SET_GPIO = "/set_gpio.fcgi"
|
|
23
|
+
GPIO_STATE = "/gpio_state.fcgi"
|
|
24
|
+
REMOTE_ENROLL = "/remote_enroll.fcgi"
|
|
25
|
+
USER_SET_IMAGE = "/user_set_image.fcgi"
|
|
26
|
+
USER_GET_IMAGE = "/user_get_image.fcgi"
|
|
27
|
+
EXECUTE_ACTIONS = "/execute_actions.fcgi"
|
|
28
|
+
READ_GPIO_STATUS = "/gpio_state.fcgi"
|
|
29
|
+
SET_CONFIGURATION = "/set_configuration.fcgi"
|
|
30
|
+
|
|
31
|
+
# Custom Fields (c_users)
|
|
32
|
+
OBJECT_ADD_FIELD = "/object_add_field.fcgi"
|
|
33
|
+
OBJECT_REMOVE_FIELDS = "/object_remove_fields.fcgi"
|
|
34
|
+
OBJECT_METADATA = "/object_metadata.fcgi"
|
|
35
|
+
|
|
36
|
+
# Export
|
|
37
|
+
EXPORT_OBJECTS = "/export_objects.fcgi"
|
|
38
|
+
|
|
39
|
+
# Common Table/Object Names
|
|
40
|
+
TABLE_USERS = "users"
|
|
41
|
+
TABLE_CARDS = "cards"
|
|
42
|
+
TABLE_FINGERPRINTS = "fingerprints"
|
|
43
|
+
TABLE_FACE_TEMPLATES = "face_templates"
|
|
44
|
+
TABLE_ACCESS_RULES = "access_rules"
|
|
45
|
+
TABLE_GROUPS = "groups"
|
|
46
|
+
TABLE_TIME_ZONES = "time_zones"
|
|
47
|
+
TABLE_AREAS = "areas"
|
|
48
|
+
TABLE_PORTALS = "portals"
|
|
49
|
+
TABLE_DOORS = "doors"
|
|
50
|
+
TABLE_DEVICES = "devices"
|
|
51
|
+
TABLE_ACCESS_LOGS = "access_logs"
|
|
52
|
+
TABLE_USER_GROUPS = "user_groups"
|
|
53
|
+
TABLE_GROUP_ACCESS_RULES = "group_access_rules"
|
|
54
|
+
TABLE_AREA_ACCESS_RULES = "area_access_rules"
|
|
55
|
+
TABLE_CATRA_INFOS = "catra_infos"
|
|
56
|
+
TABLE_USER_ROLES = "user_roles" # User types / roles
|
|
57
|
+
TABLE_C_USERS = "c_users" # Custom user fields table
|
|
58
|
+
|
|
59
|
+
# Card Types / Identifier types
|
|
60
|
+
CARD_TYPE_CARD = 0 # Standard RFID card
|
|
61
|
+
CARD_TYPE_QR = 1 # QR Code
|
|
62
|
+
CARD_TYPE_BIOMETRIC = 2 # Fingerprint
|
|
63
|
+
CARD_TYPE_PASSWORD = 3 # Password
|
|
64
|
+
CARD_TYPE_FACE = 4 # Face recognition
|
|
65
|
+
|
|
66
|
+
# User Types (user role IDs as used in user_roles table)
|
|
67
|
+
USER_EMPLOYEE = 1
|
|
68
|
+
USER_VISITOR = 2
|
|
69
|
+
USER_MANAGER = 4
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from typing import Optional, List, Dict, Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
# ─── Core Models ───────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
class User(BaseModel):
|
|
8
|
+
id: Optional[int] = None
|
|
9
|
+
name: str
|
|
10
|
+
registration: str = ""
|
|
11
|
+
password: Optional[str] = ""
|
|
12
|
+
salt: Optional[str] = ""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Card(BaseModel):
|
|
16
|
+
id: Optional[int] = None
|
|
17
|
+
value: int
|
|
18
|
+
user_id: int
|
|
19
|
+
# NOTE: the 'type' field is NOT supported by all device firmware versions.
|
|
20
|
+
# Only include it if your device accepts it (check load_objects on 'cards').
|
|
21
|
+
type: Optional[int] = None # 0=RFID, 1=QR — exclude_none keeps it off by default
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UserGroup(BaseModel):
|
|
25
|
+
id: Optional[int] = None
|
|
26
|
+
name: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AccessRule(BaseModel):
|
|
30
|
+
id: Optional[int] = None
|
|
31
|
+
name: str
|
|
32
|
+
type: int = 1 # 1: Always allowed
|
|
33
|
+
priority: int = 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TimeZone(BaseModel):
|
|
37
|
+
id: Optional[int] = None
|
|
38
|
+
name: str
|
|
39
|
+
# week schedule bits are stored inside the device, not here
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Area(BaseModel):
|
|
43
|
+
id: Optional[int] = None
|
|
44
|
+
name: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Door(BaseModel):
|
|
48
|
+
id: Optional[int] = None
|
|
49
|
+
name: str
|
|
50
|
+
hostname: Optional[str] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AccessLog(BaseModel):
|
|
54
|
+
id: int
|
|
55
|
+
time: int
|
|
56
|
+
event: int
|
|
57
|
+
device_id: int
|
|
58
|
+
identifier_id: int
|
|
59
|
+
user_id: int
|
|
60
|
+
portal_id: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Device(BaseModel):
|
|
64
|
+
id: Optional[int] = None
|
|
65
|
+
name: str
|
|
66
|
+
ip: str
|
|
67
|
+
public_key: Optional[str] = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ─── Biometrics ────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
class FaceTemplate(BaseModel):
|
|
73
|
+
id: Optional[int] = None
|
|
74
|
+
user_id: int
|
|
75
|
+
template: str # Base64 encoded
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ─── iDBlock / Hardware ────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
class CatraInfo(BaseModel):
|
|
81
|
+
id: Optional[int] = None
|
|
82
|
+
name: str
|
|
83
|
+
mode: int = 0 # 0: Standard, 1: Pro, 2: Enterprise
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class GPIOStatus(BaseModel):
|
|
87
|
+
inputs: Dict[str, int]
|
|
88
|
+
outputs: Dict[str, int]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ─── User Roles / Types ────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
class UserRole(BaseModel):
|
|
94
|
+
"""
|
|
95
|
+
Defines a type/role for users (e.g. Employee, Visitor, Manager).
|
|
96
|
+
Stored in the 'user_roles' table on the device.
|
|
97
|
+
"""
|
|
98
|
+
id: Optional[int] = None
|
|
99
|
+
name: str
|
|
100
|
+
expire_on: Optional[int] = None # Unix timestamp; None = never expires
|
|
101
|
+
begin_time: Optional[int] = None # Seconds from midnight
|
|
102
|
+
end_time: Optional[int] = None # Seconds from midnight
|
|
103
|
+
max_uses: Optional[int] = None # -1 = unlimited
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ─── QR Code / Visitor Cards ──────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
class QRCard(BaseModel):
|
|
109
|
+
"""
|
|
110
|
+
A QR-code credential. The 'value' field stores the card hash;
|
|
111
|
+
'type' is always 1 (QR Code) on the device.
|
|
112
|
+
"""
|
|
113
|
+
id: Optional[int] = None
|
|
114
|
+
value: int
|
|
115
|
+
user_id: int
|
|
116
|
+
type: int = 1 # 1 = QR Code
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ─── Custom Fields ─────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
class CustomField(BaseModel):
|
|
122
|
+
"""
|
|
123
|
+
Defines a schema for a new column in the c_users table.
|
|
124
|
+
Use ControlIDClient.add_custom_field() to persist this on the device.
|
|
125
|
+
"""
|
|
126
|
+
object: str = "c_users"
|
|
127
|
+
column_name: str # e.g. "cpf", "birthdate"
|
|
128
|
+
name: str # Human-readable label
|
|
129
|
+
type: str = "TEXT" # TEXT | INTEGER | REAL | BLOB
|
|
130
|
+
constraint: str = "NONE"
|
|
131
|
+
default_value: str = ""
|
|
132
|
+
unique: bool = False
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: controlid-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python SDK for ControlID access control devices.
|
|
5
|
+
Author-email: Tulio Amancio <root@tsuriu.com.br>
|
|
6
|
+
Project-URL: Homepage, https://github.com/tulioamancio/controlid-python-sdk
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: httpx>=0.24.0
|
|
13
|
+
Requires-Dist: pydantic>=2.0.0
|
|
14
|
+
|
|
15
|
+
# ControlID Python SDK (Async)
|
|
16
|
+
|
|
17
|
+
A production-ready, high-performance asynchronous Python SDK for ControlID access control devices (iDAccess, iDFace, iDFlex, iDBlock, etc.).
|
|
18
|
+
|
|
19
|
+
Built on top of `httpx` and `pydantic` v2, this SDK provides robust abstractions over the ControlID API, gracefully handling undocumented firmware quirks, session management, and complex data schemas.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- ⚡️ **Fully Asynchronous**: Built exclusively on `async/await` and `httpx` for high-concurrency non-blocking I/O.
|
|
24
|
+
- 🔑 **Automatic Session Management**: Handles logins and injects secure, URL-encoded tokens into requests.
|
|
25
|
+
- 🛡️ **Pydantic Validation**: Robust, type-hinted data models (`User`, `Card`, `AccessRule`, etc.).
|
|
26
|
+
- 🛠️ **Quirk Resolution**: Automatically manages device-specific API quirks (e.g., nested `where` clauses, strict `modify_objects` routing, implicit schema limitations).
|
|
27
|
+
- 📸 **Facial Recognition**: Push direct binary payload image uploads for iDFace enrollment.
|
|
28
|
+
- 📱 **QR Code Support**: Generate and register CRC-32 QR Code credentials safely.
|
|
29
|
+
- 🚪 **Advanced Hardware Control**: Interact with relays, GPIO pins, SecBoxes, turnstiles (catras), and ballot boxes.
|
|
30
|
+
- 📋 **Dynamic Schemas**: Native support for creating and interacting with custom device fields (`c_users` table).
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install .
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import asyncio
|
|
42
|
+
from controlid import ControlIDClient, User
|
|
43
|
+
|
|
44
|
+
async def main():
|
|
45
|
+
# Use as an async context manager for automatic session cleanup.
|
|
46
|
+
# We use verify=False typically because devices use self-signed SSL certificates.
|
|
47
|
+
async with ControlIDClient(
|
|
48
|
+
host="https://192.168.0.100",
|
|
49
|
+
user="admin",
|
|
50
|
+
password="password",
|
|
51
|
+
verify=False
|
|
52
|
+
) as client:
|
|
53
|
+
|
|
54
|
+
# 1. Fetch Users
|
|
55
|
+
users = await client.get_users()
|
|
56
|
+
print(f"Found {len(users)} users on device.")
|
|
57
|
+
|
|
58
|
+
# 2. Add a new user
|
|
59
|
+
user_id = await client.add_user(User(name="Jane Doe", registration="EMP-001"))
|
|
60
|
+
print(f"Created user with ID: {user_id}")
|
|
61
|
+
|
|
62
|
+
# 3. Open a Door (using internal relay)
|
|
63
|
+
await client.open_door(door_id=1)
|
|
64
|
+
|
|
65
|
+
# Or using an external SecBox (standard on iDFace/iDFlex installations)
|
|
66
|
+
# await client.open_sec_box(box_id=65793)
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
asyncio.run(main())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Supported Workflows
|
|
73
|
+
|
|
74
|
+
This SDK provides extensive high-level wrappers. Below are just a few examples.
|
|
75
|
+
|
|
76
|
+
*(For complete runnable scripts, check the `examples/` directory).*
|
|
77
|
+
|
|
78
|
+
### 📸 Facial Recognition & Photo Upload
|
|
79
|
+
Unlike standard JSON requests, facial photo enrollment requires raw binary streaming.
|
|
80
|
+
The iDFace firmware is famously strict: if you upload a high-resolution, dense portrait (like a 2MB iPhone photo) or images where the face isn't perfectly centered, the device will silently reject the neural-network validation with a `400 Bad Request`.
|
|
81
|
+
|
|
82
|
+
Our SDK examples handle this elegantly by using `Pillow` to instantly downscale huge images to `< 40KB` and `< 600x600px` before uploading:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from examples import helper
|
|
86
|
+
|
|
87
|
+
# Automatically loops through photos, shrinks them flawlessly, and registers the face!
|
|
88
|
+
await helper.enhance_user(client, user_id=10, user_ref="EMP")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Alternatively, you can skip local uploads entirely and trigger the device's physical screen so the person can enroll themselves live at the gate!
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
# The iDFace terminal will open its camera, display a 5-second countdown,
|
|
95
|
+
# analyze liveness, and automatically save the biometric hash!
|
|
96
|
+
await client.remote_enroll_face(user_id=10, auto=True, countdown=5)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 📱 QR Code Credentials
|
|
100
|
+
The device expects CRC-32 integers when scanning standard QR codes. The SDK can help you register them correctly.
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
import binascii
|
|
104
|
+
|
|
105
|
+
def qr_hash(text: str) -> int:
|
|
106
|
+
return binascii.crc32(text.encode()) & 0xFFFFFFFF
|
|
107
|
+
|
|
108
|
+
# Give a user a QR code credential that matches the text "VISITOR-99"
|
|
109
|
+
await client.add_qr_code(user_id=10, qr_value=qr_hash("VISITOR-99"))
|
|
110
|
+
```
|
|
111
|
+
*Note: Our `examples/helper.py` will automatically generate and save physical `.png` files of the QR Codes so you can test them instantly on your screen or phone.*
|
|
112
|
+
|
|
113
|
+
### 🏢 Departments, Groups, and Access Rules
|
|
114
|
+
Organize your users logically and restrict their access using Rules and Groups.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
# 1. Create a Department
|
|
118
|
+
group_id = await client.create_group("Engineering")
|
|
119
|
+
|
|
120
|
+
# 2. Create an Access Rule (e.g., 1 = Always Allowed, Priority = 0)
|
|
121
|
+
rule_id = await client.create_access_rule("24/7 Access", rule_type=1)
|
|
122
|
+
|
|
123
|
+
# 3. Link the Group to the Rule
|
|
124
|
+
await client.assign_group_access_rule(group_id, rule_id)
|
|
125
|
+
|
|
126
|
+
# 4. Add the user to the Group
|
|
127
|
+
await client.add_user_to_group(user_id=10, group_id=group_id)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 🧩 Custom Fields (`c_users`)
|
|
131
|
+
Extend the device's native database schema dynamically to store your own business logic (e.g., CPF, Department Code).
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from controlid import CustomField
|
|
135
|
+
|
|
136
|
+
# 1. Create the schema column (run once per device)
|
|
137
|
+
await client.add_custom_field(CustomField(
|
|
138
|
+
column_name="cpf",
|
|
139
|
+
name="CPF Number",
|
|
140
|
+
type="TEXT"
|
|
141
|
+
))
|
|
142
|
+
|
|
143
|
+
# 2. Write data for a specific user
|
|
144
|
+
await client.set_custom_user_data(user_id=10, cpf="123.456.789-00")
|
|
145
|
+
|
|
146
|
+
# 3. Read it back
|
|
147
|
+
custom_data = await client.get_custom_user_data(user_id=10)
|
|
148
|
+
print(custom_data) # {'cpf': '123.456.789-00', 'user_id': 10}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### ⚙️ Generic Object API
|
|
152
|
+
For device tables that lack specialized wrappers, you can always bypass the abstractions and use the highly resilient Generic Database API:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
# Fetch from any device table seamlessly
|
|
156
|
+
roles = await client.load_objects("user_roles")
|
|
157
|
+
|
|
158
|
+
# Safely update using Python dicts.
|
|
159
|
+
# The SDK automatically handles the internal 'where' namespace quirks.
|
|
160
|
+
await client.modify_objects(
|
|
161
|
+
"users",
|
|
162
|
+
values={"name": "New Name"},
|
|
163
|
+
where={"id": 10}
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Directory Structure
|
|
168
|
+
- `src/controlid/client.py`: Core asynchronous logic and endpoints.
|
|
169
|
+
- `src/controlid/models.py`: Pydantic V2 definitions.
|
|
170
|
+
- `src/controlid/constants.py`: Enums and endpoint tables.
|
|
171
|
+
- `examples/*.py`: Fully self-contained scripts demonstrating every major use-case. Including a comprehensive `run_tests.py` that validates the entire API sequentially against live hardware.
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/controlid/__init__.py
|
|
4
|
+
src/controlid/client.py
|
|
5
|
+
src/controlid/constants.py
|
|
6
|
+
src/controlid/exceptions.py
|
|
7
|
+
src/controlid/models.py
|
|
8
|
+
src/controlid_sdk.egg-info/PKG-INFO
|
|
9
|
+
src/controlid_sdk.egg-info/SOURCES.txt
|
|
10
|
+
src/controlid_sdk.egg-info/dependency_links.txt
|
|
11
|
+
src/controlid_sdk.egg-info/requires.txt
|
|
12
|
+
src/controlid_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
controlid
|