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.

@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ *.egg-info/
8
+ uv.lock
@@ -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()