微光互聯 TX800-U 掃碼器無法輸出中文到游標的問題
問題背景
某檢測場有一批掃碼器,購於微光互聯,型號 TX800-U,用於在不同辦理窗口間掃描紙質材料上的二維碼,簡化錄入過程。掃碼器通過 USB 接入 PC 系統 (windows),自動安裝驅動,接電即可使用,掃描的資訊會直接輸出在游標所在位置,掃碼器大概長這樣:
問題現象
在一次 IT 系統升級後,發現它們掃描不了車輛外觀檢驗單上的車牌二維碼了,掃車架號二維碼是沒問題的,兩者的區別就是是否帶漢字,車牌第一個字元為地區標識,例如「京」,而車架號全部由數字和大寫字母組成。
拿到設備後,第一時間驗證了上面的問題,掃碼後都有滴的一聲,但是車牌號沒有任何資訊上屏。為了驗證這個問題確實和漢字有關,找到一個製造二維碼的網站 (草料二維碼),造了幾個不同的二維碼供掃碼器掃描,發現帶漢字的果然掃不出來,而只要去掉漢字,就能正確上屏。
同樣的二維碼,通過微信掃一掃是可以得到漢字結果的,另外升級 IT 系統前掃碼器也是正常的,所以初步判斷這個問題和漢字編碼相關,可能是升級系統後修改了默認字元編碼集導致掃碼器出問題了。
問題的解決
聯繫了廠家的售後,拉了個微信群,開發人員說這是已知問題,要想解決需要二次開發。
二次開發不就是調用 sdk 介面嗎,這個我熟啊。從官網找到對應產品和型號:
下載了 windows 上的 C/C++ 二次開發 sdk:
另外發現一個配置工具,感覺蠻有用,一起搞下來:
話說這公司夠實誠,設計圖紙都開源了 😅
開發者模式
擼了一遍文檔,大概明白了,這個掃碼器默認工作在普通模式,這種模式下會將掃到字元直接輸出到系統游標位置;如果想要二次開發,需要先將掃碼器設置為開發者模式,在這種模式下,掃到的資訊不會輸出到游標,而是藉由 sdk 介面返回給調用者,在這裡就可以對數據進行任意加工了。來看看如何配置開發者模式,共有兩種方式
- 通過配置工具 VguangConfig
- 直接掃描文檔中的二維碼
其實第一步最終也是生成一個二維碼,殊途同歸,不過可以選擇的設置項更豐富一些,先來看看這種方式吧
VguangConfig
打開後的介面是這樣,當掃碼器處於普通模式時會自動識別並連接設備:
如果已經處於開發者模式,則無法自動連接,這裡直接點「下一頁」
工作方式選擇「開發」後點「下一頁」:
這裡有一些高級設置,在當前場景下主要關注掃碼設置這頁,裡面有諸如碼制、前後綴、添加回車換行符、掃碼間隔時間、掃碼後動作、背光燈開關等,一般選擇默認即可。配置好後點右側的「生成配置碼」,得到一張二維碼配置圖:
掃碼器掃這個碼後再重新加電就可以按新模式工作了。同理可以設置掃碼器按普通模式工作:
文檔中的配置二維碼
上面那個配置工具的優點是靈活,缺點是只支援 windows 平台,如果沒有 windows 機器,可以直接使用開發文檔中幾個預定義的配置二維碼:
這個文檔位於 C/CPP 開發包解壓後的如下路徑:「USB介面C-CPP語言SDK20220411\USB介面C&CPP語言SDK20220411\掃碼器C&CPP簡易開發指南v2.1.pdf」,其它開發包是否有這個文件沒有驗證過。
對比兩組圖,生成的二維碼和文檔中的幾乎一樣,看起來後者也像通過工具生成的。
Demohidprotocal
進入開發模式後再掃碼就只是滴滴叫不上屏了,此時需要使用 sdk 寫一個程式來獲取掃碼器的輸出,在 C/CPP 開發包有中一個現成的 demo:USB介面C-CPP語言SDK20220411\USB介面C&CPP語言SDK20220411\Demohidprotocal\Release\Demohidprotocal.exe,這是 release 版本,選擇 debug 版本也行,啟動後介面如下:
表示連接掃碼器成功,分別掃描車架號和車牌號:
vbar_open success!
開始解碼:
二維碼長度:18
LFV3A23C083027701
二維碼長度:10
浜琈D0926
車架號是正常的,而車牌號果然是亂碼。
找到 Demohidprotocol 源碼 (USB介面C-CPP語言SDK20220411\USB介面C&CPP語言SDK20220411\Demohidprotocal\Demohidprotocal\main.cpp):
#include "channel.h"
#include <stdio.h>
#include <windows.h>
#include <string.h>
struct vbar_channel *dev;
/*背光燈開關控制 state為1時打開補光燈,為0時關閉補光燈*/
void lightswitch(int state)
{
unsigned char buf[1025] = { 0 };
if (state == 1)
{
buf[0] = 0x55;
buf[1] = 0xAA;
buf[2] = 0x24;
buf[3] = 0x01;
buf[4] = 0x00;
buf[5] = 0x01;
buf[6] = 0xDB;
vbar_channel_send(dev, buf, 1024);
}
else
{
buf[0] = 0x55;
buf[1] = 0xAA;
buf[2] = 0x24;
buf[3] = 0x01;
buf[4] = 0x00;
buf[5] = 0x00;
buf[6] = 0xDA;
vbar_channel_send(dev, buf, 1024);
}
}
/*掃碼開關控制 state為1時打開掃碼,為0時關閉掃碼*/
void scanswitch(int state)
{
unsigned char buf[1025] = {0};
if (state == 1)
{
buf[0] = 0x55;
buf[1] = 0xAA;
buf[2] = 0x05;
buf[3] = 0x01;
buf[4] = 0x00;
buf[5] = 0x00;
buf[6] = 0xfb;
vbar_channel_send(dev, buf, 1024);
}
else
{
buf[0] = 0x55;
buf[1] = 0xAA;
buf[2] = 0x05;
buf[3] = 0x01;
buf[4] = 0x00;
buf[5] = 0x01;
buf[6] = 0xfa;
vbar_channel_send(dev, buf, 1024);
}
}
int main() {
dev = vbar_channel_open(1, 1);
if (!dev) {
printf("open dev fail!\n");
return -1;
}
else
{
printf("open dev success!\n");
}
printf("開始解碼:\r\n");
scanswitch(1);
//接收掃碼
unsigned char bufresult[1024] = {0};
unsigned char bufferrecv_1[1024] = {0};
unsigned char readBuffers[2048] = {0};
while (1)
{
if (vbar_channel_recv(dev, bufresult, 1024, 200) > 0)
{
if (bufresult[0] == 0x55 && bufresult[1] == 0xAA && bufresult[2] == 0x30)
{
int datalen = bufresult[4] + (bufresult[5] << 8);
if (datalen <= 1017)
{
for (int s1 = 0; s1 < datalen; s1++)
{
readBuffers[s1] = bufresult[6 + s1];
}
}
if (1017 < datalen && datalen <= 2041)
{
for (int s1 = 0; s1 < 1018; s1++)
{
readBuffers[s1] = bufresult[6 + s1];
}
vbar_channel_recv(dev, bufferrecv_1, 1024, 200);
for (int s2 = 0; s2 < datalen + 7 - 1025; s2++)
{
readBuffers[s2 + 1018] = bufferrecv_1[s2];
}
}
printf("二維碼長度:%d\n", datalen);
readBuffers[datalen + 7] = '\0';
printf("%.*s\n", datalen, readBuffers);
}
}
}
}
謎之編碼風格,另外這介面設計的也有點凌亂,程式中出現了好多魔數:1017/1018/2041/200/7,看著頭大。所幸讀取的數據位於 readBuffers 緩衝中,只要對它做個編碼轉換就 OK 啦。
編碼轉換
windows 中文版編碼一般是 gb2312,漢字源編碼則可能是 utf-8,為了驗證這一點,搬出來了 iconv:
$ echo "浜琈D0926" | iconv -f 'utf-8' -t 'cp936'
京MD0926
看來確實如此,注意這裡使用 cp936 而不是 gb2312 作為 iconv 的第二個參數。如果沒有 iconv,也有許多線上的編碼轉換工具可用:
確定了字符集轉換方向,直接從網上搜羅來一些現成的實現:
std::wstring utf8_to_unicode(std::string const& utf8)
{
int need = MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, NULL, 0);
if (need > 0)
{
std::wstring unicode;
unicode.resize(need);
MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, &unicode[0], need);
return unicode;
}
return std::wstring();
}
std::string unicode_to_gb2312(std::wstring const& unicode)
{
int need = WideCharToMultiByte(936, 0, unicode.c_str(), -1, NULL, 0, NULL, NULL);
if (need > 0)
{
std::string gb2312;
gb2312.resize(need);
WideCharToMultiByte(936, 0, unicode.c_str(), -1, &gb2312[0], need, 0, 0);
return gb2312;
}
return std::string();
}
std::string utf8_to_gb2312(std::string const& utf8)
{
std::wstring unicode = utf8_to_unicode(utf8);
return unicode_to_gb2312(unicode);
}
windows 上編碼轉換都是先轉到 unicode,再轉其它編碼,比較好理解。那麼 demo 中的輸出就可以改為:
std::string gb2312 = utf8_to_gb2312(std::string((char *)readBuffers, datalen));
printf("%.*s\n", gb2312.lenght(), gb2312.c_str());
再次運行:
二維碼長度:10
京MD0926
恢復正常!
輸出到剪貼板
上面的過程雖然能正確解析 utf-8 數據了,但還需要用戶複製 console 輸出的結果,很不方便,如果能將結果直接輸出到剪貼板上豈不是很爽?說干就干:
void copy_to_system_clipboard(std::string const& data)
{
printf("ready to copy data: %s\n", data.c_str());
BOOL ret = OpenClipboard(NULL);
if (!ret)
{
printf("open clipboard failed\n");
return;
}
do
{
ret = EmptyClipboard();
if (!ret)
{
printf("empty clipboard failed\n");
break;
}
HGLOBAL hdata = GlobalAlloc(GMEM_MOVEABLE, data.length() + 1);
if (hdata == NULL)
{
printf("alloc data for clipboard failed");
break;
}
char* str = (char *) GlobalLock(hdata);
memcpy(str, data.c_str(), data.length());
str[data.length()] = 0;
GlobalUnlock(hdata);
// HANDLE h = SetClipboardData(CF_UNICODETEXT, hdata);
HANDLE h = SetClipboardData(CF_TEXT, hdata);
if (!h)
{
printf("set clipboard data failed");
break;
}
printf("copy to clipboard ok\n");
} while (0);
CloseClipboard();
}
基本上是抄了網上一個例子實現的,只是增加了一些錯誤提示。調用點稍微改造就大功告成:
printf("%.*s\n", datalen, readBuffers);
std::string gb2312 = utf8_to_gb2312(std::string((char *)readBuffers, datalen));
copy_to_system_clipboard(gb2312);
再次運行:
二維碼長度:10
浜琈D0926
ready to copy data: 京MD0926
copy to clipboard ok
此時在任一文本框中按 Ctrl+V,均能得到號牌數據。
這裡請注意 copy_to_system_clipboard 中的 SetClipboardData 調用,使用 CF_TEXT 而不是 CF_UNICODETEXT,否則會得到下面的亂碼輸出:
ꦾ䑍㤰㘲
另外測試中發現可以同時啟動多個 demo,相互之間不衝突,均能從介面拿到掃描後的數據,神奇。
輸出到當前游標
上面的解決方案已經很好了,但是如果能像之前一樣輸出到游標就更棒了,用戶可以無疑切換。作為資深 MFCer,立刻想到了一種解決方案:查找當前桌面前台 (Foreground) 窗口,找到它的活動子窗口並投遞 WM_SETTEXT 消息。下面是參考網上一個例子的實現:
void set_text_to_active_windows(std::string const& data)
{
int ret = 0;
std::wstring unicode;
HWND wnd = GetForegroundWindow();
//HWND wnd = GetActiveWindow();
//HWND wnd = GetDesktopWindow();
if (wnd == NULL)
{
printf("no active windows\n");
return;
}
printf("get active window\n");
DWORD SelfThreadId = GetCurrentThreadId();
DWORD ForeThreadId = GetWindowThreadProcessId(wnd, NULL);
if (!AttachThreadInput(ForeThreadId, SelfThreadId, true))
{
printf("attach thread input failed\n");
return;
}
printf("attach thread input\n");
//wnd = GetFocus();
wnd = GetActiveWindow();
if (wnd == NULL)
{
printf("no focus windows\n");
return;
}
printf("get focus window\n");
AttachThreadInput(ForeThreadId, SelfThreadId, false);
unicode = gb2312_to_unicode(data);
ret = SendMessage(wnd, WM_SETTEXT, 0, (LPARAM)unicode.c_str());
printf("send text to active window return %d: %s\n", ret, data.c_str());
}
調用點僅需稍加改造就可以了:
printf("%.*s\n", datalen, readBuffers);
std::string gb2312 = utf8_to_gb2312(std::string((char *)readBuffers, datalen));
copy_to_system_clipboard(gb2312);
set_text_to_active_windows(gb2312);
編譯運行,先啟動一個 notepad 應用,將游標置於其中,便於稍後看輸出結果,然而掃碼後沒有任何輸出。將上面的 GetForegroundWindow 替換為 GetActiveWindow 或 GetDesktopWindows 都沒有效果,更神奇的是加的許多 printf 調試日誌也沒有輸出,這真是見了鬼了:
open dev success!
開始解碼:
二維碼長度:10
浜琈D0926
ready to copy data: 京MD0926
copy to clipboard ok
send text to active window return 0: 京MD0926
二維碼長度:18
LFV3A23C083027701
ready to copy data: LFV3A23C083027701
copy to clipboard ok
send text to active window return 0: LFV3A23C083027701
只輸出最終的一個調用結果。一開始懷疑是 console 程式和 win32 介面程式的不同,決定新建一個新的 win32 應用試試,由於 Win32 應用的主執行緒要做消息循環,這裡啟動一個單獨的執行緒跑掃碼的邏輯:
int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
_In_opt_ HINSTANCE hPrevInstance,
_In_ LPWSTR lpCmdLine,
_In_ int nCmdShow)
{
UNREFERENCED_PARAMETER(hPrevInstance);
UNREFERENCED_PARAMETER(lpCmdLine);
// TODO: Place code here.
// Initialize global strings
LoadStringW(hInstance, IDS_APP_TITLE, szTitle, MAX_LOADSTRING);
LoadStringW(hInstance, IDC_DEMOHIDPROTOCAL, szWindowClass, MAX_LOADSTRING);
MyRegisterClass(hInstance);
// Perform application initialization:
if (!InitInstance (hInstance, nCmdShow))
{
return FALSE;
}
hThread = CreateThread(NULL, 0, qrscanner_loop, NULL, 0, NULL);
HACCEL hAccelTable = LoadAccelerators(hInstance, MAKEINTRESOURCE(IDC_DEMOHIDPROTOCAL));
MSG msg;
// Main message loop:
while (GetMessage(&msg, nullptr, 0, 0))
{
if (!TranslateAccelerator(msg.hwnd, hAccelTable, &msg))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
return (int) msg.wParam;
}
qrscanner_loop 就是之前 console main 那一堆東西了,為了展示資訊,在默認的視圖中間填充一個 edit 控制項:
BOOL InitInstance(HINSTANCE hInstance, int nCmdShow)
{
hInst = hInstance; // Store instance handle in our global variable
HWND hWnd = CreateWindowW(szWindowClass, szTitle, WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, nullptr, nullptr, hInstance, nullptr);
if (!hWnd)
{
return FALSE;
}
RECT rect = { 0 };
GetClientRect(hWnd, &rect);
hEdit = CreateWindowW(TEXT("Edit"), TEXT(""),
WS_CHILD | WS_VISIBLE | ES_LEFT | ES_MULTILINE | ES_WANTRETURN | WS_VSCROLL | ES_AUTOVSCROLL,
rect.left, rect.top, rect.right-rect.left, rect.bottom-rect.top, hWnd, (HMENU)10002, hInstance, NULL);
ShowWindow(hWnd, nCmdShow);
UpdateWindow(hWnd);
return TRUE;
}
至於編輯框隨視圖大小變化而變化這種基本功,就不贅述了,後面會放出完整源碼。注意這裡的 hEdit,它存儲著編輯框的句柄,後面會用到。
console 改 win32 最大的變化是 printf 日誌輸出沒有了,為了解決這個問題,改寫 printf 為 my_printf,在裡面做些文章:
extern HWND hEdit;
void my_printf(char const* format, ...)
{
char line[4096] = { 0 };
va_list vp;
va_start(vp, format);
vsprintf(line, format, vp);
va_end(vp);
// replace '\n' to '\r\n'
if (strlen(line) > 0)
line[strlen(line) - 1] = '\r';
strcat(line, "\n");
//std::wstring data = gb2312_to_unicode(line);
// SendMessage(hEdit, WM_SETTEXT, 0, (WPARAM)data.c_str());
SendMessage(hEdit, EM_SETSEL, -2, -1);
SendMessageA(hEdit, EM_REPLACESEL, true, (long)line);
SendMessage(hEdit, WM_VSCROLL, SB_BOTTOM, 0);
OutputDebugStringA(line);
}
基本就是將日誌發往剛才的 hEdit,注意這裡不使用 WM_SETTEXT 以免衝掉歷史消息,最後上張效果圖:
再看下新版 set_text_to_active_windows 的實現:
void set_text_to_active_windows(std::string const& data)
{
int ret = 0;
wchar_t const* str;
HWND wnd = GetForegroundWindow();
//HWND wnd = GetActiveWindow();
//HWND wnd = GetDesktopWindow();
if (wnd == NULL)
{
my_printf("no active windows\n");
return;
}
my_printf("get active window\n");
DWORD SelfThreadId = GetCurrentThreadId();
DWORD ForeThreadId = GetWindowThreadProcessId(wnd, NULL);
if (!AttachThreadInput(ForeThreadId, SelfThreadId, true))
{
my_printf("attach thread input failed\n");
return;
}
my_printf("attach thread input\n");
//wnd = GetFocus();
wnd = GetActiveWindow();
if (wnd == NULL)
{
my_printf("no focus windows\n");
return;
}
my_printf("get focus window\n");
AttachThreadInput(ForeThreadId, SelfThreadId, false);
//std::wstring unicode = gb2312_to_unicode(data);
std::wstring_convert<std::codecvt_utf8_utf16<wchar_t>> converter;
std::wstring tst = converter.from_bytes(data);
str = tst.data();
//ret = SendMessageA(wnd, WM_SETTEXT, 0, (LPARAM)data.c_str());
for (int i=0; str[i] != '\n'; ++i)
{
ret = SendMessage(hEdit, WM_CHAR, str[i], 0);
}
my_printf("send text to active window return %d: %s\n", ret, data.c_str());
}
與之前版本相比,除了 printf 變為 my_printf,最大的變化是在結尾部分:使用 WM_CHAR 消息代替 WM_SETTEXT。這樣做是為了更好的模擬游標行為,畢竟不能假設用戶游標一定位於 windows edit 控制項上,有可能位於繪製介面框架 (Qt) 或描述介面框架 (Electron) 生成的 App 的控制項上,這個消息可以實現字元被一個個輸入編輯框的效果,兼容上述所有控制項。
滿懷期待的啟動應用後,出現和 console 程式一樣的行為——游標下沒有任何輸出,且不列印任何調試日誌,遇到中文字元還會崩潰:
看崩潰點沒什麼頭緒,表現還不如 console 呢,這下把我整不會了,最終這個方案宣告失敗。不過留著還是有意義的,萬一有人基於它實現了游標輸出呢…
結語
本文嘗試解決掃碼器在遇到中文時不輸出字元的問題,總體上解決了這個問題,優雅的解決方案因技術問題沒有實現,不優雅的解決方案針對檢測場的需求來說也夠用了。
最早想的技術方案其實是不想動 demo 程式的,當時想通過在外麵包一層 shell 腳本來解決,熟悉的讀者知道我喜歡用這種方式解決一些問題,當時主要有兩個原因導致想這樣干:
- 家裡的 windows 筆記型電腦沒裝 VS,安裝 VS2015 一來比較慢,二來拖累機器運行速度,不想裝
- demo 程式已經比較完整,只缺一個編碼轉換的工作,而用腳本調用 iconv 一行就能搞定,何必費力寫 c++ 呢?
後面親自試過後,發現有兩個問題 shell 腳本無法繞開:
- demo 的輸出在經過 msys2 處理後,無法正確斷行,導致無法從輸出資訊中提取掃碼器讀取的數據,對於這個問題
- 開始懷疑是管道重定向後 stdout 不再是行緩衝的,而在 shell 層面無法改變一個程式的 stdout 緩衝類型
- 後來修改 demo 源碼,增加 setvbuf 調用 (參考《[apue] 標準 I/O 庫那些事兒 》中緩衝一節),重新編譯但不起作用
- 最終定性是 msys2 與 demo 之間的兼容性問題,不好搞,放棄
- 想要將數據複製到系統剪貼板,可以直接在 msys2 中使用 windows 的 clip 命令接收要放置的數據 (echo “${data}” | clip),但是如果想將數據輸出到游標,對不起辦不到。這個必需用 c++ 進行系統開發 (後來也沒走通,不過這是後話)
最終是將公司的 windows 本帶回來專門搞這個事情,那個開發環境配置的比較全面,不用浪費時間再配了。說到這裡,突然想到為何沒有人搞在線的 VS 開發環境?linux 上的 gcc 這種環境一搜一大把,提交個文件或直接在 web 介面里寫 c++ 程式碼,就能編譯出可執行文件,而免費的 VS 線上開發環境卻幾乎沒有!如果有人搞個 VS 的在線編譯環境,肯定能火,哪怕編譯一次收個十元二十元的,我估計也有人用。
上面說了一些解決過程中的探索,下面談談這個掃碼器的問題,如果它能將編碼轉換功能集成在硬體里,通過配置來決定如何進行編碼轉換,那麼這個場景就不需要二次開發 sdk 了!只要運行下 VguangConfig 並做一些勾選工作就可以了,如果再將常用的幾種編碼轉換做成二維碼配置放在文檔中,直接掃對應的碼就搞定了!後續給廠家回饋時,廠家表示可以考慮,其實就是增加一個 iconv.dll 的事兒,不難!
最後說一下系統升級導致掃碼器不能用的問題,這就是典型的沒做系統集成測試案例啊!新系統沒有兼容老系統的一些隱性規則,導致下游出問題,其實完全可以讓升級系統的軟體廠商改進一下它這個二維碼的生成方式,是用 utf8 還是 gb2312,搞成可配置的,操作人員通過配置來保持以前的編碼方式不變,這個問題也能得到解決。
下載
掃碼器 sdk 官網就可以下載,兩個應用的源碼及可執行文件鏈接如下:
console 版可以直接用,win32 版還是個半成品,感興趣的讀者可以嘗試探索一下。
console 版的可執行文件為 debug 版本,release 版本不知為何編譯報錯:
1>------ Build started: Project: Demohidprotocal, Configuration: Release Win32 ------
1>main.obj : error LNK2001: unresolved external symbol __imp__SetClipboardData@8
1>main.obj : error LNK2001: unresolved external symbol __imp__EmptyClipboard@0
1>main.obj : error LNK2001: unresolved external symbol __imp__CloseClipboard@0
1>main.obj : error LNK2001: unresolved external symbol __imp__OpenClipboard@4
1>D:\BaiduNetdiskDownload\USB介面C&CPP語言SDK20220411\Demohidprotocal\Release\Demohidprotocal.exe : fatal error LNK1120: 4 unresolved externals
========== Build: 0 succeeded, 1 failed, 0 up-to-date, 0 skipped ==========
看起來和作業系統剪貼板相關,因時間關係沒有進一步去研究。
參考
[1]. 草料二維碼
[2]. 微光互聯
[3]. Windows下的字符集轉換(ASCII、UICODE、UTF8、GB2312和BIG5互轉)
[4]. 編碼轉換
[5]. 剪貼板操作
[6]. Windows/Mac/Linux/ssh將shell內容輸出到剪貼板