Guide

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.

What this is based on pyzk driven against a ZKTeco K40 (firmware Ver 6.60, platform ZLM60_TFT) since 15 September, enrolments watched with a person at the terminal, a 258 student push timed six times, and three faults found on the device in the MIT alternative.
In this piece
  1. Install it from GitHub, pinned
  2. Connecting to a ZKTeco device from Python
  3. enroll_user returns False for fingerprints it stored
  4. The acknowledgement proves nothing either
  5. A terminal can stop obeying enrolment altogether
  6. delete_user_template raises over TCP
  7. Read templates in bulk
  8. The terminal cuts names at 24 bytes, silently
  9. Set the clock inside the session
  10. Keypad roles do not protect the cable
  11. Plan for the slow push
  12. If you need MIT rather than GPL
  13. 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:

  1. 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.
  2. 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.
  3. 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.