topdata-sdk 0.1.0__tar.gz → 0.2.2__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.
- topdata_sdk-0.2.2/LICENSE +21 -0
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/PKG-INFO +8 -1
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/pyproject.toml +6 -1
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/__init__.py +29 -5
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/constants.py +34 -24
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/exceptions.py +5 -0
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/image.py +12 -6
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/models.py +32 -18
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/server.py +21 -17
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/session.py +59 -32
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/wiegand.py +2 -3
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata_sdk.egg-info/PKG-INFO +8 -1
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata_sdk.egg-info/SOURCES.txt +1 -0
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata_sdk.egg-info/requires.txt +5 -0
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/tests/test_image.py +4 -2
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/tests/test_session.py +8 -3
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/tests/test_wiegand.py +1 -0
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/README.md +0 -0
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/setup.cfg +0 -0
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata_sdk.egg-info/dependency_links.txt +0 -0
- {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata_sdk.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tulio Amancio / Tsuriu Tech
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: topdata-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: A production-ready asynchronous Python SDK for Topdata facial reader devices (AiFace, Catraca Fit, Revolution, Box, Inner Ponto 4, Inner Acesso 2). WebSocket server model — the device connects to you.
|
|
5
5
|
Author-email: Tulio Amancio <root@tsuriu.com.br>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,12 +19,19 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
19
19
|
Classifier: Topic :: System :: Hardware
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
22
23
|
Requires-Dist: websockets>=12.0
|
|
23
24
|
Requires-Dist: pydantic>=2.0.0
|
|
24
25
|
Requires-Dist: Pillow>=10.0.0
|
|
25
26
|
Provides-Extra: dev
|
|
26
27
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
27
28
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
32
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: flake8>=6.0.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
28
35
|
|
|
29
36
|
# Topdata Python SDK
|
|
30
37
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "topdata-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Tulio Amancio", email="root@tsuriu.com.br" },
|
|
10
10
|
]
|
|
@@ -35,6 +35,11 @@ dependencies = [
|
|
|
35
35
|
dev = [
|
|
36
36
|
"pytest>=7.0.0",
|
|
37
37
|
"pytest-asyncio>=0.21.0",
|
|
38
|
+
"pytest-cov>=5.0.0",
|
|
39
|
+
"build>=1.0.0",
|
|
40
|
+
"twine>=4.0.0",
|
|
41
|
+
"black>=23.0.0",
|
|
42
|
+
"flake8>=6.0.0",
|
|
38
43
|
]
|
|
39
44
|
|
|
40
45
|
[project.urls]
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
from . import constants
|
|
2
|
-
from .
|
|
3
|
-
|
|
2
|
+
from .constants import (
|
|
3
|
+
AdminLevel,
|
|
4
|
+
AuthMode,
|
|
5
|
+
BackupNum,
|
|
6
|
+
CardFormat,
|
|
7
|
+
ServerVerify,
|
|
8
|
+
VerifyMode,
|
|
9
|
+
)
|
|
4
10
|
from .exceptions import (
|
|
5
11
|
CommandTimeoutError,
|
|
6
12
|
DeviceDisconnectedError,
|
|
@@ -8,7 +14,10 @@ from .exceptions import (
|
|
|
8
14
|
ProtocolError,
|
|
9
15
|
TopdataError,
|
|
10
16
|
)
|
|
17
|
+
from .image import prepare_image_record, validate_and_normalize
|
|
11
18
|
from .models import (
|
|
19
|
+
DaySection,
|
|
20
|
+
DayZone,
|
|
12
21
|
DeviceInfo,
|
|
13
22
|
LogEvent,
|
|
14
23
|
LogRecord,
|
|
@@ -17,8 +26,12 @@ from .models import (
|
|
|
17
26
|
SendlogResponse,
|
|
18
27
|
UserInfo,
|
|
19
28
|
UserListItem,
|
|
29
|
+
UserLockRecord,
|
|
30
|
+
WeekDay,
|
|
31
|
+
WeekZone,
|
|
20
32
|
)
|
|
21
|
-
from .
|
|
33
|
+
from .server import TopdataServer
|
|
34
|
+
from .session import TopdataDeviceSession
|
|
22
35
|
from .wiegand import (
|
|
23
36
|
format_wiegand10,
|
|
24
37
|
parse_wiegand10,
|
|
@@ -26,14 +39,20 @@ from .wiegand import (
|
|
|
26
39
|
wiegand26_to_wiegand10,
|
|
27
40
|
)
|
|
28
41
|
|
|
29
|
-
__version__ = "0.
|
|
42
|
+
__version__ = "0.2.2"
|
|
30
43
|
|
|
31
44
|
__all__ = [
|
|
32
45
|
# Core classes
|
|
33
46
|
"TopdataServer",
|
|
34
47
|
"TopdataDeviceSession",
|
|
35
|
-
# Constants
|
|
48
|
+
# Constants
|
|
36
49
|
"constants",
|
|
50
|
+
"AdminLevel",
|
|
51
|
+
"AuthMode",
|
|
52
|
+
"BackupNum",
|
|
53
|
+
"CardFormat",
|
|
54
|
+
"ServerVerify",
|
|
55
|
+
"VerifyMode",
|
|
37
56
|
# Exceptions
|
|
38
57
|
"TopdataError",
|
|
39
58
|
"CommandTimeoutError",
|
|
@@ -49,6 +68,11 @@ __all__ = [
|
|
|
49
68
|
"SendlogResponse",
|
|
50
69
|
"UserInfo",
|
|
51
70
|
"UserListItem",
|
|
71
|
+
"DaySection",
|
|
72
|
+
"DayZone",
|
|
73
|
+
"WeekDay",
|
|
74
|
+
"WeekZone",
|
|
75
|
+
"UserLockRecord",
|
|
52
76
|
# Image utilities
|
|
53
77
|
"prepare_image_record",
|
|
54
78
|
"validate_and_normalize",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
"""
|
|
3
4
|
Constants for the Topdata Facial Reader SDK.
|
|
4
5
|
"""
|
|
@@ -8,24 +9,27 @@ from enum import IntEnum
|
|
|
8
9
|
# ─── Backup Number (data type selector for setuserinfo/getuserinfo/deleteuser) ─
|
|
9
10
|
class BackupNum(IntEnum):
|
|
10
11
|
"""Backup number values used in setuserinfo, getuserinfo, and deleteuser commands."""
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
|
|
13
|
+
ALL = 0 # All data (no photo in setuserinfo; all data in getuserinfo/deleteuser)
|
|
14
|
+
PASSWORD = 10 # Password only
|
|
15
|
+
CARD = 11 # Card only
|
|
14
16
|
ALL_EXCEPT_BIOMETRY = 13 # All data except biometry (photo)
|
|
15
|
-
FACE = 50
|
|
17
|
+
FACE = 50 # Face photo only
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
# ─── Admin Level ───────────────────────────────────────────────────────────────
|
|
19
21
|
class AdminLevel(IntEnum):
|
|
20
22
|
"""User privilege levels on the device."""
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
|
|
24
|
+
USER = 0 # Standard user (facial recognition only)
|
|
25
|
+
ADMIN = 1 # Administrator (device menu access)
|
|
26
|
+
SUPER_USER = 2 # Super user (not recommended by manufacturer)
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
# ─── Authentication Mode (mode field in sendlog) ──────────────────────────────
|
|
27
30
|
class AuthMode(IntEnum):
|
|
28
31
|
"""Authentication method used in access events (sendlog.record.mode)."""
|
|
32
|
+
|
|
29
33
|
PASSWORD = 2
|
|
30
34
|
CARD = 3
|
|
31
35
|
FACE = 8
|
|
@@ -34,42 +38,48 @@ class AuthMode(IntEnum):
|
|
|
34
38
|
# ─── Verify Mode (verifymode in setdevinfo / setuserlock) ─────────────────────
|
|
35
39
|
class VerifyMode(IntEnum):
|
|
36
40
|
"""Verification mode for device/user access configuration."""
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
|
|
42
|
+
FACE_CARD_OR_PWD = 0 # Face, Card, or Password
|
|
43
|
+
PWD_ONLY = 2 # Password only
|
|
44
|
+
CARD_ONLY = 3 # Card only
|
|
45
|
+
FACE_ONLY = 8 # Face only
|
|
46
|
+
FACE_AND_PWD = 9 # Face and Password
|
|
47
|
+
CARD_AND_FACE = 10 # Card and Face
|
|
48
|
+
CARD_AND_PWD = 11 # Card and Password
|
|
44
49
|
FACE_AND_CARD_OR_PWD = 14 # Face and (Card or Password)
|
|
45
50
|
|
|
46
51
|
|
|
47
52
|
# ─── Server Verify Mode (server_verify in setdevinfo) ─────────────────────────
|
|
48
53
|
class ServerVerify(IntEnum):
|
|
49
54
|
"""Server verification mode for online/offline operation."""
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
|
|
56
|
+
OFFLINE = 0 # Offline only (device decides access)
|
|
57
|
+
ONLINE = 1 # Online only (server decides access)
|
|
58
|
+
AUTO = 2 # Auto switch between online and offline
|
|
53
59
|
|
|
54
60
|
|
|
55
61
|
# ─── Image Validation Limits ──────────────────────────────────────────────────
|
|
56
|
-
MAX_IMAGE_BYTES = 150 * 1024
|
|
57
|
-
MIN_RESOLUTION = (240, 320)
|
|
58
|
-
MAX_RESOLUTION = (800, 1280)
|
|
59
|
-
RECOMMENDED_RESOLUTION = (480, 640)
|
|
62
|
+
MAX_IMAGE_BYTES = 150 * 1024 # 150 KB
|
|
63
|
+
MIN_RESOLUTION = (240, 320) # width, height
|
|
64
|
+
MAX_RESOLUTION = (800, 1280) # width, height
|
|
65
|
+
RECOMMENDED_RESOLUTION = (480, 640) # width, height
|
|
66
|
+
|
|
60
67
|
|
|
61
68
|
# ─── Protocol Defaults ────────────────────────────────────────────────────────
|
|
62
69
|
DEFAULT_WS_PORT = 7792
|
|
63
70
|
DEFAULT_WS_PATH = "/pub/chat"
|
|
64
|
-
DEFAULT_COMMAND_TIMEOUT = 10.0
|
|
71
|
+
DEFAULT_COMMAND_TIMEOUT = 10.0 # seconds
|
|
72
|
+
|
|
65
73
|
|
|
66
74
|
# ─── Special Values ───────────────────────────────────────────────────────────
|
|
67
|
-
UNKNOWN_ENROLLID = 99999999
|
|
68
|
-
MAX_ENROLLID = 999_999_999_999
|
|
75
|
+
UNKNOWN_ENROLLID = 99999999 # enrollid used by firmware for unrecognized faces
|
|
76
|
+
MAX_ENROLLID = 999_999_999_999 # 12-digit maximum enrollid
|
|
77
|
+
|
|
69
78
|
|
|
70
79
|
# ─── Card Format (setdevlock.cardformat) ──────────────────────────────────────
|
|
71
80
|
class CardFormat(IntEnum):
|
|
72
81
|
"""Card number display format on the device."""
|
|
82
|
+
|
|
73
83
|
DECIMAL = 0
|
|
74
84
|
WIEGAND = 1
|
|
75
85
|
HEXADECIMAL = 2
|
|
@@ -3,11 +3,13 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
class TopdataError(Exception):
|
|
5
5
|
"""Base exception for the Topdata SDK."""
|
|
6
|
+
|
|
6
7
|
pass
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class CommandTimeoutError(TopdataError):
|
|
10
11
|
"""Raised when a command sent to the device does not receive a response within the timeout."""
|
|
12
|
+
|
|
11
13
|
def __init__(self, command: str, timeout: float):
|
|
12
14
|
self.command = command
|
|
13
15
|
self.timeout = timeout
|
|
@@ -16,6 +18,7 @@ class CommandTimeoutError(TopdataError):
|
|
|
16
18
|
|
|
17
19
|
class DeviceDisconnectedError(TopdataError):
|
|
18
20
|
"""Raised when the WebSocket connection to the device is lost during an operation."""
|
|
21
|
+
|
|
19
22
|
def __init__(self, sn: str | None = None, message: str | None = None):
|
|
20
23
|
self.sn = sn
|
|
21
24
|
msg = message or f"Device '{sn}' disconnected"
|
|
@@ -28,6 +31,7 @@ class InvalidImageError(TopdataError):
|
|
|
28
31
|
Possible reasons: not JPEG, file too large (>150KB after processing),
|
|
29
32
|
resolution outside 240x320 – 800x1280, or other quality issues.
|
|
30
33
|
"""
|
|
34
|
+
|
|
31
35
|
pass
|
|
32
36
|
|
|
33
37
|
|
|
@@ -39,6 +43,7 @@ class ProtocolError(TopdataError):
|
|
|
39
43
|
reason: The numeric reason code, if present.
|
|
40
44
|
msg: The human-readable error message from the device, if present.
|
|
41
45
|
"""
|
|
46
|
+
|
|
42
47
|
def __init__(self, command: str, reason: int | None = None, msg: str | None = None):
|
|
43
48
|
self.command = command
|
|
44
49
|
self.reason = reason
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
"""
|
|
3
4
|
Image validation and normalization for Topdata facial readers.
|
|
4
5
|
|
|
@@ -15,7 +16,12 @@ import io
|
|
|
15
16
|
|
|
16
17
|
from PIL import Image
|
|
17
18
|
|
|
18
|
-
from .constants import
|
|
19
|
+
from .constants import (
|
|
20
|
+
MAX_IMAGE_BYTES,
|
|
21
|
+
MAX_RESOLUTION,
|
|
22
|
+
MIN_RESOLUTION,
|
|
23
|
+
RECOMMENDED_RESOLUTION,
|
|
24
|
+
)
|
|
19
25
|
from .exceptions import InvalidImageError
|
|
20
26
|
|
|
21
27
|
|
|
@@ -49,13 +55,13 @@ def validate_and_normalize(image_data: bytes) -> bytes:
|
|
|
49
55
|
|
|
50
56
|
# Check JPEG format
|
|
51
57
|
if img.format and img.format.upper() not in ("JPEG", "JPG"):
|
|
52
|
-
raise InvalidImageError(
|
|
53
|
-
f"Image must be JPEG format, got '{img.format}'"
|
|
54
|
-
)
|
|
58
|
+
raise InvalidImageError(f"Image must be JPEG format, got '{img.format}'")
|
|
55
59
|
|
|
56
60
|
# Even if format is None (raw bytes), check if it's a valid JPEG by magic bytes
|
|
57
|
-
if not image_data[:2] == b
|
|
58
|
-
raise InvalidImageError(
|
|
61
|
+
if not image_data[:2] == b"\xff\xd8":
|
|
62
|
+
raise InvalidImageError(
|
|
63
|
+
"Image data does not start with JPEG magic bytes (FFD8)"
|
|
64
|
+
)
|
|
59
65
|
|
|
60
66
|
width, height = img.size
|
|
61
67
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
from typing import Optional
|
|
4
|
+
|
|
4
5
|
from pydantic import BaseModel, Field
|
|
5
6
|
|
|
6
7
|
|
|
@@ -11,6 +12,7 @@ class DeviceInfo(BaseModel):
|
|
|
11
12
|
Fields not applicable to facial readers (fpsize, usedfp, fpalgo,
|
|
12
13
|
intercom, floors, useosdp) are kept for protocol completeness.
|
|
13
14
|
"""
|
|
15
|
+
|
|
14
16
|
modelname: str
|
|
15
17
|
usersize: int = 0
|
|
16
18
|
facesize: int = 0
|
|
@@ -45,6 +47,7 @@ class UserListItem(BaseModel):
|
|
|
45
47
|
Each item represents one data type for a user (e.g., enrollid with
|
|
46
48
|
backupnum=10 means that user has a password, backupnum=50 means face, etc.).
|
|
47
49
|
"""
|
|
50
|
+
|
|
48
51
|
enrollid: int
|
|
49
52
|
admin: str | int = "0"
|
|
50
53
|
backupnum: int = 0
|
|
@@ -52,15 +55,16 @@ class UserListItem(BaseModel):
|
|
|
52
55
|
|
|
53
56
|
class UserInfo(BaseModel):
|
|
54
57
|
"""Detailed user information from getuserinfo response."""
|
|
58
|
+
|
|
55
59
|
enrollid: int
|
|
56
60
|
name: Optional[str] = None
|
|
57
61
|
admin: int = 0
|
|
58
62
|
card: Optional[int] = None
|
|
59
63
|
pwd: Optional[int] = None
|
|
60
|
-
faceflag: Optional[int] = None
|
|
61
|
-
enable: Optional[int] = None
|
|
64
|
+
faceflag: Optional[int] = None # 1 = has face data, 0 = no
|
|
65
|
+
enable: Optional[int] = None # 1 = enabled, 0 = disabled
|
|
62
66
|
backupnum: Optional[int] = None
|
|
63
|
-
record: Optional[str] = None
|
|
67
|
+
record: Optional[str] = None # Raw record value (photo base64 or other data)
|
|
64
68
|
# Error fields (present when result=false)
|
|
65
69
|
result: bool = True
|
|
66
70
|
reason: Optional[int] = None
|
|
@@ -69,17 +73,19 @@ class UserInfo(BaseModel):
|
|
|
69
73
|
|
|
70
74
|
class LogRecord(BaseModel):
|
|
71
75
|
"""A single access log record from sendlog or getalllog responses."""
|
|
76
|
+
|
|
72
77
|
enrollid: int
|
|
73
78
|
name: Optional[str] = None
|
|
74
|
-
time: str
|
|
75
|
-
mode: int = 0
|
|
76
|
-
inout: Optional[int] = None
|
|
77
|
-
event: int = 0
|
|
78
|
-
image: Optional[str] = None
|
|
79
|
+
time: str # "yyyy-MM-dd HH:mm:ss"
|
|
80
|
+
mode: int = 0 # 2=password, 3=card, 8=face
|
|
81
|
+
inout: Optional[int] = None # 0=entry, 1=exit (may vary)
|
|
82
|
+
event: int = 0 # Event type code
|
|
83
|
+
image: Optional[str] = None # Base64 photo (if use_logphoto=1)
|
|
79
84
|
|
|
80
85
|
|
|
81
86
|
class LogEvent(BaseModel):
|
|
82
87
|
"""Full sendlog message from the device."""
|
|
88
|
+
|
|
83
89
|
sn: str
|
|
84
90
|
count: int = 1
|
|
85
91
|
logindex: int = 0
|
|
@@ -88,6 +94,7 @@ class LogEvent(BaseModel):
|
|
|
88
94
|
|
|
89
95
|
class RegMessage(BaseModel):
|
|
90
96
|
"""Parsed 'reg' command from the device."""
|
|
97
|
+
|
|
91
98
|
cmd: str = "reg"
|
|
92
99
|
sn: str
|
|
93
100
|
devinfo: DeviceInfo
|
|
@@ -95,43 +102,50 @@ class RegMessage(BaseModel):
|
|
|
95
102
|
|
|
96
103
|
class RegResponse(BaseModel):
|
|
97
104
|
"""Response to the 'reg' command."""
|
|
105
|
+
|
|
98
106
|
ret: str = "reg"
|
|
99
107
|
result: bool = True
|
|
100
|
-
cloudtime: str
|
|
108
|
+
cloudtime: str # "yyyy-MM-dd HH:mm:ss"
|
|
101
109
|
|
|
102
110
|
|
|
103
111
|
class SendlogResponse(BaseModel):
|
|
104
112
|
"""Response to the 'sendlog' event (for online mode)."""
|
|
113
|
+
|
|
105
114
|
ret: str = "sendlog"
|
|
106
115
|
result: bool = True
|
|
107
|
-
cloudtime: str
|
|
108
|
-
message: Optional[str] = None
|
|
109
|
-
access: Optional[bool] = None
|
|
116
|
+
cloudtime: str # "yyyy-MM-dd HH:mm:ss"
|
|
117
|
+
message: Optional[str] = None # Custom message for device display
|
|
118
|
+
access: Optional[bool] = None # True=grant, False=deny (online mode)
|
|
110
119
|
|
|
111
120
|
|
|
112
121
|
class DaySection(BaseModel):
|
|
113
122
|
"""A time section within a dayzone."""
|
|
114
|
-
|
|
123
|
+
|
|
124
|
+
section: str # "HH:MM~HH:MM"
|
|
115
125
|
|
|
116
126
|
|
|
117
127
|
class DayZone(BaseModel):
|
|
118
128
|
"""A day zone containing time sections."""
|
|
129
|
+
|
|
119
130
|
day: list[DaySection] = Field(default_factory=list)
|
|
120
131
|
|
|
121
132
|
|
|
122
133
|
class WeekDay(BaseModel):
|
|
123
134
|
"""A day reference within a weekzone."""
|
|
124
|
-
|
|
135
|
+
|
|
136
|
+
day: int # dayzone index (1-based)
|
|
125
137
|
|
|
126
138
|
|
|
127
139
|
class WeekZone(BaseModel):
|
|
128
140
|
"""A week zone containing 7 day references (Sun-Sat)."""
|
|
141
|
+
|
|
129
142
|
week: list[WeekDay] = Field(default_factory=list)
|
|
130
143
|
|
|
131
144
|
|
|
132
145
|
class UserLockRecord(BaseModel):
|
|
133
146
|
"""A user access lock record for setuserlock."""
|
|
147
|
+
|
|
134
148
|
enrollid: int
|
|
135
|
-
weekzone: int
|
|
136
|
-
starttime: str
|
|
137
|
-
endtime: str
|
|
149
|
+
weekzone: int # weekzone ID
|
|
150
|
+
starttime: str # "yyyy-MM-dd HH:mm:ss"
|
|
151
|
+
endtime: str # "yyyy-MM-dd HH:mm:ss"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
"""
|
|
3
4
|
TopdataServer — WebSocket server that accepts connections from Topdata facial readers.
|
|
4
5
|
|
|
@@ -25,7 +26,6 @@ Usage::
|
|
|
25
26
|
await server.start()
|
|
26
27
|
"""
|
|
27
28
|
|
|
28
|
-
import asyncio
|
|
29
29
|
import json
|
|
30
30
|
import logging
|
|
31
31
|
from datetime import datetime
|
|
@@ -35,9 +35,7 @@ import websockets
|
|
|
35
35
|
from websockets.asyncio.server import Server, ServerConnection
|
|
36
36
|
|
|
37
37
|
from .constants import DEFAULT_COMMAND_TIMEOUT, DEFAULT_WS_PATH, DEFAULT_WS_PORT
|
|
38
|
-
from .exceptions import TopdataError
|
|
39
38
|
from .models import DeviceInfo, LogEvent, LogRecord
|
|
40
|
-
|
|
41
39
|
from .session import TopdataDeviceSession
|
|
42
40
|
|
|
43
41
|
logger = logging.getLogger("topdata.server")
|
|
@@ -72,9 +70,7 @@ class TopdataServer:
|
|
|
72
70
|
self.on_device_connected: Optional[
|
|
73
71
|
Callable[[TopdataDeviceSession], Awaitable[None]]
|
|
74
72
|
] = None
|
|
75
|
-
self.on_device_disconnected: Optional[
|
|
76
|
-
Callable[[str], Awaitable[None]]
|
|
77
|
-
] = None
|
|
73
|
+
self.on_device_disconnected: Optional[Callable[[str], Awaitable[None]]] = None
|
|
78
74
|
self.on_event: Optional[
|
|
79
75
|
Callable[[TopdataDeviceSession, LogEvent], Awaitable[dict | None]]
|
|
80
76
|
] = None
|
|
@@ -124,7 +120,9 @@ class TopdataServer:
|
|
|
124
120
|
raw_message = raw_message.decode("utf-8")
|
|
125
121
|
data = json.loads(raw_message)
|
|
126
122
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
127
|
-
logger.warning(
|
|
123
|
+
logger.warning(
|
|
124
|
+
"topdata.server.invalid_json", extra={"error": str(e)}
|
|
125
|
+
)
|
|
128
126
|
continue
|
|
129
127
|
|
|
130
128
|
# Route by message type
|
|
@@ -161,11 +159,13 @@ class TopdataServer:
|
|
|
161
159
|
|
|
162
160
|
# Respond to reg (mandatory — device loops reg until it gets this)
|
|
163
161
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
164
|
-
reg_response = json.dumps(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
162
|
+
reg_response = json.dumps(
|
|
163
|
+
{
|
|
164
|
+
"ret": "reg",
|
|
165
|
+
"result": True,
|
|
166
|
+
"cloudtime": now,
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
169
|
await websocket.send(reg_response)
|
|
170
170
|
|
|
171
171
|
logger.info(
|
|
@@ -242,11 +242,15 @@ class TopdataServer:
|
|
|
242
242
|
continue
|
|
243
243
|
|
|
244
244
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
245
|
-
await websocket.send(
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
245
|
+
await websocket.send(
|
|
246
|
+
json.dumps(
|
|
247
|
+
{
|
|
248
|
+
"ret": "senduser",
|
|
249
|
+
"result": True,
|
|
250
|
+
"cloudtime": now,
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
)
|
|
250
254
|
|
|
251
255
|
if self.on_user_sent:
|
|
252
256
|
try:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
"""
|
|
3
4
|
TopdataDeviceSession — per-device connection handler.
|
|
4
5
|
|
|
@@ -11,7 +12,6 @@ operations.
|
|
|
11
12
|
import asyncio
|
|
12
13
|
import json
|
|
13
14
|
import logging
|
|
14
|
-
from datetime import datetime
|
|
15
15
|
from typing import Any, Optional
|
|
16
16
|
|
|
17
17
|
from .constants import BackupNum, AdminLevel, MAX_ENROLLID
|
|
@@ -19,10 +19,17 @@ from .exceptions import (
|
|
|
19
19
|
CommandTimeoutError,
|
|
20
20
|
DeviceDisconnectedError,
|
|
21
21
|
ProtocolError,
|
|
22
|
-
TopdataError,
|
|
23
22
|
)
|
|
24
23
|
from .image import prepare_image_record
|
|
25
|
-
from .models import
|
|
24
|
+
from .models import (
|
|
25
|
+
DayZone,
|
|
26
|
+
DeviceInfo,
|
|
27
|
+
LogRecord,
|
|
28
|
+
UserInfo,
|
|
29
|
+
UserListItem,
|
|
30
|
+
UserLockRecord,
|
|
31
|
+
WeekZone,
|
|
32
|
+
)
|
|
26
33
|
from .wiegand import wiegand10_to_wiegand26, wiegand26_to_wiegand10
|
|
27
34
|
|
|
28
35
|
logger = logging.getLogger("topdata.session")
|
|
@@ -74,9 +81,7 @@ class TopdataDeviceSession:
|
|
|
74
81
|
self._connected = False
|
|
75
82
|
# Cancel any pending command
|
|
76
83
|
if self._pending_future and not self._pending_future.done():
|
|
77
|
-
self._pending_future.set_exception(
|
|
78
|
-
DeviceDisconnectedError(self._sn)
|
|
79
|
-
)
|
|
84
|
+
self._pending_future.set_exception(DeviceDisconnectedError(self._sn))
|
|
80
85
|
|
|
81
86
|
def _handle_response(self, data: dict) -> None:
|
|
82
87
|
"""Called by the server when a 'ret' message is received for this session.
|
|
@@ -125,7 +130,9 @@ class TopdataDeviceSession:
|
|
|
125
130
|
except asyncio.TimeoutError:
|
|
126
131
|
raise CommandTimeoutError(cmd_name, effective_timeout)
|
|
127
132
|
except Exception as e:
|
|
128
|
-
if isinstance(
|
|
133
|
+
if isinstance(
|
|
134
|
+
e, (CommandTimeoutError, DeviceDisconnectedError, ProtocolError)
|
|
135
|
+
):
|
|
129
136
|
raise
|
|
130
137
|
raise DeviceDisconnectedError(self._sn, str(e))
|
|
131
138
|
finally:
|
|
@@ -224,11 +231,13 @@ class TopdataDeviceSession:
|
|
|
224
231
|
Returns:
|
|
225
232
|
UserInfo with the requested data fields populated.
|
|
226
233
|
"""
|
|
227
|
-
resp = await self._send_command(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
234
|
+
resp = await self._send_command(
|
|
235
|
+
{
|
|
236
|
+
"cmd": "getuserinfo",
|
|
237
|
+
"enrollid": enrollid,
|
|
238
|
+
"backupnum": int(backup_num),
|
|
239
|
+
}
|
|
240
|
+
)
|
|
232
241
|
return UserInfo.model_validate(resp)
|
|
233
242
|
|
|
234
243
|
async def set_user(
|
|
@@ -335,11 +344,13 @@ class TopdataDeviceSession:
|
|
|
335
344
|
Returns:
|
|
336
345
|
True if successful.
|
|
337
346
|
"""
|
|
338
|
-
resp = await self._send_command(
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
347
|
+
resp = await self._send_command(
|
|
348
|
+
{
|
|
349
|
+
"cmd": "deleteuser",
|
|
350
|
+
"enrollid": enrollid,
|
|
351
|
+
"backupnum": int(backup_num),
|
|
352
|
+
}
|
|
353
|
+
)
|
|
343
354
|
return resp.get("result", False)
|
|
344
355
|
|
|
345
356
|
async def delete_all_users(self) -> bool:
|
|
@@ -368,9 +379,15 @@ class TopdataDeviceSession:
|
|
|
368
379
|
"""
|
|
369
380
|
payload: dict[str, Any] = {"cmd": "setdevinfo"}
|
|
370
381
|
for key in (
|
|
371
|
-
"door_opentime",
|
|
372
|
-
"
|
|
373
|
-
"
|
|
382
|
+
"door_opentime",
|
|
383
|
+
"door_password",
|
|
384
|
+
"verifymode",
|
|
385
|
+
"access_denied_nolog",
|
|
386
|
+
"volume",
|
|
387
|
+
"stranger_lock",
|
|
388
|
+
"server_verify",
|
|
389
|
+
"use_logphoto",
|
|
390
|
+
"stranger_photo",
|
|
374
391
|
):
|
|
375
392
|
if key in kwargs:
|
|
376
393
|
payload[key] = kwargs[key]
|
|
@@ -381,8 +398,8 @@ class TopdataDeviceSession:
|
|
|
381
398
|
async def set_device_lock(
|
|
382
399
|
self,
|
|
383
400
|
card_format: int | None = None,
|
|
384
|
-
day_zones: list[dict] | None = None,
|
|
385
|
-
week_zones: list[dict] | None = None,
|
|
401
|
+
day_zones: list[dict | DayZone] | None = None,
|
|
402
|
+
week_zones: list[dict | WeekZone] | None = None,
|
|
386
403
|
) -> bool:
|
|
387
404
|
"""Configure device lock settings (card format, day/week zones).
|
|
388
405
|
|
|
@@ -396,18 +413,23 @@ class TopdataDeviceSession:
|
|
|
396
413
|
"""
|
|
397
414
|
payload: dict[str, Any] = {"cmd": "setdevlock"}
|
|
398
415
|
if card_format is not None:
|
|
399
|
-
payload["cardformat"] = card_format
|
|
416
|
+
payload["cardformat"] = int(card_format)
|
|
400
417
|
if day_zones is not None:
|
|
401
|
-
payload["dayzone"] =
|
|
418
|
+
payload["dayzone"] = [
|
|
419
|
+
dz.model_dump() if hasattr(dz, "model_dump") else dz for dz in day_zones
|
|
420
|
+
]
|
|
402
421
|
if week_zones is not None:
|
|
403
|
-
payload["weekzone"] =
|
|
422
|
+
payload["weekzone"] = [
|
|
423
|
+
wz.model_dump() if hasattr(wz, "model_dump") else wz
|
|
424
|
+
for wz in week_zones
|
|
425
|
+
]
|
|
404
426
|
|
|
405
427
|
resp = await self._send_command(payload)
|
|
406
428
|
return resp.get("result", False)
|
|
407
429
|
|
|
408
430
|
async def set_user_lock(
|
|
409
431
|
self,
|
|
410
|
-
records: list[dict],
|
|
432
|
+
records: list[dict | UserLockRecord],
|
|
411
433
|
verify_mode: int = 0,
|
|
412
434
|
) -> bool:
|
|
413
435
|
"""Configure per-user access time restrictions (setuserlock).
|
|
@@ -419,12 +441,17 @@ class TopdataDeviceSession:
|
|
|
419
441
|
Returns:
|
|
420
442
|
True if successful.
|
|
421
443
|
"""
|
|
422
|
-
|
|
423
|
-
"
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
444
|
+
serialized_records = [
|
|
445
|
+
r.model_dump() if hasattr(r, "model_dump") else r for r in records
|
|
446
|
+
]
|
|
447
|
+
resp = await self._send_command(
|
|
448
|
+
{
|
|
449
|
+
"cmd": "setuserlock",
|
|
450
|
+
"count": len(serialized_records),
|
|
451
|
+
"verifymode": int(verify_mode),
|
|
452
|
+
"record": serialized_records,
|
|
453
|
+
}
|
|
454
|
+
)
|
|
428
455
|
return resp.get("result", False)
|
|
429
456
|
|
|
430
457
|
async def get_all_logs(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
"""
|
|
3
4
|
Wiegand format conversion utilities for Topdata card numbers.
|
|
4
5
|
|
|
@@ -85,9 +86,7 @@ def parse_wiegand10(value: str) -> tuple[int, int]:
|
|
|
85
86
|
"""
|
|
86
87
|
value = value.strip()
|
|
87
88
|
if len(value) != 8 or not value.isdigit():
|
|
88
|
-
raise ValueError(
|
|
89
|
-
f"Wiegand 10 value must be exactly 8 digits, got '{value}'"
|
|
90
|
-
)
|
|
89
|
+
raise ValueError(f"Wiegand 10 value must be exactly 8 digits, got '{value}'")
|
|
91
90
|
facility_code = int(value[:3])
|
|
92
91
|
card_number = int(value[3:])
|
|
93
92
|
return facility_code, card_number
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: topdata-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: A production-ready asynchronous Python SDK for Topdata facial reader devices (AiFace, Catraca Fit, Revolution, Box, Inner Ponto 4, Inner Acesso 2). WebSocket server model — the device connects to you.
|
|
5
5
|
Author-email: Tulio Amancio <root@tsuriu.com.br>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,12 +19,19 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
19
19
|
Classifier: Topic :: System :: Hardware
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
22
23
|
Requires-Dist: websockets>=12.0
|
|
23
24
|
Requires-Dist: pydantic>=2.0.0
|
|
24
25
|
Requires-Dist: Pillow>=10.0.0
|
|
25
26
|
Provides-Extra: dev
|
|
26
27
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
27
28
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
32
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: flake8>=6.0.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
28
35
|
|
|
29
36
|
# Topdata Python SDK
|
|
30
37
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Tests for image validation and normalization."""
|
|
2
|
+
|
|
2
3
|
import io
|
|
3
4
|
import pytest
|
|
4
5
|
from PIL import Image
|
|
@@ -46,7 +47,7 @@ class TestValidateAndNormalize:
|
|
|
46
47
|
data = _make_jpeg(480, 640)
|
|
47
48
|
result = validate_and_normalize(data)
|
|
48
49
|
assert len(result) <= MAX_IMAGE_BYTES
|
|
49
|
-
assert result[:2] == b
|
|
50
|
+
assert result[:2] == b"\xff\xd8" # JPEG magic bytes
|
|
50
51
|
|
|
51
52
|
def test_empty_data_raises(self):
|
|
52
53
|
with pytest.raises(InvalidImageError, match="empty"):
|
|
@@ -102,11 +103,12 @@ class TestPrepareImageRecord:
|
|
|
102
103
|
|
|
103
104
|
def test_base64_is_valid(self):
|
|
104
105
|
import base64
|
|
106
|
+
|
|
105
107
|
data = _make_jpeg()
|
|
106
108
|
result = prepare_image_record(data)
|
|
107
109
|
b64_part = result.split(",", 1)[1]
|
|
108
110
|
decoded = base64.b64decode(b64_part)
|
|
109
|
-
assert decoded[:2] == b
|
|
111
|
+
assert decoded[:2] == b"\xff\xd8"
|
|
110
112
|
|
|
111
113
|
def test_invalid_image_raises(self):
|
|
112
114
|
with pytest.raises(InvalidImageError):
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Uses a mock WebSocket transport to simulate device responses without real hardware.
|
|
4
4
|
"""
|
|
5
|
+
|
|
5
6
|
import asyncio
|
|
6
7
|
import json
|
|
7
8
|
import pytest
|
|
@@ -52,7 +53,9 @@ class MockWebSocket:
|
|
|
52
53
|
return {}
|
|
53
54
|
|
|
54
55
|
|
|
55
|
-
def _make_session(
|
|
56
|
+
def _make_session(
|
|
57
|
+
ws: MockWebSocket = None, timeout: float = 2.0
|
|
58
|
+
) -> TopdataDeviceSession:
|
|
56
59
|
"""Create a session with a mock WebSocket and test device info."""
|
|
57
60
|
if ws is None:
|
|
58
61
|
ws = MockWebSocket()
|
|
@@ -65,8 +68,11 @@ def _make_session(ws: MockWebSocket = None, timeout: float = 2.0) -> TopdataDevi
|
|
|
65
68
|
)
|
|
66
69
|
|
|
67
70
|
|
|
68
|
-
async def _send_with_response(
|
|
71
|
+
async def _send_with_response(
|
|
72
|
+
session: TopdataDeviceSession, ws: MockWebSocket, response: dict
|
|
73
|
+
):
|
|
69
74
|
"""Helper: schedule a response delivery after a brief delay, simulating device latency."""
|
|
75
|
+
|
|
70
76
|
async def deliver():
|
|
71
77
|
await asyncio.sleep(0.05)
|
|
72
78
|
session._handle_response(response)
|
|
@@ -332,7 +338,6 @@ class TestCommandSerialization:
|
|
|
332
338
|
assert execution_order.index("done1") < execution_order.index("resp2")
|
|
333
339
|
|
|
334
340
|
|
|
335
|
-
|
|
336
341
|
class TestCommandTimeout:
|
|
337
342
|
@pytest.mark.asyncio
|
|
338
343
|
async def test_timeout_raises(self):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|