feat:对接读写卡器

feat:优化用户下拉框搜索
feat:优化下单卡顿问题
feat:优化商户列表样式
This commit is contained in:
dzc committed 2026-10-13 17:24:57 +08:00
1 parent b95603829a
commit 8b2c08920f
24 files changed
+2461 -974

No files matched your search

+523
View File
@@ -0,0 +1,523 @@
/**
* USB 免驱读写卡器通信层(荣士 IC-02 / OUR 系列,浏览器 WebHID 直连)
*
* 要求 Chrome/Edge 桌面版 + HTTPS 或 localhost 访问。模块级单例状态(仿
* src/api/plugin/cardPrinter.ts 的模式):弹窗反复开关共享同一次连接,设备保持打开,
* 读卡失败/超时不自动断开,仅物理拔出或显式断开时释放。
* 设备授权会被浏览器持久记住:弹窗打开时静默自动重连(免选择器直连),换设备用 pickDevice()。
*
* 开发联调:.env 中 VITE_PLUGIN_MOCK=true 可脱离硬件模拟读卡全流程。
*/
import { reactive } from 'vue';
import emitter from '/@/utils/mitt';
import {
CMD,
OUR_USB_VID,
buildAuthKeyE2Payload,
buildChangeKeyPayload,
buildLoadKeyPayload,
buildOurFrame,
buildOurFrames,
buildReadBlocksPayload,
buildWriteBlocksPayload,
crc8Ours,
parseKeyHex,
parseOurPacket,
statusText,
toHex,
} from './protocol';
export * from './protocol';
const MOCK_ENABLED = import.meta.env.VITE_PLUGIN_MOCK === 'true' || import.meta.env.VITE_PLUGIN_MOCK === '1';
/** 单帧收发超时(ms) */
const TRANSCEIVE_TIMEOUT = 3000;
/** 报文日志上限 */
const FRAME_LOG_LIMIT = 50;
export interface CardInfo {
/** 卡号十六进制(大写无分隔,如 04A1B2C3) */
uid: string;
uidBytes: number[];
/** M1 / NTAG/DesFire */
cardType: string;
readAt: number;
}
export interface FrameLogItem {
/** tx 发送帧 / rx 收到完整有效帧 / rx-bad 校验失败帧 / rx-raw 收到的原始字节(未成帧,联调判断设备是否应答) / sys 连接事件 */
dir: 'tx' | 'rx' | 'rx-bad' | 'rx-raw' | 'sys' | 'mock';
/** hex 为报文十六进制;sys 事件时为文字说明 */
hex: string;
at: number;
}
export type PortState = 'disconnected' | 'connecting' | 'connected';
interface Waiter {
resolve: (payload: number[]) => void;
reject: (err: Error) => void;
timer: number;
}
const state = reactive<{
/** 浏览器是否支持 WebHID */
hidSupported: boolean;
portState: PortState;
reading: boolean;
beeping: boolean;
lastCard: CardInfo | null;
error: string;
portInfo: { usbVendorId?: number; usbProductId?: number } | null;
frames: FrameLogItem[];
}>({
hidSupported: typeof navigator !== 'undefined' && 'hid' in navigator,
portState: 'disconnected',
reading: false,
beeping: false,
lastCard: null,
error: '',
portInfo: null,
frames: [],
});
let hidDevice: HIDDevice | null = null;
let hidWaiter: Waiter | null = null;
/** 当前等待的应答命令回显,用于过滤迟到/串线的无关报文 */
let hidExpectedEcho = 0;
const pushFrame = (dir: FrameLogItem['dir'], hex: string) => {
state.frames.unshift({ dir, hex, at: Date.now() });
if (state.frames.length > FRAME_LOG_LIMIT) state.frames.length = FRAME_LOG_LIMIT;
};
// ---------------------------------------------------------------------------
// 收包:先记录原始字节(判断设备是否应答过任何数据),再按 78 68 同步头提取应答。
// 应答首字节须匹配当前指令的命令回显,不匹配的(迟到旧包)丢弃,避免串线。
// ---------------------------------------------------------------------------
const onInputReport = (event: HIDInputReportEvent) => {
const bytes = Array.from(new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength));
if (bytes.length) pushFrame('rx-raw', toHex(bytes));
const payload = parseOurPacket(bytes);
if (!payload) return;
if (hidWaiter && payload[0] !== hidExpectedEcho) {
pushFrame('rx-bad', `串线报文已丢弃: ${toHex(buildOurFrame(payload))}`);
return;
}
pushFrame('rx', toHex(buildOurFrame(payload)));
if (hidWaiter) {
const waiter = hidWaiter;
hidWaiter = null;
window.clearTimeout(waiter.timer);
waiter.resolve(payload);
}
};
const onHidDisconnect = (event: HIDConnectionEvent) => {
if (event.device === hidDevice) {
handlePortLost();
state.error = '设备连接已断开,请重新连接';
pushFrame('sys', 'USB 设备已断开(拔出)');
}
};
// 物理插拔监听全局注册一次
if (typeof navigator !== 'undefined' && navigator.hid) {
navigator.hid.addEventListener('disconnect', onHidDisconnect);
}
const waitHidPayload = (timeout: number) =>
new Promise<number[]>((resolve, reject) => {
const timer = window.setTimeout(() => {
hidWaiter = null;
reject(new Error('读写器无响应,请检查设备连接后重试'));
}, timeout);
hidWaiter = {
timer,
resolve: (payload) => {
window.clearTimeout(timer);
resolve(payload);
},
reject: (err) => {
window.clearTimeout(timer);
reject(err);
},
};
});
const hidTransceive = async (payload: number[], timeout = TRANSCEIVE_TIMEOUT): Promise<number[]> => {
if (!hidDevice?.opened) throw new Error('设备未连接');
const echo = payload[0];
for (const frame of buildOurFrames(payload)) {
await hidDevice.sendReport(0, new Uint8Array(frame));
pushFrame('tx', toHex(frame));
}
if (hidWaiter) {
const waiter = hidWaiter;
hidWaiter = null;
waiter.reject(new Error('新的指令已发出,上一条等待作废'));
}
hidExpectedEcho = echo;
return await waitHidPayload(timeout);
};
/** 打开读写器并挂收包监听(热更新等场景设备可能已被之前的页面实例打开,直接接管) */
const openHidDevice = async (device: HIDDevice): Promise<void> => {
if (!device.opened) await device.open();
hidDevice = device;
device.addEventListener('inputreport', onInputReport);
state.portInfo = { usbVendorId: device.vendorId, usbProductId: device.productId };
state.portState = 'connected';
pushFrame('sys', `USB 免驱读卡器已连接 VID:${toHex16(device.vendorId)} PID:${toHex16(device.productId)}`);
};
const toHex16 = (value: number) => value.toString(16).toUpperCase().padStart(4, '0');
const handlePortLost = () => {
if (hidDevice) {
try {
hidDevice.removeEventListener('inputreport', onInputReport);
} catch {
// 设备已拔出时移除监听可能报错,忽略
}
}
hidDevice = null;
loadedKeyTag = '';
if (hidWaiter) {
const waiter = hidWaiter;
hidWaiter = null;
window.clearTimeout(waiter.timer);
waiter.reject(new Error('设备连接已断开'));
}
state.portState = 'disconnected';
state.portInfo = null;
};
/**
* 连接读写器。优先复用浏览器已授权的 OUR 设备(免弹窗直连);找不到时弹出系统设备
* 选择器(requestDevice 需在用户点击手势调用链内触发)。
* @param options.silent 静默模式:只尝试已授权设备,找不到不弹选择器、不报错,用于弹窗打开时自动重连
* @returns 是否连接成功(用户在系统弹窗取消选择也返回 false,不视为错误)
*/
const connect = async (options: { silent?: boolean } = {}): Promise<boolean> => {
if (!state.hidSupported) throw new Error('当前浏览器不支持 USB 设备操作,请使用 Chrome/Edge 并通过 HTTPS 或本机地址访问');
if (state.portState !== 'disconnected') return state.portState === 'connected';
state.error = '';
state.portState = 'connecting';
try {
if (MOCK_ENABLED) {
await new Promise((resolve) => setTimeout(resolve, 400));
state.portState = 'connected';
pushFrame('mock', '已启用模拟模式(VITE_PLUGIN_MOCK)');
return true;
}
const hid = navigator.hid;
if (!hid) throw new Error('当前浏览器不支持 USB 设备操作');
// 1) 浏览器记住的已授权设备:优先厂家识别号(VID 0801),其次设备名 IC 开头(不同批次 VID 可能不同)
const granted = await hid.getDevices();
const authorized = granted.find((d) => d.vendorId === OUR_USB_VID) ?? granted.find((d) => (d.productName ?? '').toUpperCase().startsWith('IC'));
if (authorized) {
await openHidDevice(authorized);
return true;
}
if (options.silent) {
state.portState = 'disconnected';
return false;
}
// 2) 弹出系统设备选择器(按厂商号过滤)
let devices: HIDDevice[];
try {
devices = await hid.requestDevice({ filters: [{ vendorId: OUR_USB_VID }] });
} catch {
// 用户在系统弹窗取消选择
state.portState = 'disconnected';
return false;
}
if (!devices.length) {
state.portState = 'disconnected';
return false;
}
await openHidDevice(devices[0]);
return true;
} catch (e) {
handlePortLost();
state.error = `连接失败:${e instanceof Error ? e.message : String(e)}`;
return false;
}
};
/** 强制重新弹出选择器更换读写器(会先断开当前连接) */
const pickDevice = async (): Promise<boolean> => {
if (!state.hidSupported) throw new Error('当前浏览器不支持 USB 设备操作');
if (state.portState === 'connected') await disconnect();
state.error = '';
state.portState = 'connecting';
try {
const devices = await navigator.hid!.requestDevice({ filters: [{ vendorId: OUR_USB_VID }] });
if (!devices.length) {
state.portState = 'disconnected';
return false;
}
await openHidDevice(devices[0]);
return true;
} catch (e) {
state.portState = 'disconnected';
const msg = e instanceof Error ? e.message : String(e);
if (/No device|cancel|abort/i.test(msg)) return false;
state.error = `连接失败:${msg}`;
return false;
}
};
/** 主动断开并释放设备 */
const disconnect = async (): Promise<void> => {
const device = hidDevice;
handlePortLost();
if (MOCK_ENABLED) return;
if (!device) return;
try {
await device.close();
} catch {
// 忽略
}
pushFrame('sys', 'USB 免驱读卡器已断开');
};
// ---------------------------------------------------------------------------
// 业务指令
// ---------------------------------------------------------------------------
/** 读卡号应答解析:payload = [F0 回显][状态][卡号4字节][crc8(状态+卡号)] */
const parseOurReadIdResponse = (payload: number[]): { uidBytes: number[]; cardType: string } => {
if (payload[0] !== CMD.READ_ID) throw new Error(`应答命令不符(0x${payload[0].toString(16)})`);
const status = payload[1];
if (status !== 0x00) throw new Error(statusText(status));
if (payload.length < 7) throw new Error('应答缺少卡号数据');
const body = payload.slice(1, 6);
if (crc8Ours(body) !== payload[6]) throw new Error('应答校验失败,请重新读卡');
const uidBytes = payload.slice(2, 6);
return { uidBytes, cardType: 'M1' };
};
const readCard = async (): Promise<CardInfo> => {
if (state.reading) throw new Error('正在读取中,请稍候');
state.error = '';
if (MOCK_ENABLED) {
state.reading = true;
try {
await new Promise((resolve) => setTimeout(resolve, 600));
const rand = new Uint8Array(2);
crypto.getRandomValues(rand);
const uidBytes = [0x04, 0xa1, rand[0], rand[1]];
const payload = [CMD.READ_ID, 0x00, ...uidBytes, crc8Ours([0x00, ...uidBytes])];
pushFrame('tx', toHex(buildOurFrame([CMD.READ_ID])));
pushFrame('rx', toHex(buildOurFrame(payload)));
const card: CardInfo = { uid: toHex(uidBytes), uidBytes, cardType: 'M1', readAt: Date.now() };
state.lastCard = card;
emitter.emit('cardReaderRead', card);
return card;
} finally {
state.reading = false;
}
}
if (state.portState !== 'connected') throw new Error('设备未连接,请先连接读写器');
state.reading = true;
try {
const payload = await hidTransceive([CMD.READ_ID]);
const { uidBytes, cardType } = parseOurReadIdResponse(payload);
const card: CardInfo = { uid: toHex(uidBytes), uidBytes, cardType, readAt: Date.now() };
state.lastCard = card;
emitter.emit('cardReaderRead', card);
return card;
} catch (e) {
state.error = e instanceof Error ? e.message : String(e);
throw e;
} finally {
state.reading = false;
}
};
/** 蜂鸣鸣响(用于确认链路/找回设备位置),超时不视为失败 */
const beep = async (): Promise<void> => {
if (state.beeping) return;
if (!MOCK_ENABLED && state.portState !== 'connected') throw new Error('设备未连接,请先连接读写器');
state.beeping = true;
try {
if (MOCK_ENABLED) {
pushFrame('mock', 'BEEP');
await new Promise((resolve) => setTimeout(resolve, 200));
return;
}
// OUR 蜂鸣:0F 时长低 时长高 + crc8(两字节时长)
const duration = [0xff, 0x00];
await hidTransceive([CMD.BEEP, duration[0], duration[1], crc8Ours(duration)], 1500);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('无响应')) throw e;
} finally {
state.beeping = false;
}
};
// ---------------------------------------------------------------------------
// 扇区读写(本设备实际流程:装载密码(87) → 寻卡(F0) → C3认证紧邻 → F7读/F8写)
// 认证策略(用户无感):先用商户密码认证;失败自动用出厂密码 FFFFFFFFFFFF 再试;
// 出厂密码能通说明是空白新卡 → 自动把卡密码改为商户密码(0x3C,设备不支持则跳过),
// 之后统一用商户密码读写。
// ---------------------------------------------------------------------------
export interface SectorReadResult {
uidBytes: number[];
/** 16 字节卡内容(系统约定的扇区固定数据块) */
data: number[];
}
/** 出厂密码(空白新卡) */
const FACTORY_KEY = 'FFFFFFFFFFFF';
/** 已装载进读写器 E2 的密码缓存标记,避免每次读写都重装 */
let loadedKeyTag = '';
/** 密码认证失败专用错误,用于触发出厂密码降级 */
class AuthFailError extends Error {}
/** 把扇区密码装载进读写器 E2(应答 [87][状态]),装载失败抛白话提示 */
const ensureReaderKey = async (area: number, keyAB: 0 | 1, keyHex: string): Promise<void> => {
const tag = `${area}:${keyAB}:${keyHex}`;
if (loadedKeyTag === tag) return;
const payload = await hidTransceive(buildLoadKeyPayload(area, keyAB, parseKeyHex(keyHex)));
if (payload[0] !== CMD.KEY_LOAD_E2) throw new Error(`应答命令不符(0x${payload[0].toString(16)})`);
const status = payload[1];
if (status !== 0x00) throw new Error('密码装载失败:' + statusText(status));
loadedKeyTag = tag;
};
/** 寻卡(F0,选中卡片)——认证必须紧随其后 */
const requestCard = async (): Promise<number[]> => {
const card = await readCard();
return card.uidBytes;
};
/** 用 E2 已装载密码认证扇区(应答 [C3][状态])。紧跟寻卡,此时卡片必然在位,失败即密码不对。 */
const authKeyE2 = async (uidBytes: number[], area: number, keyAB: 0 | 1): Promise<void> => {
const payload = await hidTransceive(buildAuthKeyE2Payload(uidBytes, area, keyAB));
if (payload[0] !== CMD.AUTH_KEY_E2) throw new Error(`应答命令不符(0x${payload[0].toString(16)})`);
const status = payload[1];
if (status !== 0x00) {
throw new AuthFailError('饭卡密码不正确,卡片拒绝了读写。请核对商户"饭卡密码"配置是否与这张卡一致,或更换正确的卡');
}
};
/** 装载密码 + 寻卡 + 认证一条龙 */
const authFlow = async (area: number, keyAB: 0 | 1, keyHex: string): Promise<number[]> => {
await ensureReaderKey(area, keyAB, keyHex);
const uidBytes = await requestCard();
await authKeyE2(uidBytes, area, keyAB);
return uidBytes;
};
/** 用 0x3C 把卡密码从旧密码改为新密码(需已用旧密码认证),设备不支持时抛错由调用方降级 */
const changeCardKeyCmd = async (uidBytes: number[], area: number, keyAB: 0 | 1, oldKeyHex: string, newKeyHex: string): Promise<void> => {
const payload = await hidTransceive(buildChangeKeyPayload(uidBytes, area, keyAB, parseKeyHex(oldKeyHex), parseKeyHex(newKeyHex)));
if (payload[0] !== CMD.CHANGE_KEY) throw new Error(`应答命令不符(0x${payload[0].toString(16)})`);
const status = payload[1];
if (status !== 0x00) throw new Error(statusText(status));
loadedKeyTag = '';
};
/**
* 认证入口:先商户密码,失败降级出厂密码;空白卡自动改密为商户密码(尽力而为)。
* 返回完成认证的卡号。两次都失败抛 AuthFailError(白话提示在 error.message)。
*/
const authWithFallback = async (area: number, keyAB: 0 | 1, mchKeyHex: string): Promise<number[]> => {
// 1) 商户密码直接认证
try {
return await authFlow(area, keyAB, mchKeyHex);
} catch (e) {
if (!(e instanceof AuthFailError)) throw e;
}
// 2) 出厂密码(空白新卡)
if (mchKeyHex.toUpperCase() === FACTORY_KEY) {
throw new AuthFailError('饭卡密码不正确,卡片拒绝了读写。请核对商户"饭卡密码"配置是否与这张卡一致,或更换正确的卡');
}
let uidBytes: number[];
try {
uidBytes = await authFlow(area, keyAB, FACTORY_KEY);
} catch (e) {
if (e instanceof AuthFailError) {
throw new AuthFailError('饭卡密码不正确,卡片拒绝了读写。请核对商户"饭卡密码"配置是否与这张卡一致,或更换正确的卡');
}
throw e;
}
// 3) 空白卡:自动把密码改为商户密码;设备不支持改密命令时跳过,本次仍用出厂密码完成
try {
await changeCardKeyCmd(uidBytes, area, keyAB, FACTORY_KEY, mchKeyHex);
return await authFlow(area, keyAB, mchKeyHex);
} catch {
loadedKeyTag = '';
return uidBytes;
}
};
/** 系统约定:饭卡数据统一存扇区 1 的第 1 块(扇区内块号 1,绝对块 5),界面不暴露块概念 */
const DATA_BLOCK_OFFSET = 1;
/**
* 读卡内容(扇区固定数据块 16 字节),F7 读出。
* 应答 payload = [F7][状态][字节数][数据][crc(状态至数据末)]。
* 失败原因写入 state.error 供弹窗展示。
*/
const readSector = async (area: number, keyAB: 0 | 1, mchKeyHex: string): Promise<SectorReadResult> => {
try {
const uidBytes = await authWithFallback(area, keyAB, mchKeyHex);
const payload = await hidTransceive(buildReadBlocksPayload(area * 4 + DATA_BLOCK_OFFSET, 1));
if (payload[0] !== CMD.BLOCKS_READ) throw new Error(`应答命令不符(0x${payload[0].toString(16)})`);
const status = payload[1];
if (status !== 0x00) throw new Error(statusText(status));
if (payload.length < 3) throw new Error('应答数据不完整');
if (crc8Ours(payload.slice(1, -1)) !== payload[payload.length - 1]) throw new Error('应答校验失败,请重试');
const count = payload[2];
if (payload.length < 3 + count + 1) throw new Error('应答数据不完整');
return { uidBytes, data: payload.slice(3, 3 + count).slice(0, 16) };
} catch (e) {
state.error = e instanceof Error ? e.message : String(e);
throw e;
}
};
/**
* 写卡内容(16 字节写到扇区固定数据块),F8 写入。
* 应答 payload = [F8][状态]。
* 失败原因写入 state.error 供弹窗展示。
*/
const writeSector = async (area: number, keyAB: 0 | 1, mchKeyHex: string, blockData: number[]): Promise<{ uidBytes: number[] }> => {
if (blockData.length !== 16) throw new Error('单块数据须为 16 字节');
try {
const uidBytes = await authWithFallback(area, keyAB, mchKeyHex);
const payload = await hidTransceive(buildWriteBlocksPayload(area * 4 + DATA_BLOCK_OFFSET, 1, blockData));
if (payload[0] !== CMD.BLOCKS_WRITE) throw new Error(`应答命令不符(0x${payload[0].toString(16)})`);
const status = payload[1];
if (status !== 0x00) throw new Error(statusText(status));
return { uidBytes };
} catch (e) {
state.error = e instanceof Error ? e.message : String(e);
throw e;
}
};
const clearFrames = () => {
state.frames = [];
};
export function useCardReader() {
return {
state,
connect,
disconnect,
pickDevice,
readCard,
readSector,
writeSector,
beep,
clearFrames,
};
}
+306
View File
@@ -0,0 +1,306 @@
/**
* OUR(荣士)USB 免驱读写卡器协议层("21xh" 帧)
*
* 依据厂家 libOURMIFARE.so 逆向 + 真机应答帧验证:
* 传输:64 字节 HID 报文,帧 = [0x78, 0x68, len, payload..., 0x00 填充]
* 应答:报文同样以 78 68 开头,剥掉 3 字节头后的 payload = [命令回显, 状态, 数据..., crc]
* 读卡号:cmd 0xF0,应答 payload = [F0, 状态, 卡号0~3, crc8(状态+卡号)]
* 蜂鸣:cmd 0x0F + 时长低字节 + 时长高字节 + crc8(两字节时长),应答 [0F, 00]
* 错误码:08 寻不到卡 / 09 多卡冲突 / 0A 无法激活 / 0B 密码装载失败 / 0C 密码认证失败
*/
/** 命令字(帧 payload 首字节) */
export const CMD = {
/** 蜂鸣器鸣响 */
BEEP: 0x0f,
/** Mifare One 读卡号(寻卡+选卡,认证/读写前必须先执行) */
READ_ID: 0xf0,
/** 用读写器 E2 已装载的密码校验扇区(寻卡后紧接着执行) */
AUTH_KEY_E2: 0xc3,
/** 按绝对块号读多个数据块(认证后执行) */
BLOCKS_READ: 0xf7,
/** 按绝对块号写多个数据块(认证后执行) */
BLOCKS_WRITE: 0xf8,
/** 读单个数据块(16 字节,认证后执行) */
READ_BLOCK: 0xb4,
/** 写单个数据块(16 字节,认证后执行) */
WRITE_BLOCK: 0xa5,
/** 把扇区密码装载进读写器 E2 存储 */
KEY_LOAD_E2: 0x87,
/** 修改卡片扇区密码(gen1 加密族,部分固件不支持) */
CHANGE_KEY: 0x3c,
} as const;
/** 应答状态码 -> 白话提示 */
export const STATUS_TEXT: Record<number, string> = {
0x01: '密码认证成功,但读取扇区内容失败',
0x08: '感应区没有检测到卡片,请将卡片放好后再试',
0x09: '感应区内有两张以上卡片,请一次只放一张',
0x0a: '无法选择激活卡片,请重新放卡',
0x0b: '密码装载失败',
0x0c: '卡片密码认证失败',
0x0d: '读卡数据失败,请确认卡片未移开且密码正确',
0x14: '写卡数据失败,请确认卡片未移开且密码正确',
0x1b: '读写器拒绝了该指令',
};
/** 状态码转提示,未收录给通用兜底 */
export const statusText = (status: number): string => {
return STATUS_TEXT[status] || `读写器返回异常状态(0x${toHexByte(status)})`;
};
/** OUR 读卡器 USB 厂商识别号(用于 WebHID 设备过滤) */
export const OUR_USB_VID = 0x0801;
/** OUR 报文同步头 */
const OUR_FRAME_HEAD = [0x78, 0x68];
/**
* OUR 私有校验的半字节查找表(来自厂家 libOURMIFARE.so 的 cal_crcm8v2 逆向,
* 经真实读卡应答帧 + 20 组 oracle 随机向量双重验证,勿改动)
*/
const OUR_T1 = [0xc3, 0xe2, 0xc7, 0xad, 0x1a, 0xff, 0xa0, 0x85, 0xf8, 0x8d, 0xe9, 0xed, 0xe5, 0x3d, 0x7f, 0xa0];
const OUR_T2 = [0x00, 0x31, 0x62, 0x53, 0xc4, 0xf5, 0xa6, 0x97, 0xb9, 0x88, 0xdb, 0xea, 0x7d, 0x4c, 0x1f, 0x2e];
/** OUR 应答校验(厂家私有状态机算法,覆盖范围见各命令的应答格式) */
export const crc8Ours = (bytes: number[]): number => {
if (bytes.length === 0) return 0xaa;
let crc = 0;
let cnt = (bytes.length * 2) & 0xff;
let ptr = 0;
let b = bytes[ptr];
let v = OUR_T1[(b >> 2) & 0xf] ^ b;
let n2 = v & 0x7;
if (n2 === 0) n2 = 1;
let n3 = v >> 4;
while (true) {
const n1 = crc >> 4;
crc = ((crc << 4) & 0xff) ^ OUR_T2[(n1 ^ n3) & 0xf];
cnt = (cnt - 1) & 0xff;
if (cnt === 0) return (crc ^ 0xaa) & 0xff;
if (cnt & 1) {
n3 = v & 0xf;
continue;
}
n2 = (n2 - 1) & 0xff;
if (n2 !== 0) {
ptr += 1;
b = bytes[ptr];
v = OUR_T1[(b >> 2) & 0xf] ^ b;
n3 = v >> 4;
continue;
}
v = OUR_T1[(cnt >> 1) & 0xf];
n3 = v >> 4;
n2 = v & 0x7;
if (n2 <= 1) n2 = 2;
cnt = (cnt + 2) & 0xff;
}
};
/** 按 OUR 协议组 64 字节报文:[78 68][len][payload][补零](payload ≤ 61 字节) */
export const buildOurFrame = (payload: number[]): number[] => {
if (payload.length === 0 || payload.length > 61) throw new Error(`指令长度非法: ${payload.length}`);
const frame: number[] = new Array(64).fill(0);
frame[0] = OUR_FRAME_HEAD[0];
frame[1] = OUR_FRAME_HEAD[1];
frame[2] = payload.length;
for (let i = 0; i < payload.length; i++) frame[3 + i] = payload[i];
return frame;
};
/**
* 按 OUR 协议把超长 payload 组成多个 64 字节报文(写扇区等 61 字节以上指令):
* 首包 [78 68][总长][payload 前 61 字节](恰好占满 64 字节),后续包 [78 68][剩余][补零]。
*/
export const buildOurFrames = (payload: number[]): number[][] => {
if (payload.length <= 61) return [buildOurFrame(payload)];
const frames: number[][] = [];
const first: number[] = new Array(64).fill(0);
first[0] = OUR_FRAME_HEAD[0];
first[1] = OUR_FRAME_HEAD[1];
first[2] = payload.length;
for (let i = 0; i < 61; i++) first[3 + i] = payload[i];
frames.push(first);
for (let offset = 61; offset < payload.length; offset += 61) {
const chunk = payload.slice(offset, offset + 61);
const next: number[] = new Array(64).fill(0);
next[0] = OUR_FRAME_HEAD[0];
next[1] = OUR_FRAME_HEAD[1];
for (let i = 0; i < chunk.length; i++) next[2 + i] = chunk[i];
frames.push(next);
}
return frames;
};
/**
* 厂家魔改 IDEA 加密的子密钥表(libOURMIFARE.so 的 EKV2,52 个 16 位子密钥)。
* 与标准 IDEA 的唯一差异在轮末赋值:x2=C^I、x4=D^J(标准为 C^J、D^I),
* 已用容器 oracle 的 24 组随机向量 + 真机帧双重验证。
*/
const IDEA_EK = [
0x39cd, 0x6963, 0x5a84, 0x8e71, 0xbc10, 0xbab1, 0x674f, 0x9b2f, 0xc6b5, 0x091c, 0xe378, 0x2175, 0x62ce, 0x9f36, 0x5e73, 0x9ad2,
0x39c6, 0xf042, 0xeac5, 0x9d3e, 0x6cbc, 0xe735, 0xa58d, 0x6a12, 0x85d5, 0x8b3a, 0x7cd9, 0x79ce, 0x6b4b, 0x1ad4, 0x2473, 0x8de0,
0x74f9, 0xb2f3, 0x9cd6, 0x9635, 0xa848, 0xe71b, 0xc10b, 0xab16, 0xe739, 0xad2c, 0x6b50, 0x91ce, 0x3782, 0x1756, 0x2ce9, 0xf365,
0x58d6, 0xa123, 0x9c6f, 0x042e,
];
/** IDEA 乘法:模 65537,0 视为 65536,结果 65536 归 0 */
const ideaMul = (a: number, b: number): number => {
const x = a === 0 ? 0x10000 : a;
const y = b === 0 ? 0x10000 : b;
return ((x * y) % 0x10001) & 0xffff;
};
/**
* 厂家加密变换(ideaturnv2(src, dst)):把 src 前 8 字节加密后输出。
* 扇区读/写等带密码指令发送前,先构造明文 body,再把变换结果覆盖到 body[6..13]
* (即明文帧 = [命令][控制字][卡号4B] + [加密8字节] + [其余数据]),最后再做 CRC。
*/
const ideaTurn = (src: number[]): number[] => {
let x1 = (src[0] << 8) | src[1];
let x2 = (src[2] << 8) | src[3];
let x3 = (src[4] << 8) | src[5];
let x4 = (src[6] << 8) | src[7];
for (let r = 0; r < 8; r++) {
const k = 6 * r;
const a = ideaMul(x1, IDEA_EK[k]);
const b = (x2 + IDEA_EK[k + 1]) & 0xffff;
const c = (x3 + IDEA_EK[k + 2]) & 0xffff;
const d = ideaMul(x4, IDEA_EK[k + 3]);
const g = ideaMul(a ^ c, IDEA_EK[k + 4]);
const h = (g + (b ^ d)) & 0xffff;
const i = ideaMul(h, IDEA_EK[k + 5]);
const j = (g + i) & 0xffff;
x1 = a ^ i;
x2 = c ^ i;
x3 = b ^ j;
x4 = d ^ j;
}
const out = [ideaMul(x1, IDEA_EK[48]), (x3 + IDEA_EK[49]) & 0xffff, (x2 + IDEA_EK[50]) & 0xffff, ideaMul(x4, IDEA_EK[51])];
const bytes: number[] = [];
for (const w of out) bytes.push((w >> 8) & 0xff, w & 0xff);
return bytes;
};
/**
* 用读写器 E2 已装载的密码校验扇区指令 payload(8 字节,寻卡后紧接着发送):
* [C3][卡号4B][区号][密码类型][crc(卡号+区号+类型共6字节)]
* 密码本体已通过 buildLoadKeyPayload 预先装载进读写器。
*/
export const buildAuthKeyE2Payload = (uidBytes: number[], area: number, keyAB: 0 | 1): number[] => {
const body = [CMD.AUTH_KEY_E2, ...uidBytes.slice(0, 4), area, keyAB];
return [...body, crc8Ours(body.slice(1, 7))];
};
/**
* 读单个数据块指令 payload(3 字节):[B4][绝对块号][crc(命令字节)]
*/
export const buildReadBlockPayload = (blockNo: number): number[] => {
return [CMD.READ_BLOCK, blockNo, crc8Ours([CMD.READ_BLOCK])];
};
/**
* 按绝对块号读多个数据块指令 payload(4 字节):
* [F7][起始块号][块数量][crc(块号+块数量)]
* 应答 payload = [F7][状态][字节数][数据][crc(状态至数据末)]。
*/
export const buildReadBlocksPayload = (blockNo: number, blockNum: number): number[] => {
const body = [CMD.BLOCKS_READ, blockNo, blockNum];
return [...body, crc8Ours(body.slice(1, 3))];
};
/**
* 修改卡片扇区密码指令 payload(21 字节,需先用旧密码认证):
* 明文 [3C][控制字][卡号4B][区号][A/B][旧密码6B][新密码6B] →
* 双段加密:前 8 字节密文覆盖到 [4..11],[12..19] 原地加密 →
* [3C][控制字][卡号2B][密文8B][密文8B][crc(前20字节)]
* 应答 payload = [3C][状态]。
*/
export const buildChangeKeyPayload = (uidBytes: number[], area: number, keyAB: 0 | 1, oldKey: number[], newKey: number[]): number[] => {
const plain = [CMD.CHANGE_KEY, 0, ...uidBytes.slice(0, 4), area, keyAB, ...oldKey.slice(0, 6), ...newKey.slice(0, 6)];
const buf = [...plain];
const t1 = ideaTurn(plain.slice(0, 8));
for (let i = 0; i < 8; i++) buf[4 + i] = t1[i];
const t2 = ideaTurn(plain.slice(12, 20));
for (let i = 0; i < 8; i++) buf[12 + i] = t2[i];
return [...buf, crc8Ours(buf.slice(0, 20))];
};
/**
* 按绝对块号写多个数据块指令 payload(4 + 16×块数 字节):
* [F8][起始块号][块数量][数据 16×块数][crc(块号+块数量+数据)]
* 应答 payload = [F8][状态]。
*/
export const buildWriteBlocksPayload = (blockNo: number, blockNum: number, data: number[]): number[] => {
if (data.length !== blockNum * 16) throw new Error(`写块数据须为 ${blockNum * 16} 字节,当前 ${data.length}`);
const body = [CMD.BLOCKS_WRITE, blockNo, blockNum, ...data];
return [...body, crc8Ours(body.slice(1, body.length))];
};
/**
* 写单个数据块指令 payload(19 字节):[A5][绝对块号][数据16B][crc(块号+数据共17字节)]
*/
export const buildWriteBlockPayload = (blockNo: number, blockData: number[]): number[] => {
if (blockData.length !== 16) throw new Error(`单块数据须为 16 字节,当前 ${blockData.length}`);
const body = [CMD.WRITE_BLOCK, blockNo, ...blockData];
return [...body, crc8Ours(body.slice(1, 18))];
};
/**
* 把扇区密码装载进读写器 E2 存储指令 payload(10 字节):
* [87][IDEA(区号+密码类型+密码6B)][crc(密文8字节)]
* F7/F8 读写块时读写器用已装载的密码自动认证。
* @param keyAB 1 = A 密码,0 = B 密码
*/
export const buildLoadKeyPayload = (area: number, keyAB: 0 | 1, key: number[]): number[] => {
const cipher = ideaTurn([area, keyAB, ...key.slice(0, 6)]);
return [CMD.KEY_LOAD_E2, ...cipher, crc8Ours(cipher)];
};
/** 6 字节密码的十六进制串转字节数组(12 个 hex 字符) */
export const parseKeyHex = (hex: string): number[] => {
const clean = hex.replace(/\s/g, '');
if (!/^[0-9A-Fa-f]{12}$/.test(clean)) throw new Error('密码须为 12 位十六进制字符(6 字节)');
return clean.match(/.{2}/g)!.map((b) => parseInt(b, 16));
};
/** 任意长度数据十六进制串转字节数组(偶数个 hex 字符) */
export const parseDataHex = (hex: string): number[] => {
const clean = hex.replace(/\s/g, '');
if (!/^[0-9A-Fa-f]*$/.test(clean) || clean.length % 2 !== 0) throw new Error('数据须为偶数个十六进制字符');
return clean.match(/.{2}/g)?.map((b) => parseInt(b, 16)) ?? [];
};
/**
* 从原始收包字节中提取 OUR 应答 payload:按 78 68 同步头定位、按长度截取。
* 兼容报文前多出/缺少 reportId 字节的位移情况。
*/
export const parseOurPacket = (bytes: number[]): number[] | null => {
for (let i = bytes.indexOf(OUR_FRAME_HEAD[0]); i >= 0 && i + 3 <= bytes.length; i = bytes.indexOf(OUR_FRAME_HEAD[0], i + 1)) {
if (bytes[i + 1] !== OUR_FRAME_HEAD[1]) continue;
const len = bytes[i + 2];
if (len === 0 || i + 3 + len > bytes.length) continue;
return bytes.slice(i + 3, i + 3 + len);
}
return null;
};
/** 字节数组转大写十六进制串,如 [0x04,0xa1] -> "04A1" */
export const toHex = (bytes: number[]): string => {
return bytes.map((b) => toHexByte(b)).join('');
};
export const toHexByte = (b: number): string => {
return (b & 0xff).toString(16).toUpperCase().padStart(2, '0');
};
/** 卡号十六进制串转无符号十进制(按大端) */
export const hexToDecimal = (hex: string): string => {
if (!/^[0-9A-Fa-f]+$/.test(hex) || hex.length === 0) return '';
try {
return BigInt(`0x${hex}`).toString(10);
} catch {
return '';
}
};