pyzk on a real ZKTeco K40, and what the library does not tell you
It is the best Python library there is for these terminals and we would choose it again. These are the places where what it returns and what the terminal did are two different things.
In this piece
- Install it from GitHub, pinned
- Connecting to a ZKTeco device from Python
- enroll_user returns False for fingerprints it stored
- The acknowledgement proves nothing either
- A terminal can stop obeying enrolment altogether
- delete_user_template raises over TCP
- Read templates in bulk
- The terminal cuts names at 24 bytes, silently
- Set the clock inside the session
- Keypad roles do not protect the cable
- Plan for the slow push
- If you need MIT rather than GPL
- Where these notes stop
pyzk is an unofficial ZKTeco Python library, for driving the company’s fingerprint attendance terminals from your own code. It speaks their protocol on port 4370, it is alive (last pushed in May 2026), and nothing else in Python comes close. We built an offline attendance program on it for a university department in Nigeria, against one ZKTeco K40.
Our terminal is not in pyzk’s list of tested devices. The K40 listed there is platform JZ4725_TFT; ours reports ZLM60_TFT, firmware Ver 6.60 of September 2019, with 72 byte user records. Everything below was found on that unit, and where it was read from pyzk’s source rather than seen on the device, it says so.
Install it from GitHub, pinned
The version on PyPI is 0.9, uploaded on 13 August 2019, and it misses years of
fixes. clear_data() in 0.9 crashes on Python 3 with “can’t concat str to bytes”
before it sends anything; we hit that clearing the terminal. Install from GitHub
and pin a commit, so the library cannot change under you:
pyzk @ git+https://github.com/fananimi/pyzk@f29709c17bb8f1bbb5382d2670b493207cb35ff1
Connecting to a ZKTeco device from Python
from zk import ZK
zk = ZK('192.168.1.201', port=4370, timeout=10, password=0, ommit_ping=True)
conn = zk.connect()
conn.disable_device()
try:
users = conn.get_users()
finally:
try:
conn.enable_device()
finally:
conn.disconnect()
The parameter really is spelt ommit_ping. password is the terminal’s Comm Key
(Menu > COMM. > PC Connection), 0 on ours.
disable_device() stops people scanning while you read or write, and a terminal
left disabled shows a working screen and ignores every finger. So
enable_device() goes in a finally, and disconnect() in a finally of its
own, so a failed enable still closes the socket. The one exception is enrolment:
the terminal has to be live to take a finger, so it runs without
disable_device().
If the terminal is not there, connect() takes about thirty seconds to say so.
Our connection guide has why, and the
two second knock we put in front of it.
enroll_user returns False for fingerprints it stored
This one cost an evening. On our K40, enroll_user returns False after a
successful enrolment, every time.
The terminal took the finger, asked for three to six presses, and stored an 844
to 888 byte template, which we read back. pyzk’s loop counts a press only when the
terminal reports a score of exactly 0x64, which is 100. This terminal sends
scores of 49 to 73. So the loop never counts three good presses, and the function
reports failure for a fingerprint that is sitting on the terminal.
Two more details from the same loop, read from its source: it returns False for a failed finger and for a duplicate without saying which, and when the terminal needed extra presses it sends its final result twice and pyzk reads it once, leaving the second copy unread for whatever command comes next.
Trust the template, not the return value. After an enrolment, call
get_templates() and look for the user and finger you asked for. That is the only
evidence the finger was stored.
The acknowledgement proves nothing either
The terminal answers the start of an enrolment (CMD_STARTENROLL) with OK for a
user it does not hold, for an empty user ID, and for finger 99. The OK means the
packet arrived.
Listening for events does not help on this unit. Registered for every event flag, it sent nothing when a finger was placed on its idle screen or when keys were pressed. A silent socket tells you nothing here.
A terminal can stop obeying enrolment altogether
One evening the terminal answered OK to every enrolment command and asked for no finger, for over an hour. We tried every form of the command, with and without a push first, after keypad presses, with our program closed. A restart from the PC cured it, and the next two captures asked straight away.
We then ran a completed capture, a stopped one and a timed out one, each straight after a working capture. None of them caused it. The cause is unknown. What we did instead of claiming a fix was put Restart the terminal beside the waiting screen, and make the waiting screen say what the terminal should be showing, because the person standing at it is the only one who can see whether it asked.
restart() works. The terminal was back in 35 seconds through pyzk’s own connect,
and 25 to 28 seconds with a two second port check first. Users and fingerprints
survived it.
delete_user_template raises over TCP
Given a user_id over TCP, delete_user_template raises struct.error every
time. Its source packs str(user_id) into a 24s field, and on Python 3 that
field needs bytes. enroll_user in the same file encodes its user ID first; this
function does not.
By uid alone it works: we deleted finger 6 of a test user and read it back gone.
Our Remove button used the user_id form and shipped without once running
against the terminal, because the fake terminal in our tests had no such method.
If your tests use a fake, give it every method the real code calls, or it will
wave through the one that raises.
Read templates in bulk
get_user_template(uid, fid) returned None for an 872 byte template that
get_templates() listed, straight after uploading it. Use get_templates() and
filter.
The terminal cuts names at 24 bytes, silently
With 72 byte user records, a name gets 24 bytes. A longer one is cut mid-word by the terminal, with no error, and pyzk encodes names as UTF-8, so a name with accented Yoruba letters runs out of room sooner than its letter count suggests. Before we reset ours, it held one full name cut part way through the surname and four with the surname gone entirely. Shorten names yourself, before they are sent.
Set the clock inside the session
set_time() is simple to call too early. Ours took the PC’s time, then waited for
the terminal and connected, then wrote the time it had taken. The terminal ran
nine or ten seconds slow, and three classes in a row had a first scan stamped
before the class opened.
Take the time inside the open session, read the clock back, and set it again if it is more than two seconds out.
Keypad roles do not protect the cable
A Super Admin on the terminal protects its menus from people at the keypad. It does not protect anything from a program on the cable: with Comm Key 0, the terminal never asked for an administrator before a change made over TCP. And until a Super Admin exists, anyone at the keypad can open every menu, Delete All Data included.
So, in this order: enrol a Super Admin with a keypad password as soon as the terminal is installed; keep the cable and the PC where students cannot reach them; and set a Comm Key if they can. The same openness is how our program gets a lecturer back into a locked menu, which is why ours stays at 0 on a cable that runs from the lecturer’s own desk.
Plan for the slow push
Pushing the same 258 users to the same terminal, with the terminal disabled throughout, took anywhere from 31.4 to 86.6 seconds across six runs. Two of them were on the same cable in the same hour: 85.3 seconds, then 31.5. Show progress, and plan for the slow one.
The spread also means two runs cannot rank two libraries. pyzatt’s 86.7 seconds, below, sits inside pyzk’s own range.
If you need MIT rather than GPL
pyzk is GPL-2.0. The Free Software Foundation’s FAQ treats a program that imports a GPL library as one combined work once it is distributed. We are not lawyers and this is not legal advice, but it is a question to settle before you sell anything built on it.
The MIT alternative is pyzatt, last pushed in December 2022. We made our program run on either, and pyzatt now passes the same full test on the K40, 258 students pushed in 86.7 seconds. It did not at first. Three faults, each found on the device:
- Its clock decoder is a year out. It divides the day count by 365; the terminal encodes months of 31 days and years of 12 such months. It read the date as 2027-09-20 when it was 2026-09-20. The same decoder stamps every attendance record, so every scan would have been filed a year in the future.
- A name with accented letters loses the user. It writes the name into the 72 byte record by its length in characters, not in bytes, so the record grows and every later field shifts. The terminal kept nothing.
- The privilege byte is doubled. It shifts the admin level left by one, so Super Admin (14) went out as 28, and the terminal refused the record.
A licence took an afternoon to work around. The first of those faults would have taken a term to notice.
Where these notes stop
- One terminal, one firmware. Other models may behave differently, and pyzk’s own issues report enrolment timeouts on other devices (#117).
- Reading the scan count and the scans without disabling the terminal, while students are scanning, has been tested against our fake terminal only, not yet on the K40.
- None of this has gone upstream to either library yet. That is on us, and it is the next thing to do with these notes.
- You will not find the program on our apps page yet. It is in use in one department, and we are not naming it until a trademark search in Nigeria is done.
Written by Adeboye Oluwatimileyin and Moses-Azuoru George, the two people who are Emberfig. We make money from AdMob inside the apps. This site carries no ads yet. You never pay us and we never sell your data.