topdata-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.
topdata/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ from . import constants
2
+ from .server import TopdataServer
3
+ from .session import TopdataDeviceSession
4
+ from .exceptions import (
5
+ CommandTimeoutError,
6
+ DeviceDisconnectedError,
7
+ InvalidImageError,
8
+ ProtocolError,
9
+ TopdataError,
10
+ )
11
+ from .models import (
12
+ DeviceInfo,
13
+ LogEvent,
14
+ LogRecord,
15
+ RegMessage,
16
+ RegResponse,
17
+ SendlogResponse,
18
+ UserInfo,
19
+ UserListItem,
20
+ )
21
+ from .image import prepare_image_record, validate_and_normalize
22
+ from .wiegand import (
23
+ format_wiegand10,
24
+ parse_wiegand10,
25
+ wiegand10_to_wiegand26,
26
+ wiegand26_to_wiegand10,
27
+ )
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ # Core classes
33
+ "TopdataServer",
34
+ "TopdataDeviceSession",
35
+ # Constants module
36
+ "constants",
37
+ # Exceptions
38
+ "TopdataError",
39
+ "CommandTimeoutError",
40
+ "DeviceDisconnectedError",
41
+ "InvalidImageError",
42
+ "ProtocolError",
43
+ # Models
44
+ "DeviceInfo",
45
+ "LogEvent",
46
+ "LogRecord",
47
+ "RegMessage",
48
+ "RegResponse",
49
+ "SendlogResponse",
50
+ "UserInfo",
51
+ "UserListItem",
52
+ # Image utilities
53
+ "prepare_image_record",
54
+ "validate_and_normalize",
55
+ # Wiegand utilities
56
+ "format_wiegand10",
57
+ "parse_wiegand10",
58
+ "wiegand10_to_wiegand26",
59
+ "wiegand26_to_wiegand10",
60
+ ]
topdata/constants.py ADDED
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+ """
3
+ Constants for the Topdata Facial Reader SDK.
4
+ """
5
+ from enum import IntEnum
6
+
7
+
8
+ # ─── Backup Number (data type selector for setuserinfo/getuserinfo/deleteuser) ─
9
+ class BackupNum(IntEnum):
10
+ """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
14
+ ALL_EXCEPT_BIOMETRY = 13 # All data except biometry (photo)
15
+ FACE = 50 # Face photo only
16
+
17
+
18
+ # ─── Admin Level ───────────────────────────────────────────────────────────────
19
+ class AdminLevel(IntEnum):
20
+ """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)
24
+
25
+
26
+ # ─── Authentication Mode (mode field in sendlog) ──────────────────────────────
27
+ class AuthMode(IntEnum):
28
+ """Authentication method used in access events (sendlog.record.mode)."""
29
+ PASSWORD = 2
30
+ CARD = 3
31
+ FACE = 8
32
+
33
+
34
+ # ─── Verify Mode (verifymode in setdevinfo / setuserlock) ─────────────────────
35
+ class VerifyMode(IntEnum):
36
+ """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
44
+ FACE_AND_CARD_OR_PWD = 14 # Face and (Card or Password)
45
+
46
+
47
+ # ─── Server Verify Mode (server_verify in setdevinfo) ─────────────────────────
48
+ class ServerVerify(IntEnum):
49
+ """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
53
+
54
+
55
+ # ─── 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
60
+
61
+ # ─── Protocol Defaults ────────────────────────────────────────────────────────
62
+ DEFAULT_WS_PORT = 7792
63
+ DEFAULT_WS_PATH = "/pub/chat"
64
+ DEFAULT_COMMAND_TIMEOUT = 10.0 # seconds
65
+
66
+ # ─── Special Values ───────────────────────────────────────────────────────────
67
+ UNKNOWN_ENROLLID = 99999999 # enrollid used by firmware for unrecognized faces
68
+ MAX_ENROLLID = 999_999_999_999 # 12-digit maximum enrollid
69
+
70
+ # ─── Card Format (setdevlock.cardformat) ──────────────────────────────────────
71
+ class CardFormat(IntEnum):
72
+ """Card number display format on the device."""
73
+ DECIMAL = 0
74
+ WIEGAND = 1
75
+ HEXADECIMAL = 2
topdata/exceptions.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class TopdataError(Exception):
5
+ """Base exception for the Topdata SDK."""
6
+ pass
7
+
8
+
9
+ class CommandTimeoutError(TopdataError):
10
+ """Raised when a command sent to the device does not receive a response within the timeout."""
11
+ def __init__(self, command: str, timeout: float):
12
+ self.command = command
13
+ self.timeout = timeout
14
+ super().__init__(f"Command '{command}' timed out after {timeout}s")
15
+
16
+
17
+ class DeviceDisconnectedError(TopdataError):
18
+ """Raised when the WebSocket connection to the device is lost during an operation."""
19
+ def __init__(self, sn: str | None = None, message: str | None = None):
20
+ self.sn = sn
21
+ msg = message or f"Device '{sn}' disconnected"
22
+ super().__init__(msg)
23
+
24
+
25
+ class InvalidImageError(TopdataError):
26
+ """Raised when an image fails validation before being sent to the device.
27
+
28
+ Possible reasons: not JPEG, file too large (>150KB after processing),
29
+ resolution outside 240x320 – 800x1280, or other quality issues.
30
+ """
31
+ pass
32
+
33
+
34
+ class ProtocolError(TopdataError):
35
+ """Raised when the device returns result:false in a command response.
36
+
37
+ Attributes:
38
+ command: The command name (ret field value).
39
+ reason: The numeric reason code, if present.
40
+ msg: The human-readable error message from the device, if present.
41
+ """
42
+ def __init__(self, command: str, reason: int | None = None, msg: str | None = None):
43
+ self.command = command
44
+ self.reason = reason
45
+ self.msg = msg
46
+ parts = [f"Command '{command}' failed"]
47
+ if reason is not None:
48
+ parts.append(f"reason={reason}")
49
+ if msg:
50
+ parts.append(f"msg='{msg}'")
51
+ super().__init__(", ".join(parts))
topdata/image.py ADDED
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+ """
3
+ Image validation and normalization for Topdata facial readers.
4
+
5
+ Requirements per the Topdata manual:
6
+ - Format: JPEG only
7
+ - File size: < 150 KB
8
+ - Resolution: between 240x320 and 800x1280 px
9
+ - Recommended: 480x640 px
10
+ - Single person, vertical face, no mask/hat/sunglasses, good lighting
11
+ """
12
+
13
+ import base64
14
+ import io
15
+
16
+ from PIL import Image
17
+
18
+ from .constants import MAX_IMAGE_BYTES, MAX_RESOLUTION, MIN_RESOLUTION, RECOMMENDED_RESOLUTION
19
+ from .exceptions import InvalidImageError
20
+
21
+
22
+ def validate_and_normalize(image_data: bytes) -> bytes:
23
+ """Validate and normalize an image for the Topdata facial reader.
24
+
25
+ Checks:
26
+ 1. Must be JPEG format.
27
+ 2. Resolution must be within MIN_RESOLUTION..MAX_RESOLUTION.
28
+ 3. File size must be < MAX_IMAGE_BYTES (150KB).
29
+
30
+ If the image is too large (file size) or resolution exceeds MAX_RESOLUTION,
31
+ it will be downscaled to RECOMMENDED_RESOLUTION and re-compressed.
32
+
33
+ Args:
34
+ image_data: Raw image bytes.
35
+
36
+ Returns:
37
+ Validated (and possibly normalized) JPEG bytes.
38
+
39
+ Raises:
40
+ InvalidImageError: If the image cannot be processed or doesn't meet requirements.
41
+ """
42
+ if not image_data:
43
+ raise InvalidImageError("Image data is empty")
44
+
45
+ try:
46
+ img = Image.open(io.BytesIO(image_data))
47
+ except Exception as e:
48
+ raise InvalidImageError(f"Cannot open image: {e}")
49
+
50
+ # Check JPEG format
51
+ 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
+ )
55
+
56
+ # 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)")
59
+
60
+ width, height = img.size
61
+
62
+ # Check minimum resolution
63
+ if width < MIN_RESOLUTION[0] or height < MIN_RESOLUTION[1]:
64
+ raise InvalidImageError(
65
+ f"Image resolution {width}x{height} is below minimum "
66
+ f"{MIN_RESOLUTION[0]}x{MIN_RESOLUTION[1]}"
67
+ )
68
+
69
+ needs_resize = False
70
+
71
+ # Check if resolution exceeds maximum
72
+ if width > MAX_RESOLUTION[0] or height > MAX_RESOLUTION[1]:
73
+ needs_resize = True
74
+
75
+ # Check file size
76
+ if len(image_data) > MAX_IMAGE_BYTES:
77
+ needs_resize = True
78
+
79
+ if needs_resize:
80
+ # Downscale to recommended resolution, maintaining aspect ratio
81
+ target_w, target_h = RECOMMENDED_RESOLUTION
82
+ img.thumbnail((target_w, target_h), Image.LANCZOS)
83
+
84
+ # Re-encode as JPEG with quality tuning to fit under size limit
85
+ for quality in (85, 75, 65, 55, 45):
86
+ buffer = io.BytesIO()
87
+ # Convert to RGB if necessary (e.g., RGBA, P mode)
88
+ if img.mode not in ("RGB", "L"):
89
+ img = img.convert("RGB")
90
+ img.save(buffer, format="JPEG", quality=quality, optimize=True)
91
+ result = buffer.getvalue()
92
+ if len(result) <= MAX_IMAGE_BYTES:
93
+ return result
94
+
95
+ raise InvalidImageError(
96
+ f"Cannot compress image below {MAX_IMAGE_BYTES} bytes "
97
+ f"even at minimum quality"
98
+ )
99
+
100
+ return image_data
101
+
102
+
103
+ def prepare_image_record(image_data: bytes) -> str:
104
+ """Validate image and return the base64 string for the setuserinfo 'record' field.
105
+
106
+ The Topdata protocol expects the record field to contain a data URI:
107
+ ``data:image/jpeg;base64,<base64_data>``
108
+
109
+ Args:
110
+ image_data: Raw image bytes.
111
+
112
+ Returns:
113
+ Data URI string ready for the protocol's record field.
114
+
115
+ Raises:
116
+ InvalidImageError: If the image fails validation.
117
+ """
118
+ validated = validate_and_normalize(image_data)
119
+ b64 = base64.b64encode(validated).decode("ascii")
120
+ return f"data:image/jpeg;base64,{b64}"
topdata/models.py ADDED
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+ from datetime import datetime
3
+ from typing import Optional
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class DeviceInfo(BaseModel):
8
+ """Device information from the 'reg' handshake (devinfo object).
9
+
10
+ All capacity/usage fields reflect the device's current state.
11
+ Fields not applicable to facial readers (fpsize, usedfp, fpalgo,
12
+ intercom, floors, useosdp) are kept for protocol completeness.
13
+ """
14
+ modelname: str
15
+ usersize: int = 0
16
+ facesize: int = 0
17
+ fpsize: int = 0
18
+ cardsize: int = 0
19
+ pwdsize: int = 0
20
+ logsize: int = 0
21
+ useduser: int = 0
22
+ usedface: int = 0
23
+ usedfp: int = 0
24
+ usedcard: int = 0
25
+ usedpwd: int = 0
26
+ usedlog: int = 0
27
+ usednewlog: int = 0
28
+ usedrtlog: int = 0
29
+ netinuse: int = 0
30
+ usb4g: int = 0
31
+ fpalgo: Optional[str] = None
32
+ firmware: str = ""
33
+ time: Optional[str] = None
34
+ intercom: int = 0
35
+ floors: int = 0
36
+ charid: int = 0
37
+ useosdp: int = 0
38
+ dislanguage: int = 0
39
+ mac: Optional[str] = None
40
+
41
+
42
+ class UserListItem(BaseModel):
43
+ """A single record from the getuserlist response.
44
+
45
+ Each item represents one data type for a user (e.g., enrollid with
46
+ backupnum=10 means that user has a password, backupnum=50 means face, etc.).
47
+ """
48
+ enrollid: int
49
+ admin: str | int = "0"
50
+ backupnum: int = 0
51
+
52
+
53
+ class UserInfo(BaseModel):
54
+ """Detailed user information from getuserinfo response."""
55
+ enrollid: int
56
+ name: Optional[str] = None
57
+ admin: int = 0
58
+ card: Optional[int] = None
59
+ pwd: Optional[int] = None
60
+ faceflag: Optional[int] = None # 1 = has face data, 0 = no
61
+ enable: Optional[int] = None # 1 = enabled, 0 = disabled
62
+ backupnum: Optional[int] = None
63
+ record: Optional[str] = None # Raw record value (photo base64 or other data)
64
+ # Error fields (present when result=false)
65
+ result: bool = True
66
+ reason: Optional[int] = None
67
+ msg: Optional[str] = None
68
+
69
+
70
+ class LogRecord(BaseModel):
71
+ """A single access log record from sendlog or getalllog responses."""
72
+ enrollid: int
73
+ 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
+
80
+
81
+ class LogEvent(BaseModel):
82
+ """Full sendlog message from the device."""
83
+ sn: str
84
+ count: int = 1
85
+ logindex: int = 0
86
+ record: list[LogRecord] = Field(default_factory=list)
87
+
88
+
89
+ class RegMessage(BaseModel):
90
+ """Parsed 'reg' command from the device."""
91
+ cmd: str = "reg"
92
+ sn: str
93
+ devinfo: DeviceInfo
94
+
95
+
96
+ class RegResponse(BaseModel):
97
+ """Response to the 'reg' command."""
98
+ ret: str = "reg"
99
+ result: bool = True
100
+ cloudtime: str # "yyyy-MM-dd HH:mm:ss"
101
+
102
+
103
+ class SendlogResponse(BaseModel):
104
+ """Response to the 'sendlog' event (for online mode)."""
105
+ ret: str = "sendlog"
106
+ 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)
110
+
111
+
112
+ class DaySection(BaseModel):
113
+ """A time section within a dayzone."""
114
+ section: str # "HH:MM~HH:MM"
115
+
116
+
117
+ class DayZone(BaseModel):
118
+ """A day zone containing time sections."""
119
+ day: list[DaySection] = Field(default_factory=list)
120
+
121
+
122
+ class WeekDay(BaseModel):
123
+ """A day reference within a weekzone."""
124
+ day: int # dayzone index (1-based)
125
+
126
+
127
+ class WeekZone(BaseModel):
128
+ """A week zone containing 7 day references (Sun-Sat)."""
129
+ week: list[WeekDay] = Field(default_factory=list)
130
+
131
+
132
+ class UserLockRecord(BaseModel):
133
+ """A user access lock record for setuserlock."""
134
+ 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"