@palmcode/zkteco-iclock-parser
Version:
A TypeScript/JavaScript parser for ZKTeco iClock protocol attendance data with type safety and comprehensive error handling
386 lines • 14.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ZKTecoiClockParser = void 0;
const types_1 = require("./types");
/* biome-ignore lint/complexity/noStaticOnlyClass: Namespaced API surface for better DX */
class ZKTecoiClockParser {
/**
* Parse a timestamp string into a Date, treating it as device local time.
*
* @param timestampStr String timestamp from the device (e.g., "2025-09-04 16:14:09")
* @param timezoneOffset Timezone offset from UTC in hours (e.g., +4 for Dubai, -5 for EST)
* @returns Date instance or null when invalid
*/
static parseTimestamp(timestampStr, timezoneOffset = 4) {
if (!timestampStr)
return null;
try {
let clean = timestampStr.trim().replace(/\//g, "-");
let date = new Date(clean);
if (Number.isNaN(date.getTime())) {
clean = clean.replace(" ", "T");
date = new Date(clean);
if (Number.isNaN(date.getTime()))
return null;
}
const serverOffsetMin = date.getTimezoneOffset();
const deviceOffsetMin = -timezoneOffset * 60;
const offsetDifferenceMin = deviceOffsetMin - serverOffsetMin;
return new Date(date.getTime() + offsetDifferenceMin * 60 * 1000);
}
catch {
return null;
}
}
/**
* Parse a raw verify type into `VerifyType` enum.
*
* @param value Raw string value (e.g. "25")
* @returns VerifyType enum or null when unknown/invalid
*/
static parseVerifyType(value) {
if (!value)
return null;
const num = Number.parseInt(value, 10);
if (Number.isNaN(num))
return null;
return Object.values(types_1.VerifyType).includes(num)
? num
: null;
}
/**
* Parse a raw in/out mode into `InOutMode` enum.
*
* @param value Raw string value (e.g. "0")
* @returns InOutMode enum or null when unknown/invalid
*/
static parseInOutMode(value) {
if (!value)
return null;
const num = Number.parseInt(value, 10);
if (Number.isNaN(num))
return null;
return Object.values(types_1.InOutMode).includes(num)
? num
: null;
}
/**
* Parse a work code or default to NORMAL when invalid.
*
* @param value Raw string value (e.g. "0")
* @returns WorkCode enum
*/
static parseWorkCode(value) {
const num = Number.parseInt(value, 10);
return Number.isNaN(num) ? types_1.WorkCode.NORMAL : num;
}
/**
* Safe integer parsing helper.
*
* @param value Raw numeric string
* @returns Parsed number or undefined when invalid
*/
static parseNumber(value) {
const num = Number.parseInt(value, 10);
return Number.isNaN(num) ? undefined : num;
}
/**
* Parse a single ATTLOG row from ZKTeco iClock.
*
* Input is expected to be tab-separated fields.
*
* @param line The raw, single line of ATTLOG data
* @param lineNumber Optional line number (for error/warning context)
* @param options Parser options
* @returns ParseResult with a single AttendanceLog
*
* @example
* const row = "11\t2025-08-27 10:57:49\t0\t25\t0";
* const r = parseSingleAttendanceRecord(row);
* if (r.success) console.log(r.data!.userID);
*/
static parseSingleAttendanceRecord(line, lineNumber, options = {}) {
const parts = line.split("\t");
const warnings = [];
if (parts.length < 4) {
return {
success: false,
error: `Insufficient fields (${parts.length}/4 minimum required)`,
};
}
try {
const userID = parts[0]?.trim();
if (!userID)
return { success: false, error: "Missing User ID" };
const timestampStr = parts[1]?.trim();
if (!timestampStr)
return { success: false, error: "Missing timestamp" };
const timestamp = ZKTecoiClockParser.parseTimestamp(timestampStr, options.timezoneOffset ?? 4);
if (!timestamp)
return {
success: false,
error: `Invalid timestamp format: ${timestampStr}`,
};
const inOutModeRaw = parts[2]?.trim();
const inOutMode = ZKTecoiClockParser.parseInOutMode(inOutModeRaw);
if (inOutMode === null)
warnings.push(`Unknown in/out mode: ${inOutModeRaw}, using CHECK_IN`);
const verifyTypeRaw = parts[3]?.trim();
const verifyType = ZKTecoiClockParser.parseVerifyType(verifyTypeRaw);
if (verifyType === null)
warnings.push(`Unknown verify type: ${verifyTypeRaw}, using UNKNOWN`);
const workCode = parts[4]
? ZKTecoiClockParser.parseWorkCode(parts[4].trim())
: types_1.WorkCode.NORMAL;
const deviceID = parts[5]
? ZKTecoiClockParser.parseNumber(parts[5].trim())
: undefined;
const reserved = parts
.slice(6)
.map((p) => p.trim())
.filter((p) => p.length > 0);
const attendanceLog = {
userID,
timestamp,
inOutMode: inOutMode ?? types_1.InOutMode.CHECK_IN,
verifyType: verifyType ?? types_1.VerifyType.UNKNOWN,
workCode,
deviceID,
reserved: reserved.length > 0 ? reserved : undefined,
raw: options.includeRawData ? line : undefined,
};
return {
success: true,
data: attendanceLog,
warnings: warnings.length > 0 ? warnings : undefined,
};
}
catch (error) {
return { success: false, error: `Parse error: ${error}` };
}
}
/**
* Parse multi-line ATTLOG data into structured attendance logs.
*
* @param data Raw text body sent by device (tab-separated rows)
* @param options Parser options
* @returns ParseResult with list of AttendanceLog entries
*
* @example
* const body = `11\t2025-08-27 10:57:49\t0\t25\n11\t2025-08-27 10:58:58\t1\t1`;
* const result = ZKTecoiClockParser.parseAttendanceLog(body);
* if (result.success) result.data.forEach(log => console.log(log.userID));
*/
static parseAttendanceLog(data, options = {}) {
if (!data || typeof data !== "string") {
return { success: false, error: "Invalid or empty attendance data" };
}
const lines = data
.trim()
.split("\n")
.filter((line) => line.trim());
const attendances = [];
const warnings = [];
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
if (!raw)
continue;
const line = raw.trim();
const lineNumber = i + 1;
if (!line)
continue;
try {
const result = ZKTecoiClockParser.parseSingleAttendanceRecord(line, lineNumber, options);
if (result.success && result.data)
attendances.push(result.data);
if (result.warnings)
warnings.push(...result.warnings);
if (result.error) {
if (options.strictMode)
return {
success: false,
error: `Line ${lineNumber}: ${result.error}`,
};
warnings.push(`Line ${lineNumber}: ${result.error}`);
}
}
catch (error) {
const errorMsg = `Line ${lineNumber}: Unexpected error - ${error}`;
if (options.strictMode)
return { success: false, error: errorMsg };
warnings.push(errorMsg);
}
}
return {
success: true,
data: attendances,
warnings: warnings.length > 0 ? warnings : undefined,
};
}
/**
* Parse device info from iClock getrequest query parameters.
*
* @param queryParams Key-value map from request query (must include SN)
* @returns ParseResult with DeviceInfo details
*
* @example
* const q = { SN: 'ABC123', INFO: 'ZMM,1,0,10,192.168.1.10,0,0,0,0' };
* const r = ZKTecoiClockParser.parseDeviceInfo(q);
*/
static parseDeviceInfo(queryParams) {
if (!queryParams.SN) {
return {
success: false,
error: "Missing device serial number (SN parameter)",
};
}
const deviceInfo = {
serialNumber: queryParams.SN,
model: "",
userCount: 0,
fpCount: 0,
recordCount: 0,
deviceIP: "",
adminCount: 0,
passwordCount: 0,
cardCount: 0,
faceCount: 0,
};
if (queryParams.INFO) {
try {
const infoParts = queryParams.INFO.split(",");
deviceInfo.model = infoParts[0] || "";
deviceInfo.userCount = Number.parseInt(infoParts[1] || "0", 10) || 0;
deviceInfo.fpCount = Number.parseInt(infoParts[2] || "0", 10) || 0;
deviceInfo.recordCount = Number.parseInt(infoParts[3] || "0", 10) || 0;
deviceInfo.deviceIP = infoParts[4] || "";
deviceInfo.adminCount = Number.parseInt(infoParts[5] || "0", 10) || 0;
deviceInfo.passwordCount =
Number.parseInt(infoParts[6] || "0", 10) || 0;
deviceInfo.cardCount = Number.parseInt(infoParts[7] || "0", 10) || 0;
deviceInfo.faceCount = Number.parseInt(infoParts[8] || "0", 10) || 0;
if (infoParts.length > 9) {
deviceInfo.firmware = infoParts[9] || undefined;
deviceInfo.platform = infoParts[10] || undefined;
deviceInfo.fingerVer = infoParts[11] || undefined;
deviceInfo.faceVer = infoParts[12] || undefined;
deviceInfo.pushVer = infoParts[13] || undefined;
}
}
catch (error) {
return {
success: false,
error: `Failed to parse device info: ${error}`,
};
}
}
return { success: true, data: deviceInfo };
}
/**
* Get human-friendly name for a VerifyType.
* @param type VerifyType enum value
* @returns Name like "Palm Recognition"
*/
static getVerifyTypeName(type) {
return ZKTecoiClockParser.VERIFY_TYPE_NAMES[type] || `Unknown (${type})`;
}
/**
* Get human-friendly name for an InOutMode.
* @param mode InOutMode enum value
* @returns Name like "Check In"
*/
static getInOutModeName(mode) {
return ZKTecoiClockParser.IN_OUT_MODE_NAMES[mode] || `Unknown (${mode})`;
}
/**
* Pretty format an AttendanceLog for logs/console.
* @param log AttendanceLog entry
* @returns Formatted single-line string
*
* @example
* console.log(formatAttendanceLog(log));
*/
static formatAttendanceLog(log) {
const direction = log.inOutMode === types_1.InOutMode.CHECK_IN
? "→"
: log.inOutMode === types_1.InOutMode.CHECK_OUT
? "←"
: log.inOutMode === types_1.InOutMode.BREAK_OUT
? "⤴"
: log.inOutMode === types_1.InOutMode.BREAK_IN
? "⤵"
: "⚡";
return [
`${direction} User ${log.userID}`,
`📅 ${log.timestamp.toLocaleString()}`,
`🔐 ${ZKTecoiClockParser.getVerifyTypeName(log.verifyType)}`,
`📍 ${ZKTecoiClockParser.getInOutModeName(log.inOutMode)}`,
log.deviceID ? `🖥️ Device ${log.deviceID}` : "",
]
.filter(Boolean)
.join(" | ");
}
/**
* Whether an attendance log indicates a check-in event.
* @param log AttendanceLog entry
* @returns true if CHECK_IN/BREAK_IN/OT_IN
*/
static isCheckIn(log) {
return (log.inOutMode === types_1.InOutMode.CHECK_IN ||
log.inOutMode === types_1.InOutMode.BREAK_IN ||
log.inOutMode === types_1.InOutMode.OT_IN);
}
/**
* Whether an attendance log indicates a check-out event.
* @param log AttendanceLog entry
* @returns true if CHECK_OUT/BREAK_OUT/OT_OUT
*/
static isCheckOut(log) {
return (log.inOutMode === types_1.InOutMode.CHECK_OUT ||
log.inOutMode === types_1.InOutMode.BREAK_OUT ||
log.inOutMode === types_1.InOutMode.OT_OUT);
}
/**
* Validate structure and enum values of an AttendanceLog.
* @param log The attendance log to validate
* @returns ParseResult with the same log or an error
*/
static validateAttendanceLog(log) {
const errors = [];
if (!log.userID || log.userID.trim().length === 0)
errors.push("User ID is required");
if (!log.timestamp || Number.isNaN(log.timestamp.getTime()))
errors.push("Valid timestamp is required");
if (!Object.values(types_1.VerifyType).includes(log.verifyType))
errors.push("Invalid verify type");
if (!Object.values(types_1.InOutMode).includes(log.inOutMode))
errors.push("Invalid in/out mode");
if (errors.length > 0)
return { success: false, error: errors.join(", ") };
return { success: true, data: log };
}
}
exports.ZKTecoiClockParser = ZKTecoiClockParser;
/**
* Human readable names for `VerifyType` values.
*/
ZKTecoiClockParser.VERIFY_TYPE_NAMES = {
[types_1.VerifyType.UNKNOWN]: "Unknown",
[types_1.VerifyType.FINGERPRINT]: "Fingerprint",
[types_1.VerifyType.PASSWORD]: "Password",
[types_1.VerifyType.CARD]: "Card/RFID",
[types_1.VerifyType.FACE]: "Face Recognition",
[types_1.VerifyType.PALM]: "Palm Recognition",
};
/**
* Human readable names for `InOutMode` values.
*/
ZKTecoiClockParser.IN_OUT_MODE_NAMES = {
[types_1.InOutMode.CHECK_IN]: "Check In",
[types_1.InOutMode.CHECK_OUT]: "Check Out",
[types_1.InOutMode.BREAK_OUT]: "Break Out",
[types_1.InOutMode.BREAK_IN]: "Break In",
[types_1.InOutMode.OT_IN]: "Overtime In",
[types_1.InOutMode.OT_OUT]: "Overtime Out",
};
//# sourceMappingURL=parser.js.map