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 +60 -0
- topdata/constants.py +75 -0
- topdata/exceptions.py +51 -0
- topdata/image.py +120 -0
- topdata/models.py +137 -0
- topdata/server.py +307 -0
- topdata/session.py +495 -0
- topdata/wiegand.py +93 -0
- topdata_sdk-0.1.0.dist-info/METADATA +197 -0
- topdata_sdk-0.1.0.dist-info/RECORD +12 -0
- topdata_sdk-0.1.0.dist-info/WHEEL +5 -0
- topdata_sdk-0.1.0.dist-info/top_level.txt +1 -0
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"
|