(博客从CDSN搬迁啦,欢迎收藏新博客站,本站将持续更)
下面文章所讲的例子皆是根据银联NFC银行卡读取进行模仿交互的,后面会出专栏讲解NFC读取银行卡流程与实现
目的:需要实现带模拟一张智能卡(门禁卡或者其他业务卡),使用带NFC设备根据指定协议进行读取模拟卡数据(效果图如下):



- 模拟卡设计
这里使用设备自带NFC模拟卡(HCE)模式,模拟出一张虚拟卡,类似华为钱包,applepay钱包等。选择添加的卡,提供读取。
1.1 数据包交互协议
类TLV数据包格式,及Tag Length Value(和银联IC卡返回数据协议类似)。
1.2 AID规定
固定AID=F223344556 (注意不要超过10为长度)
1.3 Apdu协议 (交互Apdu格式:[apdu header]+[dataLength]+[data]+[status])
1.3.1 select apdu header #查询指令头 -> 00A40400 1.3.2 update apdu header #更新指令头 -->00B40400
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<AccountCallback> mAccountCallback; public interface AccountCallback { public void onAccountReceived(); } public LoyaltyCardReader(AccountCallback accountCallback) { mAccountCallback = new WeakReference<AccountCallback>(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<String,String> tvlsData=TLVInterpreter.parser(payload); StringBuilder sbu=new StringBuilder(); for(Map.Entry<String,String> 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
这里采用TLV格式解析,因为博主长期就职于金融公司,接触研究卡片数据交互,所以以TVL为例子,这里并非一定要采用TVL,可以自定义数据格式,但是需要小程序返回指令符合对应数据格式
public class TLVInterpreter { private static final String TAG = "LoyaltyCardReader"; public static HashMap<String,String> parser(byte[] datas){ HashMap<String,String> 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<dlen;i++){ data[i]=payload[startIndex]; startIndex++; } String dt=hexStringToString(LoyaltyCardReader.ByteArrayToHexString(data)); Log.i(TAG,"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; } }
以上是通过小程序模拟简单的卡片,并且通过AndroidNFC实现数据读取过程,本例子中对Android的NFC读取未做过多介绍,具体可以参考官方文档,或者可以看看本站银联支付专栏或Android专栏的相关文章。
相关疑问回答
1.文章中使用的例子有项目DEMO吗?
答:Demo有的,如果需要可以关注个人订阅号留言“小程序HCE”即可,看到会及时回复
2.文章中hcecard.js 中sendDataPackage 发送的16进制数据包是响应读卡器对应指令吗?
答:是的。实际业务上当我们根据业务实现了一张HCE卡片,有读卡器靠近时卡片应该要根据对应指令及时返回信息,以便告知读卡器进行下一步操作
3.请问下现在我又个功能是读取ic卡的卡号,微信小程序提供的api能实现吗,HCE模式不能获得卡号吗?
答:截至2022年博主查看了微信官方文档,已经支持了小程序的读写模式,详细见微信小程序NFC近场通讯相关文档。
4.上述例子中Android端的代码没有添加虚拟卡的操作吗?
答:没有的,卡片添加不在本文章范围内。
5.现在小程序的NFC能用于支付开发吗?就是碰一碰就支付这样的?
答:截至2022年6月,博主查看了最新的微信小程序nfc能力文档,理论上是可以支持的。
如果有其他问题欢迎在博主微信订阅号下留言相互交流学习!
