The problem
ZKTeco terminals come in two families that look identical on a shop counter and are completely different to integrate with.
The expensive ones support ADMS: the device itself opens an HTTP connection to a cloud server and pushes attendance events as they happen. The cheaper and older ones - the ones sold as Ethernet or PC Connection models - do not. They speak a proprietary TCP protocol on the local network and wait for a PC to come and collect. They cannot reach the internet, and no configuration screen will make them.
That is a commercial problem before it is a technical one. FitsSort is a cloud SaaS, and its customers are gyms that already own the cheap devices. “Buy new hardware for every branch before you can use our attendance module” is not a sentence that closes a deal.
The ZKTeco LAN Agent is the answer: a small Python service that sits on the customer’s own network, talks to the device the only way the device can be talked to, and presents the results upstream as if the device had been cloud-capable all along.
The design decision
The obvious implementation is a second server endpoint - one for real ADMS devices, one for the agent, each with its own payload shape.
I did the opposite. The agent impersonates the protocol, not the device. It re-emits events in exactly the ADMS HTTP shape the server already accepts:
[ ZKTeco device ] --TCP/4370, ZK protocol--> [ LAN Agent ] --ADMS HTTP--> [ FitsSort ]
(Ethernet only) (laptop / Pi) (unchanged)
The server does not know, and does not need to know, whether a given record came from a terminal that pushed it or an agent that fetched it. One ingest path, one set of validation rules, one place where attendance can be wrong.
The payoff shows up in maintenance rather than in the first release. Every fix to attendance ingestion applies to both device families automatically, and there is no slow drift where the ADMS path gets a feature the agent path never does. The compatibility problem lives entirely at the edge, in the component that is cheapest to update.
Never lose a punch
The uplink from a gym’s office laptop to a cloud API is not reliable, so the agent has the same problem the offline POS has, in a different shape: events exist in the physical world before the server hears about them.
Two rules carry the design.
The high-water mark is local, and the device is never cleared. ZK terminals expose a “read logs, then clear” operation, and clearing is the intuitive way to avoid re-reading. It is also the way to permanently lose a day of attendance the moment an upload fails after the clear succeeded. The agent instead records how far it has read in its own state store and re-reads from there. The device’s memory is left as a safety net, cleared only on its own retention policy - the agent’s cursor is what advances.
Event identity is derived, not assigned. A punch is keyed on device serial, user, and device timestamp. Re-sending a batch after a timeout produces the same keys, so the server absorbs it as a no-op instead of doubling someone’s attendance. This matters more here than it looks: attendance flows into payroll, and a duplicated punch is a payroll error somebody eventually has to argue about.
Together those give at-least-once delivery with idempotent absorption - a dropped connection delays attendance, it does not lose or duplicate it.
The device clock lies
Every terminal keeps its own clock, and those clocks drift, get reset by power cuts, and occasionally get set wrong by a well-meaning member of staff.
The agent syncs device time when it connects, but it never rewrites history: records carry both the device timestamp and the server receive time. Payroll rules choose deliberately between them, and when the two disagree badly the discrepancy is visible rather than silently resolved.
Quietly “correcting” a timestamp is the kind of helpfulness that turns into a dispute about someone’s wages six weeks later, with no record of what the device actually said.
Deploying to a machine you do not own
This is the part that made it a DevOps project rather than an integration script. The agent runs on hardware in someone else’s building, on a network you cannot see, maintained by people whose job is running a gym.
That constraint drove every packaging decision:
- Installable from PyPI.
pip install zkteco-lan-agentand a config file, not a repository clone and a README of build steps. - Runs on what is already there. An office laptop that stays on, or a Raspberry Pi for sites that would rather not depend on a laptop. Both are cheap enough that a spare can sit in a drawer.
- Supervised, not started by hand. Installed as a service so it survives reboots and power cuts, because it will get both.
- Self-healing on the two failures that actually happen. The device goes unreachable when the network switch is restarted; the uplink goes away when the internet does. Both are handled by backoff and resume, not by an alert asking a gym manager to intervene.
- Diagnosable remotely. The agent reports its own liveness and cursor position upstream, so “is the Dhanmondi branch syncing?” is answerable from the dashboard rather than from a phone call.
Each device is identified by serial and mapped to a branch on the server side, so one agent can serve every terminal on a site, and a device moved between branches is a configuration change rather than a redeployment.
Security posture
An agent running on a customer’s LAN holding a credential to a multi-tenant cloud deserves a moment of paranoia. The credential is scoped to one tenant and to attendance ingestion - it cannot read members, cannot write anything else, and cannot reach another tenant’s data even if the machine it lives on is compromised. Traffic to the cloud is outbound-only over TLS, so the deployment needs no inbound firewall rule and opens no new attack surface on the customer’s network.
What it demonstrates
The unglamorous integration work that decides whether a product is buyable. A protocol bridge that leaves the server untouched, delivery guarantees on an unreliable link, an explicit position on untrusted clocks, and packaging that lets non-technical people run a daemon on hardware nobody is watching.
It is also the smallest project on this site and one of the more commercially consequential - it removed a hardware purchase from the sales conversation.