controlid-sdk 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
controlid/client.py ADDED
@@ -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&timestamp={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)
controlid/constants.py ADDED
@@ -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
controlid/models.py ADDED
@@ -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,9 @@
1
+ controlid/__init__.py,sha256=D4xaa7Gq_Zmx68QOkkyAiyin3H70xpJNX3krrgmMp7E,869
2
+ controlid/client.py,sha256=7VGuzNai7iyC1dPX7wqZJqkH2TfKk0q_ahfU_pqV3WE,22075
3
+ controlid/constants.py,sha256=l4Nmx-jIXN4zE79Y4UqJvD5ZPgsyIcYOaRKBwuUM_lw,2068
4
+ controlid/exceptions.py,sha256=LXCsDIphLkPwgiFqqcdNnvPr1ZrSSG03oc8vnWrDKUo,651
5
+ controlid/models.py,sha256=fuQxZHTZt59ng-ZfMPKNS7TnINGQmY2aQQx3qfCI3_U,4135
6
+ controlid_sdk-0.1.0.dist-info/METADATA,sha256=nO4eR7io7smW-WF2n_mI0PwU3aMsZ8JItRqEp4uNE6g,6750
7
+ controlid_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ controlid_sdk-0.1.0.dist-info/top_level.txt,sha256=kSKmrNmAEx26i0W8Rs_Fn-pg7EB9uT0ILrG6PUp1TZY,10
9
+ controlid_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ controlid