zkteco-lan-agent 1.0.0__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.
Potentially problematic release.
This version of zkteco-lan-agent might be problematic. Click here for more details.
- zkteco_lan_agent-1.0.0/.gitignore +8 -0
- zkteco_lan_agent-1.0.0/PKG-INFO +216 -0
- zkteco_lan_agent-1.0.0/README.md +191 -0
- zkteco_lan_agent-1.0.0/pyproject.toml +64 -0
- zkteco_lan_agent-1.0.0/src/zkteco_lan_agent/__init__.py +13 -0
- zkteco_lan_agent-1.0.0/src/zkteco_lan_agent/__main__.py +67 -0
- zkteco_lan_agent-1.0.0/src/zkteco_lan_agent/attendance.py +54 -0
- zkteco_lan_agent-1.0.0/src/zkteco_lan_agent/command_executor.py +170 -0
- zkteco_lan_agent-1.0.0/src/zkteco_lan_agent/config.py +80 -0
- zkteco_lan_agent-1.0.0/src/zkteco_lan_agent/device_worker.py +127 -0
- zkteco_lan_agent-1.0.0/src/zkteco_lan_agent/server_client.py +91 -0
- zkteco_lan_agent-1.0.0/src/zkteco_lan_agent/state.py +26 -0
- zkteco_lan_agent-1.0.0/tests/test_agent.py +82 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zkteco-lan-agent
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Bridge non-ADMS ZKTeco devices to Fitssort ADMS HTTP (attendance, commands, enroll)
|
|
5
|
+
Project-URL: Homepage, https://github.com/GeeksSort/Gym-Management-System-SaaS
|
|
6
|
+
Project-URL: Documentation, https://github.com/GeeksSort/Gym-Management-System-SaaS/blob/main/zkteco_lan_agent/README.md
|
|
7
|
+
Project-URL: Repository, https://github.com/GeeksSort/Gym-Management-System-SaaS
|
|
8
|
+
Author: GeeksSort / Fitssort
|
|
9
|
+
License: Proprietary
|
|
10
|
+
Keywords: adms,attendance,biometric,fitssort,zkteco
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: System :: Networking
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: pyyaml>=6.0
|
|
22
|
+
Requires-Dist: requests>=2.28
|
|
23
|
+
Requires-Dist: zk>=0.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# ZKTeco LAN Agent — gym laptop deployment
|
|
27
|
+
|
|
28
|
+
Bridge non-ADMS ZKTeco devices (Ethernet / PC Connection only) to Fitssort via
|
|
29
|
+
ADMS HTTP. Runs on a gym LAN laptop or Pi.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
Device(s) ──TCP 4370──► Gym laptop (agent) ──HTTP :80 /iclock/*──► Production
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Package on PyPI: [`zkteco-lan-agent`](https://pypi.org/project/zkteco-lan-agent/)
|
|
36
|
+
|
|
37
|
+
## Install (gym laptop)
|
|
38
|
+
|
|
39
|
+
### With uv (recommended)
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv tool install zkteco-lan-agent
|
|
43
|
+
# or one-off:
|
|
44
|
+
uvx zkteco-lan-agent --config devices.yaml
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### With pip
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python -m pip install zkteco-lan-agent
|
|
51
|
+
zkteco-lan-agent --config devices.yaml
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### From this repo (development)
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
cd zkteco_lan_agent
|
|
58
|
+
uv sync
|
|
59
|
+
uv run zkteco-lan-agent --config ../devices.yaml
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Copy and edit config:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cp devices.yaml.example devices.yaml # from repo root, or create manually
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Example `devices.yaml`:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
server_url: http://tenant.fitssort.com
|
|
72
|
+
poll_interval_seconds: 30
|
|
73
|
+
command_poll_interval_seconds: 10
|
|
74
|
+
heartbeat_interval_seconds: 60
|
|
75
|
+
|
|
76
|
+
devices:
|
|
77
|
+
- name: Front Door K40
|
|
78
|
+
device_ip: 192.168.1.100
|
|
79
|
+
device_port: 4370
|
|
80
|
+
device_sn: "CHANGEME_SN"
|
|
81
|
+
comm_password: 0
|
|
82
|
+
timezone: Asia/Dhaka
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Keep the laptop awake (disable sleep while the gym is open).
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Run as a background service
|
|
90
|
+
|
|
91
|
+
**systemd does not exist on Windows.** Use the section that matches the OS.
|
|
92
|
+
|
|
93
|
+
### Linux — systemd
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# After: uv tool install zkteco-lan-agent
|
|
97
|
+
which zkteco-lan-agent # note the path, e.g. ~/.local/bin/zkteco-lan-agent
|
|
98
|
+
|
|
99
|
+
sudo cp zkteco-lan-agent.service /etc/systemd/system/
|
|
100
|
+
# edit ExecStart, WorkingDirectory (config path), User=
|
|
101
|
+
sudo systemctl daemon-reload
|
|
102
|
+
sudo systemctl enable --now zkteco-lan-agent
|
|
103
|
+
sudo systemctl status zkteco-lan-agent
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Windows — Task Scheduler (built-in)
|
|
107
|
+
|
|
108
|
+
1. Create `start-lan-agent.bat`:
|
|
109
|
+
|
|
110
|
+
```bat
|
|
111
|
+
@echo off
|
|
112
|
+
cd /d C:\gym\agent
|
|
113
|
+
zkteco-lan-agent --config devices.yaml
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
2. Task Scheduler → **Create Task** → run at startup → Action: start the `.bat`.
|
|
117
|
+
3. Enable restart on failure; uncheck “Stop if runs longer than…”.
|
|
118
|
+
|
|
119
|
+
### Windows — NSSM (Windows Service)
|
|
120
|
+
|
|
121
|
+
```powershell
|
|
122
|
+
nssm install ZktecoLanAgent "C:\Users\You\AppData\Local\Programs\Python\Python312\Scripts\zkteco-lan-agent.exe"
|
|
123
|
+
nssm set ZktecoLanAgent AppDirectory "C:\gym\agent"
|
|
124
|
+
nssm set ZktecoLanAgent AppParameters "--config devices.yaml"
|
|
125
|
+
nssm set ZktecoLanAgent AppRestartDelay 10000
|
|
126
|
+
nssm start ZktecoLanAgent
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Dashboard registration
|
|
132
|
+
|
|
133
|
+
| Field | Value |
|
|
134
|
+
|-------|-------|
|
|
135
|
+
| Profile | ZKTeco |
|
|
136
|
+
| Model | K40 (or actual model) |
|
|
137
|
+
| Mode | **TCP Relay** |
|
|
138
|
+
| Serial | Exact hardware SN |
|
|
139
|
+
|
|
140
|
+
## Production ADMS smoke tests
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
curl -v "http://<tenant>.fitssort.com/iclock/cdata?SN=<YOUR_SN>"
|
|
144
|
+
curl -v "http://<tenant>.fitssort.com/iclock/getrequest?SN=<YOUR_SN>"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Use `http://` only (HTTPS ADMS paths are not routed).
|
|
148
|
+
|
|
149
|
+
## Card + fingerprint
|
|
150
|
+
|
|
151
|
+
- Enroll fingerprints from the web app (ADMS or TCP Relay) or on the device.
|
|
152
|
+
- Provision cards from the app so USERINFO includes `Card=`.
|
|
153
|
+
- Device needs a card reader module for card taps.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Publish to PyPI (maintainers)
|
|
158
|
+
|
|
159
|
+
Uses [uv](https://docs.astral.sh/uv/).
|
|
160
|
+
|
|
161
|
+
### One-time setup
|
|
162
|
+
|
|
163
|
+
1. Create a PyPI account at https://pypi.org (and TestPyPI at https://test.pypi.org).
|
|
164
|
+
2. Create an **API token** (Account → API tokens). Scope: entire account or project `zkteco-lan-agent`.
|
|
165
|
+
3. Prefer env vars (never commit tokens):
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
export UV_PUBLISH_TOKEN=pypi-AgEIcHlwaS5vcmc... # PyPI
|
|
169
|
+
# or for TestPyPI:
|
|
170
|
+
export UV_PUBLISH_TOKEN=pypi-... # TestPyPI token
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Build & test locally
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
cd zkteco_lan_agent
|
|
177
|
+
uv sync --group dev
|
|
178
|
+
uv run pytest
|
|
179
|
+
uv build
|
|
180
|
+
# artifacts in dist/: .whl and .tar.gz
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Publish to TestPyPI first
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
cd zkteco_lan_agent
|
|
187
|
+
uv build
|
|
188
|
+
uv publish --publish-url https://test.pypi.org/legacy/ --token "$UV_PUBLISH_TOKEN"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Install from TestPyPI to verify:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
uv tool install --index https://test.pypi.org/simple/ --index-strategy unsafe-best-match zkteco-lan-agent
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Publish to PyPI
|
|
198
|
+
|
|
199
|
+
1. Bump `version` in `pyproject.toml` (and ensure changelog/notes if you keep them).
|
|
200
|
+
2. Build and publish:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
cd zkteco_lan_agent
|
|
204
|
+
uv build
|
|
205
|
+
uv publish --token "$UV_PUBLISH_TOKEN"
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Or interactive (uv prompts for username `__token__` and the API token as password):
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
uv publish
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Version bumps
|
|
215
|
+
|
|
216
|
+
Edit `version` in [`pyproject.toml`](pyproject.toml). Reinstall picks up `__version__` via `importlib.metadata`.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# ZKTeco LAN Agent — gym laptop deployment
|
|
2
|
+
|
|
3
|
+
Bridge non-ADMS ZKTeco devices (Ethernet / PC Connection only) to Fitssort via
|
|
4
|
+
ADMS HTTP. Runs on a gym LAN laptop or Pi.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
Device(s) ──TCP 4370──► Gym laptop (agent) ──HTTP :80 /iclock/*──► Production
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Package on PyPI: [`zkteco-lan-agent`](https://pypi.org/project/zkteco-lan-agent/)
|
|
11
|
+
|
|
12
|
+
## Install (gym laptop)
|
|
13
|
+
|
|
14
|
+
### With uv (recommended)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
uv tool install zkteco-lan-agent
|
|
18
|
+
# or one-off:
|
|
19
|
+
uvx zkteco-lan-agent --config devices.yaml
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### With pip
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
python -m pip install zkteco-lan-agent
|
|
26
|
+
zkteco-lan-agent --config devices.yaml
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### From this repo (development)
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
cd zkteco_lan_agent
|
|
33
|
+
uv sync
|
|
34
|
+
uv run zkteco-lan-agent --config ../devices.yaml
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Copy and edit config:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
cp devices.yaml.example devices.yaml # from repo root, or create manually
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Example `devices.yaml`:
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
server_url: http://tenant.fitssort.com
|
|
47
|
+
poll_interval_seconds: 30
|
|
48
|
+
command_poll_interval_seconds: 10
|
|
49
|
+
heartbeat_interval_seconds: 60
|
|
50
|
+
|
|
51
|
+
devices:
|
|
52
|
+
- name: Front Door K40
|
|
53
|
+
device_ip: 192.168.1.100
|
|
54
|
+
device_port: 4370
|
|
55
|
+
device_sn: "CHANGEME_SN"
|
|
56
|
+
comm_password: 0
|
|
57
|
+
timezone: Asia/Dhaka
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Keep the laptop awake (disable sleep while the gym is open).
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Run as a background service
|
|
65
|
+
|
|
66
|
+
**systemd does not exist on Windows.** Use the section that matches the OS.
|
|
67
|
+
|
|
68
|
+
### Linux — systemd
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# After: uv tool install zkteco-lan-agent
|
|
72
|
+
which zkteco-lan-agent # note the path, e.g. ~/.local/bin/zkteco-lan-agent
|
|
73
|
+
|
|
74
|
+
sudo cp zkteco-lan-agent.service /etc/systemd/system/
|
|
75
|
+
# edit ExecStart, WorkingDirectory (config path), User=
|
|
76
|
+
sudo systemctl daemon-reload
|
|
77
|
+
sudo systemctl enable --now zkteco-lan-agent
|
|
78
|
+
sudo systemctl status zkteco-lan-agent
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Windows — Task Scheduler (built-in)
|
|
82
|
+
|
|
83
|
+
1. Create `start-lan-agent.bat`:
|
|
84
|
+
|
|
85
|
+
```bat
|
|
86
|
+
@echo off
|
|
87
|
+
cd /d C:\gym\agent
|
|
88
|
+
zkteco-lan-agent --config devices.yaml
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
2. Task Scheduler → **Create Task** → run at startup → Action: start the `.bat`.
|
|
92
|
+
3. Enable restart on failure; uncheck “Stop if runs longer than…”.
|
|
93
|
+
|
|
94
|
+
### Windows — NSSM (Windows Service)
|
|
95
|
+
|
|
96
|
+
```powershell
|
|
97
|
+
nssm install ZktecoLanAgent "C:\Users\You\AppData\Local\Programs\Python\Python312\Scripts\zkteco-lan-agent.exe"
|
|
98
|
+
nssm set ZktecoLanAgent AppDirectory "C:\gym\agent"
|
|
99
|
+
nssm set ZktecoLanAgent AppParameters "--config devices.yaml"
|
|
100
|
+
nssm set ZktecoLanAgent AppRestartDelay 10000
|
|
101
|
+
nssm start ZktecoLanAgent
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Dashboard registration
|
|
107
|
+
|
|
108
|
+
| Field | Value |
|
|
109
|
+
|-------|-------|
|
|
110
|
+
| Profile | ZKTeco |
|
|
111
|
+
| Model | K40 (or actual model) |
|
|
112
|
+
| Mode | **TCP Relay** |
|
|
113
|
+
| Serial | Exact hardware SN |
|
|
114
|
+
|
|
115
|
+
## Production ADMS smoke tests
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
curl -v "http://<tenant>.fitssort.com/iclock/cdata?SN=<YOUR_SN>"
|
|
119
|
+
curl -v "http://<tenant>.fitssort.com/iclock/getrequest?SN=<YOUR_SN>"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Use `http://` only (HTTPS ADMS paths are not routed).
|
|
123
|
+
|
|
124
|
+
## Card + fingerprint
|
|
125
|
+
|
|
126
|
+
- Enroll fingerprints from the web app (ADMS or TCP Relay) or on the device.
|
|
127
|
+
- Provision cards from the app so USERINFO includes `Card=`.
|
|
128
|
+
- Device needs a card reader module for card taps.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Publish to PyPI (maintainers)
|
|
133
|
+
|
|
134
|
+
Uses [uv](https://docs.astral.sh/uv/).
|
|
135
|
+
|
|
136
|
+
### One-time setup
|
|
137
|
+
|
|
138
|
+
1. Create a PyPI account at https://pypi.org (and TestPyPI at https://test.pypi.org).
|
|
139
|
+
2. Create an **API token** (Account → API tokens). Scope: entire account or project `zkteco-lan-agent`.
|
|
140
|
+
3. Prefer env vars (never commit tokens):
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
export UV_PUBLISH_TOKEN=pypi-AgEIcHlwaS5vcmc... # PyPI
|
|
144
|
+
# or for TestPyPI:
|
|
145
|
+
export UV_PUBLISH_TOKEN=pypi-... # TestPyPI token
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Build & test locally
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
cd zkteco_lan_agent
|
|
152
|
+
uv sync --group dev
|
|
153
|
+
uv run pytest
|
|
154
|
+
uv build
|
|
155
|
+
# artifacts in dist/: .whl and .tar.gz
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Publish to TestPyPI first
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
cd zkteco_lan_agent
|
|
162
|
+
uv build
|
|
163
|
+
uv publish --publish-url https://test.pypi.org/legacy/ --token "$UV_PUBLISH_TOKEN"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Install from TestPyPI to verify:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
uv tool install --index https://test.pypi.org/simple/ --index-strategy unsafe-best-match zkteco-lan-agent
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Publish to PyPI
|
|
173
|
+
|
|
174
|
+
1. Bump `version` in `pyproject.toml` (and ensure changelog/notes if you keep them).
|
|
175
|
+
2. Build and publish:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
cd zkteco_lan_agent
|
|
179
|
+
uv build
|
|
180
|
+
uv publish --token "$UV_PUBLISH_TOKEN"
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Or interactive (uv prompts for username `__token__` and the API token as password):
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
uv publish
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Version bumps
|
|
190
|
+
|
|
191
|
+
Edit `version` in [`pyproject.toml`](pyproject.toml). Reinstall picks up `__version__` via `importlib.metadata`.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zkteco-lan-agent"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Bridge non-ADMS ZKTeco devices to Fitssort ADMS HTTP (attendance, commands, enroll)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "Proprietary" }
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "GeeksSort / Fitssort" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["zkteco", "adms", "attendance", "fitssort", "biometric"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: System Administrators",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: System :: Networking",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"zk>=0.9",
|
|
25
|
+
"requests>=2.28",
|
|
26
|
+
"PyYAML>=6.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Note: dependency `zk` pulls in `thriftpy2`, which needs a C compiler + Python
|
|
30
|
+
# headers on first install (Linux: sudo apt install python3-dev build-essential).
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/GeeksSort/Gym-Management-System-SaaS"
|
|
34
|
+
Documentation = "https://github.com/GeeksSort/Gym-Management-System-SaaS/blob/main/zkteco_lan_agent/README.md"
|
|
35
|
+
Repository = "https://github.com/GeeksSort/Gym-Management-System-SaaS"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
zkteco-lan-agent = "zkteco_lan_agent.__main__:main"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["hatchling"]
|
|
42
|
+
build-backend = "hatchling.build"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/zkteco_lan_agent"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.sdist]
|
|
48
|
+
include = [
|
|
49
|
+
"src/zkteco_lan_agent",
|
|
50
|
+
"tests",
|
|
51
|
+
"README.md",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[dependency-groups]
|
|
55
|
+
dev = [
|
|
56
|
+
"pytest>=8.0",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.uv]
|
|
60
|
+
package = true
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
64
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""ZKTeco LAN Agent — bridges non-ADMS ZKTeco devices to Fitssort ADMS HTTP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("zkteco-lan-agent")
|
|
10
|
+
except PackageNotFoundError:
|
|
11
|
+
__version__ = "1.0.0"
|
|
12
|
+
except ImportError: # pragma: no cover
|
|
13
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from zkteco_lan_agent import __version__
|
|
9
|
+
from zkteco_lan_agent.config import ConfigError, load_config
|
|
10
|
+
from zkteco_lan_agent.device_worker import DeviceWorker
|
|
11
|
+
from zkteco_lan_agent.server_client import ServerClient
|
|
12
|
+
|
|
13
|
+
logging.basicConfig(
|
|
14
|
+
level=logging.INFO,
|
|
15
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
16
|
+
)
|
|
17
|
+
log = logging.getLogger("zkteco_lan_agent")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv: list[str] | None = None) -> int:
|
|
21
|
+
parser = argparse.ArgumentParser(description="ZKTeco LAN Agent — bridge non-ADMS devices to Fitssort")
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--config",
|
|
24
|
+
"-c",
|
|
25
|
+
default="devices.yaml",
|
|
26
|
+
help="Path to devices.yaml",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("--version", action="version", version=f"zkteco-lan-agent {__version__}")
|
|
29
|
+
args = parser.parse_args(argv)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
config = load_config(args.config)
|
|
33
|
+
except ConfigError as exc:
|
|
34
|
+
log.error("%s", exc)
|
|
35
|
+
return 2
|
|
36
|
+
|
|
37
|
+
server = ServerClient(config.server_url)
|
|
38
|
+
workers = [DeviceWorker(config, device, server) for device in config.devices]
|
|
39
|
+
log.info(
|
|
40
|
+
"Started zkteco-lan-agent %s server=%s devices=%d",
|
|
41
|
+
__version__,
|
|
42
|
+
config.server_url,
|
|
43
|
+
len(workers),
|
|
44
|
+
)
|
|
45
|
+
for w in workers:
|
|
46
|
+
log.info(
|
|
47
|
+
" device name=%s sn=%s ip=%s:%d",
|
|
48
|
+
w.device.name,
|
|
49
|
+
w.device.device_sn,
|
|
50
|
+
w.device.device_ip,
|
|
51
|
+
w.device.device_port,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
while True:
|
|
56
|
+
start = time.monotonic()
|
|
57
|
+
for worker in workers:
|
|
58
|
+
worker.tick()
|
|
59
|
+
elapsed = time.monotonic() - start
|
|
60
|
+
time.sleep(max(1, config.poll_interval_seconds - elapsed))
|
|
61
|
+
except KeyboardInterrupt:
|
|
62
|
+
log.info("Shutting down")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def format_attlog_line(
|
|
7
|
+
user_id: str | int,
|
|
8
|
+
ts: datetime,
|
|
9
|
+
*,
|
|
10
|
+
status: int = 0,
|
|
11
|
+
verify: int | None = None,
|
|
12
|
+
in_out: int = 0,
|
|
13
|
+
work_code: int = 0,
|
|
14
|
+
) -> str:
|
|
15
|
+
if ts.tzinfo is None:
|
|
16
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
17
|
+
time_str = ts.strftime("%Y-%m-%d %H:%M:%S")
|
|
18
|
+
verify_val = 1 if verify is None else int(verify)
|
|
19
|
+
return f"{user_id}\t{time_str}\t{status}\t{verify_val}\t{in_out}\t{work_code}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_attlog_body(lines: list[str]) -> str:
|
|
23
|
+
return "TABLE=ATTLOG\n" + "\n".join(lines)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_userinfo_body(rows: list[dict]) -> str:
|
|
27
|
+
"""Build USERINFO push body from dicts with pin, name, card keys."""
|
|
28
|
+
lines = ["TABLE=USERINFO"]
|
|
29
|
+
for row in rows:
|
|
30
|
+
pin = row.get("pin") or row.get("uid") or ""
|
|
31
|
+
name = (row.get("name") or "").replace("\t", " ").strip()[:40]
|
|
32
|
+
card = (row.get("card") or "").strip()
|
|
33
|
+
lines.append(f"PIN={pin}\tName={name}\tCard={card}")
|
|
34
|
+
return "\n".join(lines)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
VERIFY_FINGERPRINT = 1
|
|
38
|
+
VERIFY_CARD = 2
|
|
39
|
+
VERIFY_PASSWORD = 0
|
|
40
|
+
VERIFY_PIN = 3
|
|
41
|
+
VERIFY_FACE = 4
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def entry_method_for_verify(verify: int | None) -> str:
|
|
45
|
+
mapping = {
|
|
46
|
+
VERIFY_PASSWORD: "password",
|
|
47
|
+
VERIFY_FINGERPRINT: "fingerprint",
|
|
48
|
+
VERIFY_CARD: "card",
|
|
49
|
+
VERIFY_PIN: "pin",
|
|
50
|
+
VERIFY_FACE: "face",
|
|
51
|
+
}
|
|
52
|
+
if verify is None:
|
|
53
|
+
return "fingerprint"
|
|
54
|
+
return mapping.get(int(verify), "fingerprint")
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from zkteco_lan_agent.attendance import build_attlog_body, build_userinfo_body, format_attlog_line
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("zkteco_lan_agent.commands")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_userinfo_fields(cmd: str) -> dict[str, str]:
|
|
13
|
+
fields: dict[str, str] = {}
|
|
14
|
+
# Normalize "DATA UPDATE USERINFO PIN=..." so PIN is its own token
|
|
15
|
+
normalized = re.sub(r"(USERINFO)\s+", r"\1\t", cmd, count=1, flags=re.IGNORECASE)
|
|
16
|
+
for part in re.split(r"[\t\n]", normalized):
|
|
17
|
+
part = part.strip()
|
|
18
|
+
if "=" not in part:
|
|
19
|
+
continue
|
|
20
|
+
key, value = part.split("=", 1)
|
|
21
|
+
key = key.strip()
|
|
22
|
+
# Drop leading command words glued to the key
|
|
23
|
+
if " " in key:
|
|
24
|
+
key = key.split()[-1]
|
|
25
|
+
fields[key] = value.strip()
|
|
26
|
+
return fields
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CommandExecutor:
|
|
30
|
+
"""Map ADMS command strings to pyzk device operations."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, connect_fn: Callable[[], Any], push_cdata: Callable[[str, str], bool]):
|
|
33
|
+
self._connect = connect_fn
|
|
34
|
+
self._push_cdata = push_cdata
|
|
35
|
+
|
|
36
|
+
def execute(self, cmd: str) -> int:
|
|
37
|
+
"""Return ADMS Return code (0 = success)."""
|
|
38
|
+
upper = cmd.upper()
|
|
39
|
+
try:
|
|
40
|
+
if "UPDATE USERINFO" in upper or upper.startswith("DATA UPDATE USERINFO"):
|
|
41
|
+
return self._exec_userinfo(cmd)
|
|
42
|
+
if upper.startswith("ENROLL_FP"):
|
|
43
|
+
return self._exec_enroll_fp(cmd)
|
|
44
|
+
if "QUERY ATTLOG" in upper:
|
|
45
|
+
return self._exec_query_attlog(cmd)
|
|
46
|
+
if "QUERY USERINFO" in upper:
|
|
47
|
+
return self._exec_query_userinfo()
|
|
48
|
+
log.warning("Unsupported command: %s", cmd[:120])
|
|
49
|
+
return 1
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
log.exception("Command execution failed: %s", exc)
|
|
52
|
+
return 1
|
|
53
|
+
|
|
54
|
+
def _with_conn(self, fn: Callable[[Any], int]) -> int:
|
|
55
|
+
conn = None
|
|
56
|
+
try:
|
|
57
|
+
conn = self._connect()
|
|
58
|
+
conn.disable_device()
|
|
59
|
+
return fn(conn)
|
|
60
|
+
finally:
|
|
61
|
+
if conn is not None:
|
|
62
|
+
try:
|
|
63
|
+
conn.enable_device()
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
try:
|
|
67
|
+
conn.disconnect()
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def _exec_userinfo(self, cmd: str) -> int:
|
|
72
|
+
fields = parse_userinfo_fields(cmd)
|
|
73
|
+
pin = fields.get("PIN") or fields.get("Pin") or ""
|
|
74
|
+
if not pin:
|
|
75
|
+
return 1
|
|
76
|
+
name = fields.get("Name") or ""
|
|
77
|
+
card = fields.get("Card") or ""
|
|
78
|
+
|
|
79
|
+
def _run(conn: Any) -> int:
|
|
80
|
+
uid = int(pin)
|
|
81
|
+
kwargs: dict[str, Any] = {
|
|
82
|
+
"uid": uid,
|
|
83
|
+
"name": name,
|
|
84
|
+
"privilege": 0,
|
|
85
|
+
"password": "",
|
|
86
|
+
"group_id": "",
|
|
87
|
+
"user_id": str(pin),
|
|
88
|
+
}
|
|
89
|
+
# pyzk set_user signature varies; pass card when supported
|
|
90
|
+
try:
|
|
91
|
+
conn.set_user(card=card, **kwargs)
|
|
92
|
+
except TypeError:
|
|
93
|
+
conn.set_user(**kwargs)
|
|
94
|
+
if card and hasattr(conn, "set_user"):
|
|
95
|
+
# Best-effort second call with positional card if available
|
|
96
|
+
try:
|
|
97
|
+
conn.set_user(uid, name, 0, "", card, "", str(pin))
|
|
98
|
+
except Exception:
|
|
99
|
+
log.warning("Could not set card=%s for pin=%s", card, pin)
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
return self._with_conn(_run)
|
|
103
|
+
|
|
104
|
+
def _exec_enroll_fp(self, cmd: str) -> int:
|
|
105
|
+
fields = parse_userinfo_fields(cmd)
|
|
106
|
+
pin = fields.get("PIN") or ""
|
|
107
|
+
fid = int(fields.get("FID") or 0)
|
|
108
|
+
if not pin:
|
|
109
|
+
return 1
|
|
110
|
+
|
|
111
|
+
def _run(conn: Any) -> int:
|
|
112
|
+
uid = int(pin)
|
|
113
|
+
# pyzk enroll_user / enroll fingerprint APIs differ by firmware
|
|
114
|
+
if hasattr(conn, "enroll_user"):
|
|
115
|
+
conn.enroll_user(uid)
|
|
116
|
+
return 0
|
|
117
|
+
if hasattr(conn, "enroll_fingerprint"):
|
|
118
|
+
conn.enroll_fingerprint(uid, fid)
|
|
119
|
+
return 0
|
|
120
|
+
log.warning("Device connection has no enroll API; command not executed")
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
return self._with_conn(_run)
|
|
124
|
+
|
|
125
|
+
def _exec_query_attlog(self, cmd: str) -> int:
|
|
126
|
+
fields = parse_userinfo_fields(cmd)
|
|
127
|
+
start = fields.get("StartTime")
|
|
128
|
+
|
|
129
|
+
def _run(conn: Any) -> int:
|
|
130
|
+
logs = conn.get_attendance() or []
|
|
131
|
+
lines: list[str] = []
|
|
132
|
+
for att in logs:
|
|
133
|
+
ts = att.timestamp
|
|
134
|
+
if start and ts.strftime("%Y-%m-%d %H:%M:%S") < start:
|
|
135
|
+
continue
|
|
136
|
+
verify = getattr(att, "punch", None)
|
|
137
|
+
if verify is None:
|
|
138
|
+
verify = getattr(att, "status", None)
|
|
139
|
+
lines.append(
|
|
140
|
+
format_attlog_line(
|
|
141
|
+
att.user_id,
|
|
142
|
+
ts,
|
|
143
|
+
status=int(getattr(att, "status", 0) or 0),
|
|
144
|
+
verify=int(verify) if verify is not None else None,
|
|
145
|
+
in_out=int(getattr(att, "punch", 0) or 0),
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
if lines:
|
|
149
|
+
self._push_cdata(build_attlog_body(lines), "ATTLOG")
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
return self._with_conn(_run)
|
|
153
|
+
|
|
154
|
+
def _exec_query_userinfo(self) -> int:
|
|
155
|
+
def _run(conn: Any) -> int:
|
|
156
|
+
users = conn.get_users() or []
|
|
157
|
+
rows = []
|
|
158
|
+
for user in users:
|
|
159
|
+
rows.append(
|
|
160
|
+
{
|
|
161
|
+
"pin": getattr(user, "user_id", None) or getattr(user, "uid", ""),
|
|
162
|
+
"name": getattr(user, "name", "") or "",
|
|
163
|
+
"card": getattr(user, "card", "") or "",
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
if rows:
|
|
167
|
+
self._push_cdata(build_userinfo_body(rows), "USERINFO")
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
return self._with_conn(_run)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class DeviceConfig:
|
|
12
|
+
name: str
|
|
13
|
+
device_ip: str
|
|
14
|
+
device_sn: str
|
|
15
|
+
device_port: int = 4370
|
|
16
|
+
comm_password: int = 0
|
|
17
|
+
timezone: str = "Asia/Dhaka"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AgentConfig:
|
|
22
|
+
server_url: str
|
|
23
|
+
devices: list[DeviceConfig] = field(default_factory=list)
|
|
24
|
+
poll_interval_seconds: int = 30
|
|
25
|
+
command_poll_interval_seconds: int = 10
|
|
26
|
+
heartbeat_interval_seconds: int = 60
|
|
27
|
+
state_dir: str = "/tmp/zkteco_lan_agent"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConfigError(ValueError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_config(path: str | Path) -> AgentConfig:
|
|
35
|
+
raw_path = Path(path)
|
|
36
|
+
if not raw_path.exists():
|
|
37
|
+
raise ConfigError(f"Config file not found: {raw_path}")
|
|
38
|
+
|
|
39
|
+
data = yaml.safe_load(raw_path.read_text(encoding="utf-8")) or {}
|
|
40
|
+
return parse_config(data)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_config(data: dict[str, Any]) -> AgentConfig:
|
|
44
|
+
server_url = str(data.get("server_url") or "").rstrip("/")
|
|
45
|
+
if not server_url:
|
|
46
|
+
raise ConfigError("server_url is required")
|
|
47
|
+
|
|
48
|
+
devices_raw = data.get("devices") or []
|
|
49
|
+
if not isinstance(devices_raw, list) or not devices_raw:
|
|
50
|
+
raise ConfigError("devices must be a non-empty list")
|
|
51
|
+
|
|
52
|
+
devices: list[DeviceConfig] = []
|
|
53
|
+
for idx, item in enumerate(devices_raw):
|
|
54
|
+
if not isinstance(item, dict):
|
|
55
|
+
raise ConfigError(f"devices[{idx}] must be a mapping")
|
|
56
|
+
sn = str(item.get("device_sn") or "").strip()
|
|
57
|
+
if not sn:
|
|
58
|
+
raise ConfigError(f"devices[{idx}].device_sn is required")
|
|
59
|
+
ip = str(item.get("device_ip") or "").strip()
|
|
60
|
+
if not ip:
|
|
61
|
+
raise ConfigError(f"devices[{idx}].device_ip is required")
|
|
62
|
+
devices.append(
|
|
63
|
+
DeviceConfig(
|
|
64
|
+
name=str(item.get("name") or sn),
|
|
65
|
+
device_ip=ip,
|
|
66
|
+
device_sn=sn,
|
|
67
|
+
device_port=int(item.get("device_port") or 4370),
|
|
68
|
+
comm_password=int(item.get("comm_password") or 0),
|
|
69
|
+
timezone=str(item.get("timezone") or "Asia/Dhaka"),
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return AgentConfig(
|
|
74
|
+
server_url=server_url,
|
|
75
|
+
devices=devices,
|
|
76
|
+
poll_interval_seconds=int(data.get("poll_interval_seconds") or 30),
|
|
77
|
+
command_poll_interval_seconds=int(data.get("command_poll_interval_seconds") or 10),
|
|
78
|
+
heartbeat_interval_seconds=int(data.get("heartbeat_interval_seconds") or 60),
|
|
79
|
+
state_dir=str(data.get("state_dir") or "/tmp/zkteco_lan_agent"),
|
|
80
|
+
)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from zk import ZK
|
|
9
|
+
|
|
10
|
+
from zkteco_lan_agent.attendance import build_attlog_body, format_attlog_line
|
|
11
|
+
from zkteco_lan_agent.command_executor import CommandExecutor
|
|
12
|
+
from zkteco_lan_agent.config import AgentConfig, DeviceConfig
|
|
13
|
+
from zkteco_lan_agent.server_client import ServerClient
|
|
14
|
+
from zkteco_lan_agent.state import DeviceState
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("zkteco_lan_agent.worker")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DeviceWorker:
|
|
20
|
+
def __init__(self, agent: AgentConfig, device: DeviceConfig, server: ServerClient):
|
|
21
|
+
self.agent = agent
|
|
22
|
+
self.device = device
|
|
23
|
+
self.server = server
|
|
24
|
+
self.state = DeviceState(agent.state_dir, device.device_sn)
|
|
25
|
+
self._last_heartbeat = 0.0
|
|
26
|
+
self._last_command_poll = 0.0
|
|
27
|
+
|
|
28
|
+
def _connect(self) -> Any:
|
|
29
|
+
zk = ZK(
|
|
30
|
+
self.device.device_ip,
|
|
31
|
+
port=self.device.device_port,
|
|
32
|
+
timeout=10,
|
|
33
|
+
password=self.device.comm_password,
|
|
34
|
+
force_udp=False,
|
|
35
|
+
)
|
|
36
|
+
return zk.connect()
|
|
37
|
+
|
|
38
|
+
def poll_attendance(self) -> None:
|
|
39
|
+
conn = None
|
|
40
|
+
try:
|
|
41
|
+
conn = self._connect()
|
|
42
|
+
log.info(
|
|
43
|
+
"Connected to %s (%s:%d)",
|
|
44
|
+
self.device.device_sn,
|
|
45
|
+
self.device.device_ip,
|
|
46
|
+
self.device.device_port,
|
|
47
|
+
)
|
|
48
|
+
conn.disable_device()
|
|
49
|
+
last_poll = self.state.load_last_poll()
|
|
50
|
+
logs = conn.get_attendance() or []
|
|
51
|
+
log.info("SN=%s has %d attendance records", self.device.device_sn, len(logs))
|
|
52
|
+
|
|
53
|
+
new_logs = []
|
|
54
|
+
for att in logs:
|
|
55
|
+
ts: datetime = att.timestamp
|
|
56
|
+
if ts.tzinfo is None:
|
|
57
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
58
|
+
if last_poll and ts <= last_poll:
|
|
59
|
+
continue
|
|
60
|
+
new_logs.append(att)
|
|
61
|
+
|
|
62
|
+
if new_logs:
|
|
63
|
+
lines: list[str] = []
|
|
64
|
+
newest: datetime | None = None
|
|
65
|
+
for att in new_logs:
|
|
66
|
+
ts = att.timestamp
|
|
67
|
+
if ts.tzinfo is None:
|
|
68
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
69
|
+
# pyzk: status often holds punch type; punch may hold verify on some firmwares
|
|
70
|
+
verify = getattr(att, "punch", None)
|
|
71
|
+
status = int(getattr(att, "status", 0) or 0)
|
|
72
|
+
# Prefer explicit verify attribute if present
|
|
73
|
+
if hasattr(att, "verify") and getattr(att, "verify") is not None:
|
|
74
|
+
verify = getattr(att, "verify")
|
|
75
|
+
lines.append(
|
|
76
|
+
format_attlog_line(
|
|
77
|
+
att.user_id,
|
|
78
|
+
ts,
|
|
79
|
+
status=status,
|
|
80
|
+
verify=int(verify) if verify is not None else None,
|
|
81
|
+
in_out=int(getattr(att, "punch", 0) or 0) if verify is None else 0,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
if newest is None or ts > newest:
|
|
85
|
+
newest = ts
|
|
86
|
+
body = build_attlog_body(lines)
|
|
87
|
+
if self.server.push_cdata(self.device.device_sn, body, table="ATTLOG") and newest:
|
|
88
|
+
self.state.save_last_poll(newest)
|
|
89
|
+
else:
|
|
90
|
+
log.info("SN=%s no new records", self.device.device_sn)
|
|
91
|
+
self._maybe_heartbeat(force=True)
|
|
92
|
+
|
|
93
|
+
conn.enable_device()
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
log.error("Device poll error SN=%s: %s", self.device.device_sn, exc)
|
|
96
|
+
finally:
|
|
97
|
+
if conn:
|
|
98
|
+
try:
|
|
99
|
+
conn.disconnect()
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
def poll_commands(self) -> None:
|
|
104
|
+
pending = self.server.get_request(self.device.device_sn)
|
|
105
|
+
if not pending:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
def push(body: str, table: str) -> bool:
|
|
109
|
+
return self.server.push_cdata(self.device.device_sn, body, table=table)
|
|
110
|
+
|
|
111
|
+
executor = CommandExecutor(self._connect, push)
|
|
112
|
+
return_code = executor.execute(pending.cmd)
|
|
113
|
+
self.server.ack_command(self.device.device_sn, pending.id, return_code)
|
|
114
|
+
|
|
115
|
+
def _maybe_heartbeat(self, *, force: bool = False) -> None:
|
|
116
|
+
now = time.monotonic()
|
|
117
|
+
if force or now - self._last_heartbeat >= self.agent.heartbeat_interval_seconds:
|
|
118
|
+
if self.server.heartbeat(self.device.device_sn):
|
|
119
|
+
self._last_heartbeat = now
|
|
120
|
+
|
|
121
|
+
def tick(self) -> None:
|
|
122
|
+
self.poll_attendance()
|
|
123
|
+
now = time.monotonic()
|
|
124
|
+
if now - self._last_command_poll >= self.agent.command_poll_interval_seconds:
|
|
125
|
+
self.poll_commands()
|
|
126
|
+
self._last_command_poll = now
|
|
127
|
+
self._maybe_heartbeat()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from zkteco_lan_agent import __version__
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger("zkteco_lan_agent.server")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class PendingCommand:
|
|
16
|
+
id: int
|
|
17
|
+
cmd: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ServerClient:
|
|
21
|
+
def __init__(self, server_url: str, timeout: float = 15.0):
|
|
22
|
+
self.server_url = server_url.rstrip("/")
|
|
23
|
+
self.timeout = timeout
|
|
24
|
+
self.session = requests.Session()
|
|
25
|
+
self.session.headers.update({"User-Agent": f"zkteco-lan-agent/{__version__}"})
|
|
26
|
+
|
|
27
|
+
def heartbeat(self, device_sn: str) -> bool:
|
|
28
|
+
try:
|
|
29
|
+
resp = self.session.get(
|
|
30
|
+
f"{self.server_url}/iclock/cdata/",
|
|
31
|
+
params={"SN": device_sn, "agent": __version__},
|
|
32
|
+
timeout=self.timeout,
|
|
33
|
+
)
|
|
34
|
+
return resp.status_code == 200
|
|
35
|
+
except requests.RequestException as exc:
|
|
36
|
+
log.warning("Heartbeat failed SN=%s: %s", device_sn, exc)
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
def push_cdata(self, device_sn: str, body: str, *, table: str) -> bool:
|
|
40
|
+
try:
|
|
41
|
+
resp = self.session.post(
|
|
42
|
+
f"{self.server_url}/iclock/cdata/",
|
|
43
|
+
params={"SN": device_sn, "table": table},
|
|
44
|
+
data=body.encode("utf-8"),
|
|
45
|
+
timeout=self.timeout,
|
|
46
|
+
)
|
|
47
|
+
log.info(
|
|
48
|
+
"Push SN=%s table=%s status=%s body=%s",
|
|
49
|
+
device_sn,
|
|
50
|
+
table,
|
|
51
|
+
resp.status_code,
|
|
52
|
+
resp.text.strip()[:200],
|
|
53
|
+
)
|
|
54
|
+
return resp.status_code == 200
|
|
55
|
+
except requests.RequestException as exc:
|
|
56
|
+
log.error("Push failed SN=%s: %s", device_sn, exc)
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
def get_request(self, device_sn: str) -> PendingCommand | None:
|
|
60
|
+
try:
|
|
61
|
+
resp = self.session.get(
|
|
62
|
+
f"{self.server_url}/iclock/getrequest/",
|
|
63
|
+
params={"SN": device_sn},
|
|
64
|
+
timeout=self.timeout,
|
|
65
|
+
)
|
|
66
|
+
except requests.RequestException as exc:
|
|
67
|
+
log.warning("getrequest failed SN=%s: %s", device_sn, exc)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
text = (resp.text or "").strip()
|
|
71
|
+
if not text or text.upper().startswith("OK"):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
match = re.match(r"^C:(\d+):(.+)$", text, re.DOTALL)
|
|
75
|
+
if not match:
|
|
76
|
+
log.warning("Unrecognized getrequest response SN=%s: %s", device_sn, text[:200])
|
|
77
|
+
return None
|
|
78
|
+
return PendingCommand(id=int(match.group(1)), cmd=match.group(2).strip())
|
|
79
|
+
|
|
80
|
+
def ack_command(self, device_sn: str, cmd_id: int, return_code: int = 0) -> bool:
|
|
81
|
+
try:
|
|
82
|
+
resp = self.session.post(
|
|
83
|
+
f"{self.server_url}/iclock/devicecmd/",
|
|
84
|
+
params={"SN": device_sn},
|
|
85
|
+
data=f"ID={cmd_id}&Return={return_code}&CMD=DATA".encode("utf-8"),
|
|
86
|
+
timeout=self.timeout,
|
|
87
|
+
)
|
|
88
|
+
return resp.status_code == 200
|
|
89
|
+
except requests.RequestException as exc:
|
|
90
|
+
log.error("devicecmd ack failed SN=%s id=%s: %s", device_sn, cmd_id, exc)
|
|
91
|
+
return False
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DeviceState:
|
|
8
|
+
def __init__(self, state_dir: str | Path, device_sn: str):
|
|
9
|
+
self._dir = Path(state_dir)
|
|
10
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in device_sn)
|
|
12
|
+
self._path = self._dir / f"{safe}_last_poll.txt"
|
|
13
|
+
|
|
14
|
+
def load_last_poll(self) -> datetime | None:
|
|
15
|
+
if not self._path.exists():
|
|
16
|
+
return None
|
|
17
|
+
raw = self._path.read_text(encoding="utf-8").strip()
|
|
18
|
+
if not raw:
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
return datetime.fromisoformat(raw)
|
|
22
|
+
except ValueError:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
def save_last_poll(self, dt: datetime) -> None:
|
|
26
|
+
self._path.write_text(dt.isoformat(), encoding="utf-8")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import unittest
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
SRC = Path(__file__).resolve().parents[1] / "src"
|
|
9
|
+
if str(SRC) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(SRC))
|
|
11
|
+
|
|
12
|
+
from zkteco_lan_agent.attendance import (
|
|
13
|
+
build_attlog_body,
|
|
14
|
+
entry_method_for_verify,
|
|
15
|
+
format_attlog_line,
|
|
16
|
+
)
|
|
17
|
+
from zkteco_lan_agent.config import ConfigError, parse_config
|
|
18
|
+
from zkteco_lan_agent.command_executor import parse_userinfo_fields
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConfigTests(unittest.TestCase):
|
|
22
|
+
def test_parse_multi_device(self):
|
|
23
|
+
cfg = parse_config(
|
|
24
|
+
{
|
|
25
|
+
"server_url": "http://gym.fitssort.com",
|
|
26
|
+
"devices": [
|
|
27
|
+
{"name": "A", "device_ip": "192.168.1.1", "device_sn": "SN1"},
|
|
28
|
+
{"device_ip": "192.168.1.2", "device_sn": "SN2"},
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
self.assertEqual(len(cfg.devices), 2)
|
|
33
|
+
self.assertEqual(cfg.devices[1].name, "SN2")
|
|
34
|
+
|
|
35
|
+
def test_missing_sn_rejected(self):
|
|
36
|
+
with self.assertRaises(ConfigError):
|
|
37
|
+
parse_config(
|
|
38
|
+
{
|
|
39
|
+
"server_url": "http://x",
|
|
40
|
+
"devices": [{"device_ip": "1.2.3.4"}],
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def test_empty_devices_rejected(self):
|
|
45
|
+
with self.assertRaises(ConfigError):
|
|
46
|
+
parse_config({"server_url": "http://x", "devices": []})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AttlogFormatTests(unittest.TestCase):
|
|
50
|
+
def test_full_line_includes_verify(self):
|
|
51
|
+
ts = datetime(2026, 1, 11, 10, 12, 30, tzinfo=timezone.utc)
|
|
52
|
+
line = format_attlog_line("1001", ts, status=0, verify=2, in_out=0, work_code=0)
|
|
53
|
+
self.assertEqual(line, "1001\t2026-01-11 10:12:30\t0\t2\t0\t0")
|
|
54
|
+
|
|
55
|
+
def test_default_verify_fingerprint(self):
|
|
56
|
+
ts = datetime(2026, 1, 11, 10, 12, 30, tzinfo=timezone.utc)
|
|
57
|
+
line = format_attlog_line("1001", ts)
|
|
58
|
+
parts = line.split("\t")
|
|
59
|
+
self.assertEqual(parts[3], "1")
|
|
60
|
+
|
|
61
|
+
def test_body_prefix(self):
|
|
62
|
+
body = build_attlog_body(["1001\t2026-01-11 10:12:30\t0\t1\t0\t0"])
|
|
63
|
+
self.assertTrue(body.startswith("TABLE=ATTLOG\n"))
|
|
64
|
+
|
|
65
|
+
def test_entry_method_mapping(self):
|
|
66
|
+
self.assertEqual(entry_method_for_verify(2), "card")
|
|
67
|
+
self.assertEqual(entry_method_for_verify(1), "fingerprint")
|
|
68
|
+
self.assertEqual(entry_method_for_verify(None), "fingerprint")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CommandParseTests(unittest.TestCase):
|
|
72
|
+
def test_parse_userinfo(self):
|
|
73
|
+
fields = parse_userinfo_fields(
|
|
74
|
+
"DATA UPDATE USERINFO PIN=42\tName=Jane\tCard=RFID-9\tPri=0"
|
|
75
|
+
)
|
|
76
|
+
self.assertEqual(fields["PIN"], "42")
|
|
77
|
+
self.assertEqual(fields["Card"], "RFID-9")
|
|
78
|
+
self.assertEqual(fields["Name"], "Jane")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
unittest.main()
|