A Go library implementing the ZKTeco ADMS protocol for ZKTeco biometric attendance devices.
This library provides a complete implementation of the HTTP-based ADMS protocol used by ZKTeco devices to communicate with servers. It handles device registration, attendance data collection, and remote command execution.
Zero external dependencies — pure Go standard library.
- Full ADMS Protocol Support: Implements all standard endpoints (
/iclock/cdata,/iclock/getrequest,/iclock/devicecmd) plus device registry and inspection endpoints - Functional Options API: Clean, extensible configuration via
WithXoption functions - Structured Logging: Uses
log/slogfor structured, leveled logging - Context Support: Callbacks receive a
context.Contexttied to the server lifecycle - Attendance Data Processing: Parses and processes attendance logs with multiple timestamp formats
- Device Management: Thread-safe device registration and tracking
- Command Queuing: Queue and send commands to devices remotely
- Heartbeat & Online Status: Tracks last activity per device with configurable online threshold
- Concurrent-Safe: Built with goroutine-safe data structures and async callback dispatch
- Request Body Limits: Configurable
MaxBytesReaderprotection against oversized payloads - Serial Number Validation: Rejects malformed device identifiers at the protocol boundary
- Device & Command Limits: Configurable caps on registered devices and per-device command queue depth
- Opt-In Debug Endpoint:
/iclock/inspectis disabled by default, enabled viaWithEnableInspect() - Graceful Shutdown:
Close()drains pending callbacks and cancels the base context
Requires Go 1.26.
go get github.com/s0x90/zkteco-admspackage main
import (
"context"
"fmt"
"log"
"net/http"
"time"
zkadms "github.com/s0x90/zkteco-adms"
)
func main() {
server := zkadms.NewADMSServer(
zkadms.WithOnAttendance(func(ctx context.Context, record zkadms.AttendanceRecord) {
fmt.Printf("User %s status: %s at %s from device %s\n",
record.UserID,
statusString(record.Status),
record.Timestamp.Format(time.RFC3339),
record.SerialNumber)
}),
zkadms.WithOnDeviceInfo(func(ctx context.Context, sn string, info map[string]string) {
fmt.Printf("Device %s connected: %v\n", sn, info)
}),
)
defer server.Close()
http.Handle("/iclock/", server)
log.Fatal(http.ListenAndServe(":8080", nil))
}
func statusString(status int) string {
switch status {
case 0:
return "check-in"
case 1:
return "check-out"
case 2:
return "break-out"
case 3:
return "break-in"
case 4:
return "overtime-in"
case 5:
return "overtime-out"
default:
return "unknown"
}
}// Defaults: slog.Default() logger, 10 MB body limit, 256 callback buffer,
// 2-minute online threshold, 1-second dispatch timeout, 1000 max devices,
// 24h device eviction timeout.
server := zkadms.NewADMSServer()
defer server.Close()Configure the server at construction time:
server := zkadms.NewADMSServer(
zkadms.WithLogger(slog.New(slog.NewJSONHandler(os.Stderr, nil))),
zkadms.WithMaxBodySize(5 << 20), // 5 MB body limit
zkadms.WithCallbackBufferSize(512), // larger callback buffer
zkadms.WithOnlineThreshold(5 * time.Minute), // 5 min before "offline"
zkadms.WithDispatchTimeout(2 * time.Second), // callback dispatch timeout
zkadms.WithBaseContext(ctx), // tie to parent context
zkadms.WithOnAttendance(handleAttendance),
zkadms.WithOnDeviceInfo(handleDeviceInfo),
zkadms.WithOnRegistry(handleRegistry),
)
defer server.Close()| Option | Default | Description |
|---|---|---|
WithLogger |
slog.Default() |
Structured logger |
WithMaxBodySize |
10 MB | Max request body size |
WithCallbackBufferSize |
256 | Internal callback channel capacity |
WithOnlineThreshold |
2 min | Duration before a device is considered offline |
WithDispatchTimeout |
1 sec | Max block time when callback queue is full |
WithBaseContext |
context.Background() |
Parent context for callbacks |
WithMaxDevices |
1000 | Max registered devices; use WithUnlimitedDevices() to remove the cap |
WithUnlimitedDevices |
— | Remove the device registration limit (use with caution) |
WithMaxCommandsPerDevice |
0 (unlimited) | Max queued commands per device; returns ErrCommandQueueFull |
WithDeviceEvictionInterval |
5 min | How often the eviction worker checks for stale devices |
WithDeviceEvictionTimeout |
24 hours | Inactivity duration before a device is automatically evicted |
WithEnableInspect |
disabled | Enable the /iclock/inspect debug endpoint |
WithDefaultTimezone |
time.UTC |
Fallback timezone for parsing attendance timestamps (see WithDeviceTimezone) |
WithOnAttendance |
nil | Attendance record callback |
WithOnDeviceInfo |
nil | Device info callback |
WithOnRegistry |
nil | Device registry callback |
WithOnCommandResult |
nil | Command confirmation callback (see CommandResult) |
WithOnQueryUsers |
nil | User query callback; receives []UserRecord pushed by the device |
zkadms.WithOnAttendance(func(ctx context.Context, record zkadms.AttendanceRecord) {
// record.UserID - Employee ID
// record.Timestamp - Time of attendance
// record.Status - 0=Check In, 1=Check Out, 2=Break Out, 3=Break In, 4=Overtime In, 5=Overtime Out
// record.VerifyMode - Verification method; use zkadms.VerifyModeName(record.VerifyMode) for label
// record.WorkCode - Optional work code
// record.SerialNumber - Device serial number
//
// ctx is cancelled when server.Close() is called.
})zkadms.WithOnDeviceInfo(func(ctx context.Context, sn string, info map[string]string) {
// sn - Device serial number
// info - Map of device properties (firmware version, device name, etc.)
})zkadms.WithOnRegistry(func(ctx context.Context, sn string, info map[string]string) {
// Called when a device registers or re-registers.
// info contains parsed key=value pairs from the registry body.
})zkadms.WithOnCommandResult(func(ctx context.Context, result zkadms.CommandResult) {
// Called when a device reports the result of a command.
// result.SerialNumber - Device that executed the command
// result.ID - Command ID assigned by the server
// result.ReturnCode - 0 = success, non-zero = error
// result.Command - Command type echoed back (e.g. "INFO", "DATA")
})zkadms.WithOnQueryUsers(func(ctx context.Context, sn string, users []zkadms.UserRecord) {
// Called when a device pushes user records in response to
// SendQueryUsersCommand(). Data arrives via POST /iclock/cdata.
for _, u := range users {
fmt.Printf("PIN=%s Name=%s Privilege=%d Card=%s\n",
u.PIN, u.Name, u.Privilege, u.Card)
}
})// Queue a custom command (returns ErrCommandQueueFull if limit reached)
_, err := server.QueueCommand("DEVICE001", "CHECK")
// Request device information
_, err = server.SendInfoCommand("DEVICE001")
// Heartbeat / connectivity check
_, err = server.SendCheckCommand("DEVICE001")
// Add or update a user on the device
_, err = server.SendUserAddCommand("DEVICE001", "12345", "John Doe", 0, "")
// Delete a user from the device
_, err = server.SendUserDeleteCommand("DEVICE001", "12345")
// Retrieve a device option (value arrives via device info push)
_, err = server.SendGetOptionCommand("DEVICE001", "DeviceName")
// Query all users (data pushed via POST /iclock/cdata with table=USERINFO)
_, err = server.SendQueryUsersCommand("DEVICE001")
// Execute a shell command on the device (use with caution!)
_, err = server.SendShellCommand("DEVICE001", "date")
// Request log data
_, err = server.SendLogCommand("DEVICE001")
// Drain all pending commands for a device
cmds := server.DrainCommands("DEVICE001")
for _, cmd := range cmds {
fmt.Println(cmd.ID, cmd.Command)
}devices := server.ListDevices()
for _, device := range devices {
online := server.IsDeviceOnline(device.SerialNumber)
fmt.Printf("Device: %s, Last Seen: %s, Online: %t\n",
device.SerialNumber,
device.LastActivity.Format(time.RFC3339),
online)
}The server implements http.Handler, so you can use it directly:
http.Handle("/iclock/", server)Or register individual endpoints:
http.HandleFunc("/iclock/cdata", server.HandleCData)
http.HandleFunc("/iclock/registry", server.HandleRegistry)
http.HandleFunc("/iclock/getrequest", server.HandleGetRequest)
http.HandleFunc("/iclock/devicecmd", server.HandleDeviceCmd)
http.HandleFunc("/iclock/inspect", server.HandleInspect) // opt-in: not routed by ServeHTTP unless WithEnableInspect is setvar (
zkadms.ErrServerClosed // operation attempted on a closed server
zkadms.ErrCallbackQueueFull // callback queue full, dispatch timed out
zkadms.ErrMaxDevicesReached // device limit reached (WithMaxDevices)
zkadms.ErrCommandQueueFull // per-device command queue full (WithMaxCommandsPerDevice)
zkadms.ErrInvalidSerialNumber // serial number failed validation
zkadms.ErrDeviceNotFound // operation targets an unregistered device
zkadms.ErrInvalidCommandField // command field contains forbidden control characters
)server := zkadms.NewADMSServer(/* ... */)
// ... use server ...
// Close drains pending callbacks, cancels the base context, and stops the worker.
// Safe to call multiple times.
server.Close()| Endpoint | Method | Purpose |
|---|---|---|
/iclock/cdata |
GET/POST | Receives attendance logs (ATTLOG) and operation logs (OPERLOG). Also accepts device info POSTs |
/iclock/registry |
GET/POST | Device registration and capability payloads (key=value comma-separated) |
/iclock/getrequest |
GET | Device polls for pending commands |
/iclock/devicecmd |
POST | Device reports command execution results |
/iclock/inspect |
GET | Returns JSON summary of devices and their current status (opt-in via WithEnableInspect) |
When a device polls /iclock/getrequest, pending commands are sent as:
C:<ID>:<CMD>\n
Where <ID> is a monotonically increasing integer assigned by the server. For example:
C:1:INFO
C:2:DATA UPDATE USERINFO PIN=1001 Name=John Doe Privilege=0 Card=12345678
C:3:DATA DELETE USERINFO PIN=1001
After executing a command, the device POSTs the result to /iclock/devicecmd with a body like:
ID=1&Return=0&CMD=INFO
A Return value of 0 indicates success. The parsed result is delivered to the callback registered via WithOnCommandResult.
Devices may batch multiple confirmations in a single POST:
ID=1&Return=0&CMD=DATA
ID=2&Return=0&CMD=DATA
The parser handles both batched and multiline formats (e.g. Shell command responses with Content= fields).
These error codes have been confirmed on real ZAM180-NF firmware:
| Code | Meaning |
|---|---|
0 |
Success |
-1 |
Command not supported or no data available |
-2 |
File operation failed |
-1002 |
Invalid command syntax |
-1004 |
Table/feature not supported on this device model |
The following commands have been verified on real hardware (SpeedFace-V5L-RFID, ZAM180-NF-Ver1.1.17 firmware):
| Command | Convenience Method | Confirmed CMD echo | Notes |
|---|---|---|---|
INFO |
SendInfoCommand |
INFO |
Returns full device info |
CHECK |
SendCheckCommand |
CHECK |
Heartbeat/ping |
GET OPTION FROM <key> |
SendGetOptionCommand |
GET OPTION |
See key list below |
DATA UPDATE USERINFO PIN=... |
SendUserAddCommand |
DATA |
Tab-separated fields |
DATA DELETE USERINFO PIN=... |
SendUserDeleteCommand |
DATA |
|
DATA QUERY USERINFO |
SendQueryUsersCommand |
DATA |
Data pushed via /iclock/cdata (see note) |
DATA QUERY USERINFO PIN=<n> |
QueueCommand |
DATA |
Query single user (see note) |
Shell <cmd> |
SendShellCommand |
Shell |
Executes OS commands |
LOG |
SendLogCommand |
LOG |
Request log data |
Confirmed GET OPTION keys: DeviceName, FWVersion, IPAddress, MACAddress, Platform, WorkCode, LockCount, UserCount, FPCount, AttLogCount, FaceCount, TransactionCount, MaxUserCount, MaxAttLogCount, MaxFingerCount, MaxFaceCount.
Important protocol notes:
- The ADMS datasheet documents user commands as
USER ADD/USER DEL, but real devices reject these with error -1002. UseDATA UPDATE USERINFO/DATA DELETE USERINFOinstead. DATA DEL USERINFO(truncated) also fails — the full wordDELETEis required.DATA QUERYcommands cause the device to push data viaPOST /iclock/cdata, not via the command confirmation endpoint. The library fully parsesUSERINFOrecords pushed this way and dispatches them via theWithOnQueryUserscallback. UseSendQueryUsersCommandto trigger a full user dump, orQueueCommand(sn, "DATA QUERY USERINFO PIN=1")for a single user.Shellcommands execute on the device's Linux OS — use with extreme caution.
Some ZKTeco devices POST a registry body containing comma-separated key=value pairs, e.g.:
DeviceType=acc,~DeviceName=SpeedFace-V5L-RFID[TI],FirmVer=ZAM180...,IPAddress=192.168.1.201
Notes:
- Keys can be prefixed with
~. The tilde is stripped when parsed. - Values are stored into
Device.Optionsfor subsequent inspection. - The handler merges all parsed keys into the registered device.
The server updates Device.LastActivity at each request from the device and marks the device online.
- A device is considered online if its last activity is within the online threshold (default: 2 minutes, configurable via
WithOnlineThreshold). - The
/iclock/inspectendpoint reports for each device:serial: device serial numberlastActivity: RFC3339 timestamp of last activityonline: boolean derived from last activityoptions: the registry/options maptimezone: effective timezone for timestamp parsing (device → server default → UTC)
Devices send attendance data as tab-separated values:
UserID\tTimestamp\tStatus\tVerifyMode\tWorkCode
Example:
123 2024-01-01 08:00:00 0 1 0
Supported timestamp formats:
2006-01-02 15:04:05(standard datetime)- Unix timestamp (seconds since epoch)
0- Check In1- Check Out2- Break Out3- Break In4- Overtime In5- Overtime Out
These are the ADMS protocol verify mode values observed from real devices.
Use zkadms.VerifyModeName(mode) to resolve any value to a human-readable label.
| Value | Method |
|---|---|
0 |
Password |
1 |
Fingerprint |
2 |
Card (legacy) |
3 |
Password (alternative) |
4 |
Card |
5 |
Fingerprint+Card |
6 |
Fingerprint+Password |
7 |
Card+Password |
8 |
Card+Fingerprint+Password |
9 |
Other |
15 |
Face |
25 |
Palm |
Note: Values may vary across device models and firmware versions. The constants
VerifyModePassword,VerifyModeFingerprint,VerifyModeCard,VerifyModeFace, andVerifyModePalmare provided for the most common codes.
See the examples directory for complete examples:
- basic - Simple server with status endpoint
- commands - Device command management via REST API
- database - Integration with database storage
The cmd/probe tool queues candidate commands to a real device and reports which ones succeed vs fail. Useful for discovering what your specific device model supports:
go run ./cmd/probe -addr :8080 -sn YOUR_SERIAL_NUMBERUse -destructive to include potentially dangerous commands (CONTROL DEVICE, SET OPTION, etc.).
go run ./examples/basicThen configure your ZKTeco device to connect to:
http://your-server:8080/iclock/
The commands example exposes a REST API for managing devices:
go run ./examples/commands -devices ABCD12345678Endpoints:
| Method | Path | Description |
|---|---|---|
| GET | /api/devices |
List all connected devices |
| GET | /api/devices/{sn} |
Device detail |
| POST | /api/devices/{sn}/reboot |
Reboot device |
| POST | /api/devices/{sn}/info |
Request device info |
| POST | /api/devices/{sn}/check |
Heartbeat / connectivity check |
| POST | /api/devices/{sn}/sync-time |
Sync device clock |
| POST | /api/devices/{sn}/clear-data |
Clear attendance data |
| POST | /api/devices/{sn}/clear-log |
Clear operation log |
| POST | /api/devices/{sn}/users |
Add/update user (JSON body) |
| POST | /api/devices/{sn}/users/delete |
Delete user (JSON body) |
| POST | /api/devices/{sn}/users/query |
Query all users from device |
| POST | /api/devices/{sn}/open-door |
Trigger door relay |
| POST | /api/devices/{sn}/get-option |
Get device option (JSON body) |
| POST | /api/devices/{sn}/shell |
Execute shell command (JSON body) |
| POST | /api/devices/{sn}/log |
Request log data |
| POST | /api/devices/{sn}/command |
Send raw command (JSON body) |
Example curl usage:
# Request device info
curl -X POST http://localhost:8080/api/devices/<SN>/info
# Heartbeat check
curl -X POST http://localhost:8080/api/devices/<SN>/check
# Add a user
curl -X POST http://localhost:8080/api/devices/<SN>/users \
-H 'Content-Type: application/json' \
-d '{"pin":"1001","name":"John Doe","privilege":0,"card":"12345678"}'
# Delete a user
curl -X POST http://localhost:8080/api/devices/<SN>/users/delete \
-H 'Content-Type: application/json' \
-d '{"pin":"1001"}'
# Query all users from device (data pushed via /iclock/cdata)
curl -X POST http://localhost:8080/api/devices/<SN>/users/query
# Get a device option
curl -X POST http://localhost:8080/api/devices/<SN>/get-option \
-H 'Content-Type: application/json' \
-d '{"key":"DeviceName"}'
# Execute a shell command on the device (use with caution!)
curl -X POST http://localhost:8080/api/devices/<SN>/shell \
-H 'Content-Type: application/json' \
-d '{"command":"date"}'
# Request log data
curl -X POST http://localhost:8080/api/devices/<SN>/log
# Reboot device
curl -X POST http://localhost:8080/api/devices/<SN>/reboot
# Send a raw command
curl -X POST http://localhost:8080/api/devices/<SN>/command \
-H 'Content-Type: application/json' \
-d '{"command":"GET OPTION FROM FWVersion"}'Run the test suite:
go test -vRun with race detection and coverage:
go test -v -race -coverRun benchmarks:
go test -bench=.A function that configures an ADMSServer. Obtained via WithX functions.
Main server structure handling all protocol operations. Implements http.Handler.
Represents a registered ZKTeco device with SerialNumber, LastActivity, Options, and Timezone fields.
Represents a single attendance transaction with UserID, Timestamp, Status, VerifyMode, WorkCode, and SerialNumber fields.
JSON representation of a device in the /iclock/inspect response.
Represents the result of a command execution reported by a device. Fields: SerialNumber, ID (int64), ReturnCode (int, 0 = success), Command (string), and QueuedCommand (original queued command string for correlation).
Pairs a pre-assigned command ID with the command string. Returned by DrainCommands.
Represents a user record returned by a device in response to a DATA QUERY USERINFO command. Fields: PIN, Name, Privilege (int), Card, Password.
A function that configures a Device during registration. Obtained via WithDevice* functions (e.g. WithDeviceTimezone).
Creates a new ADMS server instance configured with the given options.
| Function | Description |
|---|---|
WithLogger(*slog.Logger) |
Set structured logger |
WithMaxBodySize(int64) |
Set max request body size |
WithCallbackBufferSize(int) |
Set callback channel capacity |
WithOnlineThreshold(time.Duration) |
Set device online threshold |
WithDispatchTimeout(time.Duration) |
Set callback dispatch timeout |
WithBaseContext(context.Context) |
Set parent context |
WithMaxDevices(int) |
Set max registered devices (default 1000) |
WithUnlimitedDevices() |
Remove the device registration limit |
WithMaxCommandsPerDevice(int) |
Set max command queue depth per device |
WithDeviceEvictionInterval(time.Duration) |
Set stale-device check interval |
WithDeviceEvictionTimeout(time.Duration) |
Set inactivity threshold for eviction |
WithEnableInspect() |
Enable /iclock/inspect in ServeHTTP router |
WithDefaultTimezone(*time.Location) |
Set fallback timezone for attendance timestamp parsing (default time.UTC) |
WithOnAttendance(func(context.Context, AttendanceRecord)) |
Set attendance callback |
WithOnDeviceInfo(func(context.Context, string, map[string]string)) |
Set device info callback |
WithOnRegistry(func(context.Context, string, map[string]string)) |
Set registry callback |
WithOnCommandResult(func(context.Context, CommandResult)) |
Set command confirmation callback |
WithOnQueryUsers(func(context.Context, string, []UserRecord)) |
Set user query callback |
| Method | Description |
|---|---|
Close() |
Drain callbacks and stop the worker goroutine |
RegisterDevice(serialNumber string, opts ...DeviceOption) error |
Register a device; validates SN, respects device limit |
GetDevice(serialNumber string) *Device |
Get device information (returns a copy) |
SetDeviceTimezone(serialNumber string, loc *time.Location) error |
Set per-device timezone; returns ErrDeviceNotFound if unknown |
GetDeviceTimezone(serialNumber string) *time.Location |
Get effective timezone (device -> server default -> UTC); nil if unknown |
IsDeviceOnline(serialNumber string) bool |
Check if a device is online |
QueueCommand(serialNumber, command string) (int64, error) |
Queue a command; validates device, fields, and per-device limit |
DrainCommands(serialNumber string) []CommandEntry |
Drain and return all pending commands |
PendingCommandsCount(serialNumber string) int |
Return the number of queued commands without draining |
SendInfoCommand(serialNumber string) (int64, error) |
Queue an INFO command |
SendCheckCommand(serialNumber string) (int64, error) |
Queue a CHECK (heartbeat) command |
SendUserAddCommand(serialNumber, pin, name string, privilege int, card string) (int64, error) |
Queue a DATA UPDATE USERINFO command |
SendUserDeleteCommand(serialNumber, pin string) (int64, error) |
Queue a DATA DELETE USERINFO command |
SendGetOptionCommand(serialNumber, key string) (int64, error) |
Queue a GET OPTION FROM command |
SendQueryUsersCommand(serialNumber string) (int64, error) |
Queue a DATA QUERY USERINFO command |
SendShellCommand(serialNumber, command string) (int64, error) |
Queue a Shell command (use with caution) |
SendLogCommand(serialNumber string) (int64, error) |
Queue a LOG command |
ListDevices() []*Device |
List all registered devices (returns copies) |
ServeHTTP(w, r) |
http.Handler implementation — routes to endpoint handlers |
HandleCData(w, r) |
Handle /iclock/cdata requests |
HandleRegistry(w, r) |
Handle /iclock/registry requests |
HandleGetRequest(w, r) |
Handle /iclock/getrequest requests |
HandleDeviceCmd(w, r) |
Handle /iclock/devicecmd requests |
HandleInspect(w, r) |
Handle /iclock/inspect requests (JSON device snapshot) |
Parses URL query parameters into a map.
| Error | Description |
|---|---|
ErrServerClosed |
Returned when an operation is attempted on a closed server |
ErrCallbackQueueFull |
Returned when the callback queue is full and dispatch timed out |
ErrMaxDevicesReached |
Returned by RegisterDevice when the device limit is reached |
ErrCommandQueueFull |
Returned by QueueCommand when the per-device command limit is reached |
ErrInvalidSerialNumber |
Returned when a serial number is empty, too long, or contains invalid characters |
ErrDeviceNotFound |
Returned when an operation targets a device not registered with the server |
ErrInvalidCommandField |
Returned when a command field contains control characters that could cause injection |
Configure your ZKTeco device to connect to your server:
- Access device web interface or admin panel
- Chose ADMS
- Set server address:
http://your-server:8080
MIT License - see LICENSE file for details
Contributions are welcome! See CONTRIBUTING.md for guidelines.
For issues and questions, please open an issue on GitHub.