需要实现带模拟一张智能卡(门禁卡或者其他业务卡),使用带NFC设备根据指定协议进行读取模拟卡数据(效果图如下):
1. 模拟卡设计
这里使用设备自带NFC模拟卡(HCE)模式,模拟出一张虚拟卡,类似华为钱包,applepay钱包等。选择添加的卡,提供读取。
1.1 数据包交互协议
类TLV数据包格式,及Tag Length Value(和银联IC卡返回数据协议类似)。
1.2 AID规定
固定AID=F223344556 (注意不要超过10为长度)
1.3 Apdu协议
1.3.1 select apdu header
00A40400
1.3.2 update apdu header
00B40400
交互Apdu格式:[apdu header]+[dataLength]+[data]+[status]
1.4 界面设计
HCE界面分为2个,以上图为例,第二幅图为提供选择需要被读卡,第三幅图为卡界面,开启HCE开始交互。
1.5 小程序端HCE代码片段
HCE封装核心模块 nfc_hce_core.js:
var comm = require('comm_util.js')
function Action(){
Action_GETHCESTATUS=0;Action_STARTHCE=1;Action_SENDMESSAGE=2
Action_RECEIVEMESSAGE=3;Action_STOPHCE=4
}
var Status=[
{code:'0',msg:'OK'},
{ code: '13000', msg: '当前设备不支持 NFC' },
{ code: '13001', msg: '当前设备支持 NFC,但系统NFC开关未开启' },
{ code: '13002', msg: '当前设备支持 NFC,但不支持HCE' },
{ code: '13003', msg: 'AID 列表参数格式错误' },
{ code: '13004', msg: '未设置微信为默认NFC支付应用' },
{ code: '13005', msg: '返回的指令不合法' },
{ code: '13006', msg: '注册 AID 失败' }
]
class NfcHCECore{
constructor(mContext,_aids,mMsgCallBack,onNfcMessageLinsener){
this.context=mContext
this.aids = _aids
this.mCallBack = mMsgCallBack
this.nfcMessageCallBack = onNfcMessageLinsener
}
//获取当前状态
getNfcStatus(){
var that=this
wx.getHCEState({
success:function(res){
console.log('NfcHCECore-->getNfcStatus::success:',res)
that._runCallBack(res.errCode, res.errMsg)
},
fail:function(err){
console.error('NfcHCECore-->getNfcStatus::fail:', err)
that.callError(err)
}
})
}
//开启HCE环境
startNfcHCE(){
var that = this
wx.startHCE({
aid_list: this.aids,
success:function(res){
console.log('NfcHCECore-->startNfcHCE::success:', res)
that._runCallBack(res.errCode, res.errMsg)
},
fail:function(err){
console.error('NfcHCECore-->startNfcHCE::fail:', err)
that.callError(err)
}
})
}
//发消息
sendNfcHCEMessage(hexApdu){
console.log('开始发送发回')
var that = this
var byteArrays = comm.hex2Bytes(hexApdu)
console.log(byteArrays.length)
var retbuffer = new ArrayBuffer(byteArrays.length)
var dataView = new DataView(retbuffer)
for (var i = 0; i < dataView.byteLength; i++) {
dataView.setInt8(i, byteArrays[i])
}
wx.sendHCEMessage({
data: retbuffer,
success:function(res){
console.log('NfcHCECore-->sendNfcHCEMessage::success:', res)
that._runCallBack(res.errCode, res.errMsg)
},
fail:function(err){
console.error('NfcHCECore-->sendNfcHCEMessage::fail:', err)
that.callError(err)
}
})
}
/**
* 收到读卡器发来的消息
*/
onNfcHCEMessageHadnler(){
var that = this
wx.onHCEMessage(function(res){
console.log('NfcHCECore-->onHCEMessage:', res)
that.nfcMessageCallBack(res.messageType, res.reason, comm.ab2hex(res.data))
})
}
/**
* 停止HCE环境
*/
stopNfcHCE(){
var that = this
wx.stopHCE({
success:function(res){
console.log('NfcHCECore-->stopNfcHCE::success:', res)
that._runCallBack(res.errCode,res.errMsg)
},
fail:function(err){
console.error('NfcHCECore-->stopNfcHCE::fail:', err)
that.callError(err)
}
})
}
simple(){
var that = this
wx.getHCEState({
success:function(res){
console.log('NfcHCECore-->simple::getHCEState:', res)
console.log(that.aids)
that._runCallBack(res.errCode, res.errMsg)
wx.startHCE({
aid_list: that.aids,
success:function(res){
console.log('NfcHCECore-->simple::startHCE:', res)
that._runCallBack(res.errCode, res.errMsg)
wx.onHCEMessage(function(res){
console.log('NfcHCECore-->simple::onHCEMessage:', res)
that.nfcMessageCallBack(res.messageType, res.reason, comm.ab2hex(res.data))
})
},
fail:function(err){
that.callError(err)
}
})
},
fail:function(err){
that.callError(err)
}
})
}
callError(err){
var that=this
Status.forEach(function (value, index, list) {
if (value.code === err.errCode) {
that._runCallBack(value, value.msg)
}
})
}
_runCallBack(status,data){
this.mCallBack(status,data)
}
}
module.exports = NfcHCECore
工具模块 用来做数据处理等 comm_util.js:
/**
* 生成指定长度随机数
*/
function genRandom(n) {
let a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; //生成的随机数的集合
let res = [];
for (let i = 0; i < n; i++) {
let index = parseInt(Math.random() * (a.length)); //生成一个的随机索引,索引值的范围随数组a的长度而变化
res.push(a[index]);
a.splice(index, 1) //已选用的数,从数组a中移除, 实现去重复
}
return res.join('');
}
function isFunctinMethod(name) {
if (name != undefined && typeof name === 'function') {
return true
}
return false
}
const formatNumber = n => {
n = n.toString()
return n[1] ? n : '0' + n
}
// ArrayBuffer转16进度字符串
function ab2hex(buffer) {
var hexArr = Array.prototype.map.call(
new Uint8Array(buffer),
function (bit) {
return ('00' + bit.toString(16)).slice(-2)
}
)
return hexArr.join('');
}
//十六进制字符串转字节数组
function hex2Bytes(str) {
var pos = 0;
var len = str.length;
if (len % 2 != 0) {
return null;
}
len /= 2;
var hexA = new Array();
for (var i = 0; i < len; i++) {
var s = str.substr(pos, 2);
var v = parseInt(s, 16);
hexA.push(v);
pos += 2;
}
return hexA;
}
function hex2ArrayBuffer(hex){
var pos = 0;
var len = hex.length;
if (len % 2 != 0) {
return null;
}
len /= 2;
var buffer = new ArrayBuffer(len)
var dataview=new DataView(buffer)
for (var i = 0; i < len; i++) {
var s = hex.substr(pos, 2);
var v = parseInt(s, 16);
dataview.setInt16(i,v)
pos += 2;
}
return buffer
}
//string转16进制
function stringToHex(str) {
var val = "";
for (var i = 0; i < str.length; i++) {
if (val == "")
val = str.charCodeAt(i).toString(16);
else
val += str.charCodeAt(i).toString(16);
}
return val;
}
//16进制转string
function hexCharCodeToStr(hexCharCodeStr) {
var trimedStr = hexCharCodeStr.trim();
var rawStr =
trimedStr.substr(0, 2).toLowerCase() === "0x"
?
trimedStr.substr(2)
:
trimedStr;
var len = rawStr.length;
if (len % 2 !== 0) {
alert("Illegal Format ASCII Code!");
return "";
}
var curCharCode;
var resultStr = [];
for (var i = 0; i < len; i = i + 2) {
curCharCode = parseInt(rawStr.substr(i, 2), 16); // ASCII Code Value
resultStr.push(String.fromCharCode(curCharCode));
}
return resultStr.join("");
}
function pad(num, n) {
var len = num.toString().length;
while (len < n) {
num = "0" + num;
len++;
}
return num;
}
function strToHexCharCode(str) {
if (str === "")
return "";
var hexCharCode = [];
hexCharCode.push("0x");
for (var i = 0; i < str.length; i++) {
hexCharCode.push((str.charCodeAt(i)).toString(16));
}
return hexCharCode.join("");
}
//string转byte数组
function stringToByteArray(str) {
var bytes = new Array();
var len, c;
len = str.length;
for (var i = 0; i < len; i++) {
c = str.charCodeAt(i);
if (c >= 0x010000 && c <= 0x10FFFF) {
bytes.push(((c >> 18) & 0x07) | 0xF0);
bytes.push(((c >> 12) & 0x3F) | 0x80);
bytes.push(((c >> 6) & 0x3F) | 0x80);
bytes.push((c & 0x3F) | 0x80);
} else if (c >= 0x000800 && c <= 0x00FFFF) {
bytes.push(((c >> 12) & 0x0F) | 0xE0);
bytes.push(((c >> 6) & 0x3F) | 0x80);
bytes.push((c & 0x3F) | 0x80);
} else if (c >= 0x000080 && c <= 0x0007FF) {
bytes.push(((c >> 6) & 0x1F) | 0xC0);
bytes.push((c & 0x3F) | 0x80);
} else {
bytes.push(c & 0xFF);
}
}
return bytes;
}
// byte数组转string
function byteToString(bytearr) {
if (typeof arr === 'string') {
return arr;
}
var str = '',
_arr = arr;
for (var i = 0; i < _arr.length; i++) {
var one = _arr[i].toString(2),
v = one.match(/^1+?(?=0)/);
if (v && one.length == 8) {
var bytesLength = v[0].length;
var store = _arr[i].toString(2).slice(7 - bytesLength);
for (var st = 1; st < bytesLength; st++) {
store += _arr[st + i].toString(2).slice(2);
}
str += String.fromCharCode(parseInt(store, 2));
i += bytesLength - 1;
} else {
str += String.fromCharCode(_arr[i]);
}
}
return str;
}
//二进制转10
function bariny2Ten(byte){
return parseInt(byte, 2)
}
function bariny2Hex(a){
return parseInt(a, 16)
}
//10/16进制转2进制
function ten2Bariny(ten){
return ten.toString(2)
}
function str2Hex(str){
return parseInt(str, 10).toString(16)
}
//16进制转2进制
function hex2bariny(hex){
return parseInt(hex, 16).toString(2)
}
module.exports = {
formatTime: formatTime,isFunctinMethod: isFunctinMethod,ab2hex: ab2hex,
hex2Bytes: hex2Bytes,stringToByteArray: stringToByteArray,byteToString: byteToString,hex2ArrayBuffer:hex2ArrayBuffer,bariny2Ten:bariny2Ten,bariny2Hex: bariny2Hex,ten2Bariny: ten2Bariny,str2Hex: str2Hex,hex2bariny: hex2bariny,genRandom: genRandom,stringToHex: stringToHex,hexToString: hexCharCodeToStr,pad: pad
}
卡交互页面逻辑 hcecard.js:
该js模块对应卡页面交互功能,读卡器使用nfc读卡模式会进入onHCEMessage()回调中,
返回二进制数据,此时使用console是无法打印出data,需要转成16进制才行。
流程:
获取NFC状态–>开启HCE模式–>接受读卡器消息–>发送消息给读卡器
具体API详见:
https://developers.weixin.qq.com/miniprogram/dev/api/nfc.html#wx.sendhcemessageobject
var comm = require('../../utils/comm_util.js')
var NfcHCECore = require('../../utils/nfc_hce_core.js')
var app=getApp()
var msg=''
var countdown = 120;
var timer=null
//倒计时 120s退出 关闭hce
var settime = function (that) {
if (countdown == 0) {
wx.navigateBack({})
return;
} else {
that.setData({
last_time: countdown
})
countdown--;
}
timer=setTimeout(function () {
settime(that)
}, 1000)
}
Page({
//页面的初始数据
data: {
currentCard:null,
content:'',
last_time: '',
},
onLoad: function (options) {
var cardKey = options.cardkey
var cardbean=wx.getStorageSync(cardKey)
console.log('cardbean=' ,cardbean)
this.setData({
currentCard: cardbean
})
wx.setNavigationBarTitle({
title: "门禁卡:"+cardbean.cardName,
})
this.nfcHCECore = new NfcHCECore(this, [cardbean.AID], this.onOptMessageCallBack.bind(this), this.onHCEMessageCallBack.bind(this))
console.log("-->initNFCHCE")
this.nfcHCECore.simple()
},
//hce操作相关回调
onOptMessageCallBack(code, _msg) {
console.log('onOptMessageCallBack')
if (code === 0) {
console.log("执行成功!", _msg)
} else {
msg = msg + '执行失败code=' + code + ",msg=" + _msg + '\n'
}
this.setData({
content: msg
})
this.resetTime()
},
resetTime(){
clearTimeout(timer)
countdown=120
this.setData({
last_time:'120'
})
settime(this)
},
//收到读卡器发送指令
onHCEMessageCallBack(messageType, reason, hexData) {
var that = this
console.log('onHCEMessageCallBack')
console.log("有读卡器读我,messageType=", messageType)
if (messageType == 1) {
msg = msg + "有读卡器读我,数据包:" + hexData + '\n'
that.setData({
content: msg
})
this.sendDataPackage()
}
this.resetTime()
},
//发送数据及包
sendDataPackage() {
var cardbean = this.data.currentCard
console.log(comm.pad(2, 2))
//组装TLV数据包
var header = '00A40400'
var hexCardName = comm.stringToHex('yanglika')
hexCardName = plusZero(hexCardName)
console.log('cardName=>', cardbean.cardName,';hexCardName=>' + hexCardName)
var nameTag = '1F01'
var len = comm.stringToHex(comm.pad((hexCardName.length / 2), 2))
var cmdname = nameTag + len + hexCardName
console.log('cmdname.TVL=>' + cmdname)
var hexCardNo = comm.stringToHex(cardbean.cardNo)
hexCardNo = plusZero(hexCardNo)
console.log('cardNo=>', cardbean.cardNo,';hexCardNo=>' + hexCardNo)
var noTag = '5F01'
len = comm.stringToHex(comm.pad((hexCardNo.length / 2), 2))
var cmdNo = noTag + len + hexCardNo
console.log('cmdNo.TVL=>' + cmdNo)
var hexCreateDate = comm.stringToHex(cardbean.createDate)
hexCreateDate = plusZero(hexCreateDate)
console.log('hexCreateDate=>' + hexCreateDate)
var createDateTag = '5F02'
len = comm.stringToHex(comm.pad((hexCreateDate.length / 2), 2))
var cmdDate = createDateTag + len + hexCreateDate
console.log('cmdDate.TVL=>' + cmdDate)
var hexCardExp = comm.stringToHex(cardbean.cardExp)
hexCardExp = plusZero(hexCardExp)
console.log('hexCardExp=>' + hexCardExp)
var hexCardExpTag = '9F01'
len = comm.stringToHex(comm.pad((hexCardExp.length / 2), 2))
var cmdExp = hexCardExpTag + len + hexCardExp
console.log('cmdExp.TVL=>' + cmdExp)
len = comm.stringToHex(((cmdname.length + cmdNo.length + cmdDate.length + cmdExp.length)/2).toString();
console.log('len='+len)
var status="9000"
var sendcmd = (header + len + cmdname + cmdNo + cmdDate + cmdExp + status).toUpperCase()
msg = msg + "卡片返回读卡器指令:" + sendcmd+ '\n'
this.setData({
content: msg
})
this.nfcHCECore.sendNfcHCEMessage(sendcmd)
},
onShow: function () {
this.resetTime()
},
//生命周期函数--监听页面卸载
onUnload: function () {
this.resetTime()
_stopHCE()
}
})
//仅在安卓系统下有效。
function _stopHCE() {
wx.stopHCE({
success: function (res) {
console.log(res)
},
fail: function (err) {
console.error(err)
}
})
}
//补零
function plusZero(_str) {
while (_str.length % 2 != 0){
_str += "0"
}
return _str
}
1.6 android 设备端NFC Reader
CardReader.java
@TargetApi(Build.VERSION_CODES.KITKAT)
public class CardReader implements NfcAdapter.ReaderCallback {
private static final String TAG = "CardReader";
// ISO-DEP command HEADER for selecting an AID.
private static final String SAMPLE_LOYALTY_CARD_AID = "F223344556";
//select 命令报文头
private static final String SELECT_APDU_HEADER = "00A40400";
// "OK" status word sent in response to SELECT AID command (0x9000)
private static final byte[] SELECT_OK_SW = {(byte) 0x90, (byte) 0x00};
private WeakReference mAccountCallback;
public interface AccountCallback {
public void onAccountReceived();
}
public LoyaltyCardReader(AccountCallback accountCallback) {
mAccountCallback = new WeakReference(accountCallback);
}
@Override
public void onTagDiscovered(Tag tag) {
Log.i(TAG, "New tag discovered");
MainActivity.Companion.getSb().append("New tag discovered"+"\n");
IsoDep isoDep = IsoDep.get(tag);
if (isoDep != null) {
try {
// Connect to the remote NFC device
isoDep.connect();
Log.i(TAG, "Requesting remote AID: " + SAMPLE_LOYALTY_CARD_AID);
MainActivity.Companion.getSb().append("Requesting remote AID: " + SAMPLE_LOYALTY_CARD_AID+"\n");
mAccountCallback.get().onAccountReceived();
byte[] command = BuildSelectApdu(SAMPLE_LOYALTY_CARD_AID);
// Send command to remote device
Log.i(TAG, "Sending: " + ByteArrayToHexString(command));
byte[] result = isoDep.transceive(command);
Log.i(TAG, "result: " + ByteArrayToHexString(result));
int resultLength = result.length;
//获取状态码 9000为成功
byte[] statusWord = {result[resultLength-2], result[resultLength-1]};
if (Arrays.equals(SELECT_OK_SW, statusWord)) {
byte[] payload = Arrays.copyOf(result, resultLength-2);
// The remote NFC device will immediately respond with its stored account number
String accountNumber = ByteArrayToHexString(payload);
Log.i(TAG, "Received: " + accountNumber);
// Inform CardReaderFragment of received account number
MainActivity.Companion.getSb().append("Received 卡片返回指令: " + accountNumber+"\n");
HashMap tvlsData=TLVInterpreter.parser(payload);
StringBuilder sbu=new StringBuilder();
for(Map.Entry obj:tvlsData.entrySet()){
sbu.append(obj.getKey()+":"+obj.getValue()+"\n");
}
MainActivity.Companion.getSb().append("解析TLV:\n" + sbu.toString()+"\n");
mAccountCallback.get().onAccountReceived();
}else{
MainActivity.Companion.getSb().append("Received: 卡片拒绝指令,msg:"+ByteArrayToHexString(result)+ "\n");
mAccountCallback.get().onAccountReceived();
}
} catch (IOException e) {
Log.e(TAG, "Error communicating with card: " + e.toString());
}
}
}
public static byte[] BuildSelectApdu(String aid) {
// Format: [CLASS | INSTRUCTION | PARAMETER 1 | PARAMETER 2 | LENGTH | DATA]
return HexStringToByteArray(SELECT_APDU_HEADER + String.format("%02X", aid.length() / 2) + aid);
}
public static String ByteArrayToHexString(byte[] bytes) {
final char[] hexArray = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'};
char[] hexChars = new char[bytes.length * 2];
int v;
for ( int j = 0; j < bytes.length; j++ ) {
v = bytes[j] & 0xFF;
hexChars[j * 2] = hexArray[v >>> 4];
hexChars[j * 2 + 1] = hexArray[v & 0x0F];
}
return new String(hexChars);
}
public static byte[] HexStringToByteArray(String s) {
int len = s.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
+ Character.digit(s.charAt(i+1), 16));
}
return data;
}
}
TLV解析器 TLVInterpreter.java
public class TLVInterpreter {
private static final String TAG = "LoyaltyCardReader";
public static HashMap parser(byte[] datas){
HashMap params=new HashMap<>();
if(datas!=null&&datas.length>0){
byte[] payload =datas;
int resultLength = payload.length;
Log.i(TAG,"payload.length:"+resultLength);
Log.i(TAG,"parser:"+LoyaltyCardReader.ByteArrayToHexString(payload));
byte[] apduHeader={payload[0],payload[1],payload[2],payload[3]};
Log.i(TAG,"apduHeader:"+LoyaltyCardReader.ByteArrayToHexString(apduHeader));
params.put("apduHeader",LoyaltyCardReader.ByteArrayToHexString(apduHeader));
byte[] dataLen={payload[4],payload[5]};
int len=Integer.parseInt(hexStringToString(LoyaltyCardReader.ByteArrayToHexString(dataLen)));
Log.i(TAG,"dataLen:"+len);
boolean flag=true;
int startIndex=6;
int lenEffect=1;
while (flag){
//取tag
byte[] bTag={payload[startIndex],payload[startIndex+lenEffect]};
String tag=LoyaltyCardReader.ByteArrayToHexString(bTag);
Log.i(TAG,"tag="+tag);
//取长度
startIndex=startIndex+lenEffect+1;
byte[] datalen={payload[startIndex],payload[startIndex+lenEffect]};
Log.i(TAG,LoyaltyCardReader.ByteArrayToHexString(datalen));
int dlen=Integer.parseInt(hexStringToString(LoyaltyCardReader.ByteArrayToHexString(datalen)));
Log.i(TAG,"dlen="+dlen);
//取数据
startIndex=startIndex+lenEffect+1;
byte[] data=new byte[dlen];
for(int i=0;i"data="+dt);
Log.i(TAG,"startIndex="+startIndex);
params.put(tag,dt);
if(resultLength==startIndex){
//读取到最后结束
flag=false;
}
}
}
return params;
}
/**
* 字符串转换为16进制字符串
*
* @param s
* @return
*/
public static String stringToHexString(String s) {
String str = "";
for (int i = 0; i < s.length(); i++) {
int ch = (int) s.charAt(i);
String s4 = Integer.toHexString(ch);
str = str + s4;
}
return str;
}
/**
* 16进制字符串转换为字符串
*
* @param s
* @return
*/
public static String hexStringToString(String s) {
if (s == null || s.equals("")) {
return null;
}
s = s.replace(" ", "");
byte[] baKeyword = new byte[s.length() / 2];
for (int i = 0; i < baKeyword.length; i++) {
try {
baKeyword[i] = (byte) (0xff & Integer.parseInt(
s.substring(i * 2, i * 2 + 2), 16));
} catch (Exception e) {
e.printStackTrace();
}
}
try {
s = new String(baKeyword, "gbk");
new String();
} catch (Exception e1) {
e1.printStackTrace();
}
return s;
}
}