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.
Files changed (21) hide show
  1. topdata_sdk-0.2.2/LICENSE +21 -0
  2. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/PKG-INFO +8 -1
  3. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/pyproject.toml +6 -1
  4. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/__init__.py +29 -5
  5. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/constants.py +34 -24
  6. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/exceptions.py +5 -0
  7. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/image.py +12 -6
  8. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/models.py +32 -18
  9. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/server.py +21 -17
  10. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/session.py +59 -32
  11. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata/wiegand.py +2 -3
  12. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata_sdk.egg-info/PKG-INFO +8 -1
  13. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata_sdk.egg-info/SOURCES.txt +1 -0
  14. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata_sdk.egg-info/requires.txt +5 -0
  15. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/tests/test_image.py +4 -2
  16. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/tests/test_session.py +8 -3
  17. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/tests/test_wiegand.py +1 -0
  18. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/README.md +0 -0
  19. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/setup.cfg +0 -0
  20. {topdata_sdk-0.1.0 → topdata_sdk-0.2.2}/src/topdata_sdk.egg-info/dependency_links.txt +0 -0
  21. {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.1.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.1.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 .server import TopdataServer
3
- from .session import TopdataDeviceSession
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 .image import prepare_image_record, validate_and_normalize
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.1.0"
42
+ __version__ = "0.2.2"
30
43
 
31
44
  __all__ = [
32
45
  # Core classes
33
46
  "TopdataServer",
34
47
  "TopdataDeviceSession",
35
- # Constants module
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
- ALL = 0 # All data (no photo in setuserinfo; all data in getuserinfo/deleteuser)
12
- PASSWORD = 10 # Password only
13
- CARD = 11 # Card only
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 # Face photo only
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
- USER = 0 # Standard user (facial recognition only)
22
- ADMIN = 1 # Administrator (device menu access)
23
- SUPER_USER = 2 # Super user (not recommended by manufacturer)
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
- FACE_CARD_OR_PWD = 0 # Face, Card, or Password
38
- PWD_ONLY = 2 # Password only
39
- CARD_ONLY = 3 # Card only
40
- FACE_ONLY = 8 # Face only
41
- FACE_AND_PWD = 9 # Face and Password
42
- CARD_AND_FACE = 10 # Card and Face
43
- CARD_AND_PWD = 11 # Card and Password
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
- OFFLINE = 0 # Offline only (device decides access)
51
- ONLINE = 1 # Online only (server decides access)
52
- AUTO = 2 # Auto switch between online and offline
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 # 150 KB
57
- MIN_RESOLUTION = (240, 320) # width, height
58
- MAX_RESOLUTION = (800, 1280) # width, height
59
- RECOMMENDED_RESOLUTION = (480, 640) # width, height
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 # seconds
71
+ DEFAULT_COMMAND_TIMEOUT = 10.0 # seconds
72
+
65
73
 
66
74
  # ─── Special Values ───────────────────────────────────────────────────────────
67
- UNKNOWN_ENROLLID = 99999999 # enrollid used by firmware for unrecognized faces
68
- MAX_ENROLLID = 999_999_999_999 # 12-digit maximum enrollid
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 MAX_IMAGE_BYTES, MAX_RESOLUTION, MIN_RESOLUTION, RECOMMENDED_RESOLUTION
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'\xff\xd8':
58
- raise InvalidImageError("Image data does not start with JPEG magic bytes (FFD8)")
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
- from datetime import datetime
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 # 1 = has face data, 0 = no
61
- enable: Optional[int] = None # 1 = enabled, 0 = disabled
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 # Raw record value (photo base64 or other data)
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 # "yyyy-MM-dd HH:mm:ss"
75
- mode: int = 0 # 2=password, 3=card, 8=face
76
- inout: Optional[int] = None # 0=entry, 1=exit (may vary)
77
- event: int = 0 # Event type code
78
- image: Optional[str] = None # Base64 photo (if use_logphoto=1)
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 # "yyyy-MM-dd HH:mm:ss"
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 # "yyyy-MM-dd HH:mm:ss"
108
- message: Optional[str] = None # Custom message for device display
109
- access: Optional[bool] = None # True=grant, False=deny (online mode)
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
- section: str # "HH:MM~HH:MM"
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
- day: int # dayzone index (1-based)
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 # weekzone ID
136
- starttime: str # "yyyy-MM-dd HH:mm:ss"
137
- endtime: str # "yyyy-MM-dd HH:mm:ss"
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("topdata.server.invalid_json", extra={"error": str(e)})
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
- "ret": "reg",
166
- "result": True,
167
- "cloudtime": now,
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(json.dumps({
246
- "ret": "senduser",
247
- "result": True,
248
- "cloudtime": now,
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 DeviceInfo, LogRecord, UserInfo, UserListItem
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(e, (CommandTimeoutError, DeviceDisconnectedError, ProtocolError)):
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
- "cmd": "getuserinfo",
229
- "enrollid": enrollid,
230
- "backupnum": int(backup_num),
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
- "cmd": "deleteuser",
340
- "enrollid": enrollid,
341
- "backupnum": int(backup_num),
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", "door_password", "verifymode",
372
- "access_denied_nolog", "volume", "stranger_lock",
373
- "server_verify", "use_logphoto", "stranger_photo",
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"] = day_zones
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"] = week_zones
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
- resp = await self._send_command({
423
- "cmd": "setuserlock",
424
- "count": len(records),
425
- "verifymode": verify_mode,
426
- "record": records,
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.1.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,3 +1,4 @@
1
+ LICENSE
1
2
  README.md
2
3
  pyproject.toml
3
4
  src/topdata/__init__.py
@@ -5,3 +5,8 @@ Pillow>=10.0.0
5
5
  [dev]
6
6
  pytest>=7.0.0
7
7
  pytest-asyncio>=0.21.0
8
+ pytest-cov>=5.0.0
9
+ build>=1.0.0
10
+ twine>=4.0.0
11
+ black>=23.0.0
12
+ flake8>=6.0.0
@@ -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'\xff\xd8' # JPEG magic bytes
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'\xff\xd8'
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(ws: MockWebSocket = None, timeout: float = 2.0) -> TopdataDeviceSession:
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(session: TopdataDeviceSession, ws: MockWebSocket, response: dict):
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):
@@ -1,4 +1,5 @@
1
1
  """Tests for Wiegand conversion utilities."""
2
+
2
3
  import pytest
3
4
  from topdata.wiegand import (
4
5
  format_wiegand10,
File without changes
File without changes