在这个项目中,我们实现了类似 POS 机的效果,不仅可以设置收款金额,而且还可以选择收款方式,比如支付宝、微信、QQ等,用户扫码后就可以实现支付。
预期目标及功能
- 触摸键盘功能;
- 支付图标显示;
- 支付方式选择;
- 二维码生成;
- 网络状态反馈;
- 触摸震动反馈;
所用硬件
M5Core2 具有如下特点:
- 基于 ESP32 开发,支持 WiFi,蓝牙;
- 16M 闪存,8M PSRAM;
- 内置扬声器,电源指示灯,震动马达,RTC,I2S 功放,电容式触摸屏,电源键,复位键;
- TF 卡插槽(支持最大 16GB);
- 内置锂电池,配备电源管理芯片;
- 独立小板内置 6 轴 IMU,PDM 麦克风;
- M-Bus 总线插座。
程序设计
下面开始详细讲解程序设计过程。
开发环境
我们使用 Arduino IDE 来编写本项目的程序,上传程序时开发板选择 M5Stack-Core2,编程过程中需要用到的软件及库,将会打包作为附件给大家下载,详见文末下载说明。
程序思路
为了实现项目的所有功能,我们先根据预期的目标绘制思维导图,再根据思维导图逐步实现自制 POS 结算终端机的功能。
下面我们将具体讨论自制结算终端的各个子功能是如何实现的。
触摸按键测试程序
我们想要使用触摸屏实现金额的输入以及支付方式的选择,离不开设计触摸按键。M5Core2 为我们提供了成熟的解决方案,我们能够轻易地绘制一个按键,并且设置指定的区域,触摸按键的使用示例如下:
#include <M5Core2.h>
ButtonColors on_clrs = {YELLOW, WHITE, WHITE};
ButtonColors off_clrs = {BLACK, WHITE, WHITE};
Button tl(0, 0, 0, 0, false , "Button", off_clrs, on_clrs, MC_DATUM);
void setup() {
M5.begin();
M5.Buttons.addHandler(eventDisplay, E_ALL - E_MOVE);
doButtons();
}
void loop() {
M5.update();
}
void doButtons() {
uint8_t but_w = 100;
uint8_t but_h = 60;
tl.set(110, 90, but_w, but_h); // 设置按键的显示坐标以及长和宽
M5.Buttons.draw();
}
void eventDisplay(Event& e) {
Serial.printf("%-12s finger%d %-18s (%3d, %3d) --> (%3d, %3d) ",
e.typeName(), e.finger, e.objName(), e.from.x, e.from.y,
e.to.x, e.to.y);
Serial.printf("( dir %d deg, dist %d, %d ms )\n", e.direction(),
e.distance(), e.duration);
}
其中:
ButtonColors on_clrs = {YELLOW, WHITE, WHITE}
定义了按键按下时的颜色以及按键框的颜色;
ButtonColors off_clrs = {BLACK, WHITE, WHITE}
定义了按键释放时的颜色以及按键框的颜色;
Button tl(0, 0, 0, 0, false , "Button", off_clrs, on_clrs, MC_DATUM)
定义了按键的显示文本以及文本显示方式;
doButtons()
函数绘制了按钮;
eventDisplay(Event& e)
函数用来侦测屏幕触摸事件。
如果你想要实现更多个性化设置,请参考 M5Core2.h
库文件进行设置。
触摸按键效果测试
上传上面的测试程序,打开串口监视器,点击 M5Core2 屏幕上的触摸按键可看到下图所示内容:
可以看到:
-
当点击程序定义的触摸按键时,串口会返回按键的标签字符串 Button 以及按下的持续时间以及坐标区域;
-
当点击未被程序设置的区域时,返回的字符串是 background;
-
当点击触摸屏上的另外三个默认触摸按键时,返回 BtnA,BtnB 或 BtnC。
这里我们重点关注串口打印的 e.typeName()
(触发类型)和 e.objName()
(触发按键名),后面我们将重点利用这两个返回值,可以根据返回值区分我们按下的每一个按键。触发类型我们关注 E_RELEASE
这个返回值,该字符串代表了按键被释放,可以用来检测按键是否点击结束。
主界面 UI 设计
知道了如何利用程序定义一个触摸按钮之后,接下来我们来设计该主界面的 UI。根据前面所学的按钮绘制以及定义方法,绘制出所有数字输入按键、支付按键、清除按键以及确认按键,结合圆角矩形绘制函数 M5.Lcd.drawRoundRect()
绘制出主界面,主界面设计如下:
图标显示
显示图标有两种方式,第一种方式是单色图标,第二种方式是彩色图标。单色图标可以直接使用 M5.Lcd.drawXBitmap()
函数绘制,彩色图标则可以使用 M5.Lcd.drawBitmap()
函数进行绘制。本项目中,单色图标有 WiFi 图标以及货币的符号 ¥。
对于单色图标,可以通过取模软件 Image2Lcd 取模。在 Image2Lcd 软件中,选择需要取模的图片,根据自己的屏幕类型,调整取模方式、取模大小、亮度,最后导出取模数据。设置如下:
对于货币符号 ¥ 我们使用 Mixly 软件中的取模工具获取字模,取模设置如下:
对于彩色图片,可以使用 ImageConverter 软件获取彩色图片取模数据,其界面如下,选择需要取模的图片并调整其大小,最后导出为 C 语言取模数据即可:
按键功能以及 UI 设计
现在根据前面的按键 UI 设计示例、以及图像显示函数设计出按键的处理程序,我们先定义一个输入字符串变量 Input_data
代表输入的字符串,当我们按下数字按键以及小数点时对输入的字符进行连接,按下删除按键 DEL 删除 Input_data 的最后一个字符,按下对应的支付方式时显示对应的图标,具体程序如下:
#include <M5Core2.h>
extern const unsigned short success_icon[0x125C0];
extern const unsigned short failure_icon[0xE100];
extern const unsigned char currency[0x78];
extern const unsigned short QQ_icon[0x3A2];
extern const unsigned short WX_icon[0x384];
extern const unsigned short ZFB_icon[0x384];
extern const unsigned char wifi[0x78];
String Input_data;
ButtonColors on_clrs = {YELLOW, WHITE, WHITE};
ButtonColors off_clrs = {BLACK, WHITE, WHITE};
Button tl(0, 0, 0, 0, false , "7", off_clrs, on_clrs, MC_DATUM);
Button t2(0, 0, 0, 0, false, "8", off_clrs, on_clrs, MC_DATUM);
Button t3(0, 0, 0, 0, false, "9", off_clrs, on_clrs, MC_DATUM);
Button t4(0, 0, 0, 0, false, "QQ", off_clrs, on_clrs, MC_DATUM);
Button t5(0, 0, 0, 0, false , "4", off_clrs, on_clrs, MC_DATUM);
Button t6(0, 0, 0, 0, false, "5", off_clrs, on_clrs, MC_DATUM);
Button t7(0, 0, 0, 0, false, "6", off_clrs, on_clrs, MC_DATUM);
Button t8(0, 0, 0, 0, false, "WX", off_clrs, on_clrs, MC_DATUM);
Button t9(0, 0, 0, 0, false , "1", off_clrs, on_clrs, MC_DATUM);
Button t10(0, 0, 0, 0, false, "2", off_clrs, on_clrs, MC_DATUM);
Button t11(0, 0, 0, 0, false, "3", off_clrs, on_clrs, MC_DATUM);
Button t12(0, 0, 0, 0, false, "ZFB", off_clrs, on_clrs, MC_DATUM);
Button t13(0, 0, 0, 0, false , "0", off_clrs, on_clrs, MC_DATUM);
Button t14(0, 0, 0, 0, false, ".", off_clrs, on_clrs, MC_DATUM);
Button t15(0, 0, 0, 0, false, "DEL", off_clrs, on_clrs, MC_DATUM);
Button t16(0, 0, 0, 0, false, "CON", off_clrs, on_clrs, MC_DATUM);
void setup() {
M5.begin();
M5.Lcd.fillScreen(BLACK); // 设置背景颜色
M5.Buttons.addHandler(eventDisplay, E_ALL - E_MOVE); // 注册按键动作检测
M5.Lcd.drawRoundRect(0, 0, 315, 43, 5, WHITE); // 绘制显示文本框
M5.Lcd.drawXBitmap(10, 6, currency, 30, 30, WHITE); // 显示货币图标
M5.Lcd.setTextColor(WHITE, BLACK); // 设置显示文本颜色
M5.Lcd.setTextSize(2); // 设置显示字体大小
M5.Lcd.drawBitmap(262, 6, 30, 30, WX_icon); // 显示默认微信图标
doButtons(); // 设置按钮属性及绘制按钮
}
void loop() {
M5.update();
}
void doButtons() {
uint8_t but_w = 75;
uint8_t but_h = 43;
tl.set(0, 48, but_w, but_h);
t2.set(80, 48, but_w, but_h);
t3.set(160, 48, but_w, but_h);
t4.set(240, 48, but_w, but_h);
t5.set(0, 96, but_w, but_h);
t6.set(80, 96, but_w, but_h);
t7.set(160, 96, but_w, but_h);
t8.set(240, 96, but_w, but_h);
t9.set(0, 144, but_w, but_h);
t10.set(80, 144, but_w, but_h);
t11.set(160, 144, but_w, but_h);
t12.set(240, 144, but_w, but_h);
t13.set(0, 192, but_w, but_h);
t14.set(80, 192, but_w, but_h);
t15.set(160, 192, but_w, but_h);
t16.set(240, 192, but_w, but_h);
M5.Buttons.draw();
}
void eventDisplay(Event& e) {
Serial.printf("%-12s finger%d %-18s (%3d, %3d) --> (%3d, %3d) ",
e.typeName(), e.finger, e.objName(), e.from.x, e.from.y,
e.to.x, e.to.y);
Serial.printf("( dir %d deg, dist %d, %d ms )\n", e.direction(),
e.distance(), e.duration);
if (String(e.objName()).equals(String("BtnA"))) {
// 执行BtnA事件
delay(50);
} else {
if (String(e.objName()).equals(String("BtnB"))) {
// 执行BtnB事件
delay(50);
} else {
if (String(e.objName()).equals(String("BtnC"))) {
// 执行BtnC事件
delay(50);
} else {
if (String(e.typeName()).equals(String("E_RELEASE"))) { // 检测是否释放屏幕
if (!String(e.objName()).equals(String("background"))) { // 检测是否为已注册按键区域
if (String(e.objName()).equals(String("QQ"))) { // 检测是否为按下QQ按键
Serial.println("QQ");
M5.Lcd.drawBitmap(262, 6, 30, 31, QQ_icon); // 显示QQ图标
delay(50);
} else {
if (String(e.objName()).equals(String("WX"))) { // 检测是否为按下WX按键
Serial.println("WX");
M5.Lcd.drawBitmap(262, 6, 30, 30, WX_icon); // 显示微信图标
delay(50);
} else {
if (String(e.objName()).equals(String("ZFB"))) { // 检测是否为按下ZFB按键
Serial.println("ZFB");
M5.Lcd.drawBitmap(262, 6, 30, 30, ZFB_icon); // 显示支付宝图标
delay(50);
} else {
if (String(e.objName()).equals(String("CON"))) { // 检测是否为按下CON确认按键
Serial.println("CON");
if (!Input_data.equals(String("")) && Input_data.toFloat() > 0) { // 当输入不为空且数字大于0生成订单
Serial.println("Amount:" + Input_data);
// 生成订单
}
delay(50);
} else {
if (String(e.objName()).equals(String("DEL"))) { // 检测是否为按下DEL删除按键,按下删除末尾字符
Input_data = String(Input_data).substring(0, (String(Input_data).length() - 1)); // 删除输入字符串末尾字符
M5.Lcd.fillRoundRect(40, 0, 180, 43, 5, BLACK); // 清空显示字符串
M5.Lcd.drawRoundRect(0, 0, 315, 43, 5, WHITE); // 重新绘制文本显示框
M5.Lcd.setCursor(40, 33); // 设置文本显示坐标
M5.Lcd.printf(Input_data.c_str()); // 显示输入文本
delay(50);
} else {
if (String(Input_data).length() <= 7) { // 检测到数字按键及小数点
Input_data = String(Input_data) + String(e.objName()); // 连接字符串
if (Input_data.startsWith(".")) { // 输入首位字符为小数点
Input_data = ""; // 清空字符串
}
M5.Lcd.fillRoundRect(40, 0, 180, 43, 5, BLACK);
M5.Lcd.drawRoundRect(0, 0, 315, 43, 5, WHITE);
M5.Lcd.setCursor(40, 33);
M5.Lcd.printf(Input_data.c_str());
delay(50);
}
}
}
}
}
}
}
}
}
}
}
}
在上面的程序当中,我们还应该考虑输入字符串的特殊情况例如,首位不能是小数点,首位不能连续两个 0 等情况,其他情况请自行分析,此处省略了取模数据,上传该程序效果如下:
WiFi 连接反馈
当没有连接网络时,网络应当自动连接并且反馈当前的连接状态,实现代码如下:
if (!(WiFi.status() != WL_CONNECTED)) { // 检测网络连接状态
M5.Lcd.drawXBitmap(220, 6, wifi, 30, 30, GREEN); // 显示绿色wifi图标
} else {
M5.Lcd.drawXBitmap(220, 6, wifi, 30, 30, RED); // 显示红色wifi图标
WiFi.disconnect(); // 断开连接
WiFi.mode(WIFI_STA); // 设置为STA模式
WiFi.begin(STASSID, STAPSW); // 设置wifi并连接
delay(1000); // 等待网络连接
}
使用此方式连接网络比直接初始化连接网络更好,其主函数不会受网络状况的影响且能够自动连接网络,在以后使用 ESP 系列连接网络都建议使用此种方式。加上连网反馈后效果如下:
发起支付请求
完成上面的程序后,当我们输入金额并确认支付方式时,按下 CON 确认键会发出订单请求,生成订单号并显示支付二维码。
这里我们用到了第三方服务奇迹码支付(http://pay.qj61.cn/),进入官网,按提示注册以及上传自己的收款二维码,并下载安装其软件监听通知,按提示进行设置,并保证监听软件与收款账号为同一个手机。微信可以开启店员功能,则无需保证同一手机,监听软件必须保证不被后台清除。完成上面步骤后,再来简单了解下其 API 的构成,我们重点查看其订单生成与支付状态验证。
订单生成
通过对文档分析可以得到订单生成的 API 为:
http://pay.qj61.cn/createOrder?mid=<mid>&payId=<payId>&type=<type>&price=<price>&sign=<sign>¶m=<param>&isHtml=0
其中 sign 为订单信息与密钥的 MD5 加密信息,MD5 是一种加密技术,可以通过百度获取其加密原理,在这里我们可以通过 MD5 在线加密获取这个加密信息,替换参数就能生成这个订单。上面的例子中给我们演示了 MD5 加密和加密后的情况,在这里我们通过 MD5 加密库文件对原数据进行加密,加密程序如下:
#include <MD5_String.h>
void setup()
{
Serial.begin(9600);
Serial.println(md5_string("1547129707139vone66620.1a7cc8678193ee9c70ae3d75fd04ae6a9"));
}
void loop()
{}
上传程序后,我们便能够得到加密后的结果与给定的示例一致,值得注意的是 md5_string()
函数接受的是字符数组,不能通过字符串变量的形式传入字符串,加密的字符串必须先转换为字符数组,其转换的方法将在附件的完整程序进行说明。
订单返回数据
等我们成功生成订单并返回数据后,我们需要对返回的 JSON 数据进行分析,从而得到我们想要的数据。在反馈的数据当中,我们希望获取的是云端订单号,我们将通过查询该订单号来获取是否支付成功,在这里我们通过 ArduinoJson 库对返回的数据进行分析,我们可以通过 ArduinoJson 助手(https://arduinojson.org/v6/assistant/)在线反序列 JSON 数据得到想要的结果。此处获得解析结果的代码如下:
// std::string input;
StaticJsonDocument<512> doc;
DeserializationError error = deserializeJson(doc, input);
if (error) {
Serial.print(F("deserializeJson() failed: "));
Serial.println(error.f_str());
return;
}
int code = doc["code"]; // 1
const char* msg = doc["msg"]; // "成功"
JsonObject data = doc["data"];
const char* data_payId = data["payId"]; // "4037819497"
const char* data_orderId = data["orderId"]; // "202104210204575941"
int data_payType = data["payType"]; // 1
const char* data_price = data["price"]; // "0.10"
float data_reallyPrice = data["reallyPrice"]; // 0.1
const char* data_payUrl = data["payUrl"]; // "wxp://f2f0FaA-i_PQr6TJHgnqKVy8ICbYQQB4Zpj6"
int data_isAuto = data["isAuto"]; // 1
int data_state = data["state"]; // 2
const char* data_timeOut = data["timeOut"]; // "5"
long data_date = data["date"]; // 1618943457
我们对以上结果进行分析,删除无关数据后处理结果如下:
StaticJsonDocument<512> doc;
DeserializationError error = deserializeJson(doc, input);
if (error) {
Serial.print(F("deserializeJson() failed: "));
Serial.println(error.f_str());
return;
}
JsonObject data = doc["data"];
const char* data_orderId = data["orderId"]; // "202104210204575941"
订单状态验证
通过对文档的分析可得到查询订单状态的 API 为:
http://pay.qj61.cn/getOrder?orderId=<order_id>
同理我们用 ArduinoJson 助手解析返回的数据,可以得到当返回的订单状态 state,当 state 大于 0 那么就认为支付成功。
发送 API 请求
ESP32 发送 get 请求方法如下:
#include <WiFi.h>
#include <HTTPClient.h>
void setup() {
Serial.begin(9600);
WiFi.begin("ssid", "password");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("Local IP:");
Serial.print(WiFi.localIP());
}
void loop() {
if (WiFi.status() == WL_CONNECTED) {
HTTPClient http;
http.begin("http://jsonplaceholder.typicode.com/posts/1");
int httpCode = http.GET();
if (httpCode > 0) { //请求成功
String Request_result = http.getString();
Serial.println(Request_result); //打印获取到的数据
}
else {
Serial.println("Invalid response!"); //请求失败,打印提示
}
http.end();
}
delay(5000);
}
显示二维码
通过对上面文档的分析和 ESP32 发送 get 请求的案例,我们已经能够生成订单并且获取订单支付结果了。当订单生成后,我们需要将订单转换成二维码。
对于 M5Core2,我们可以通过内置二维码显示函数M5.Lcd.qrcode()
来生成收款二维码,只需要输入想要显示的字符串就可以全屏显示二维码了。此处我们使用了一个小技巧,因为云端已经上传过收款码,生成订单后,返回 URL 为我们的收款码图片地址,由于我们的单片机性能有限,并不能够直接显示网络图片,故此处我们先将收款码通过草料二维码平台(https://cli.im/deqr)在线解析出二维码数据,最后通过二维码显示函数直接显示这个收款码数据字符串,便实现了间接显示收款码图片了。
另外,我们还应当根据选择的支付类型显示不同的收款二维码。如果您使用的是其他开发板,不能够使用现成的函数显示二维码,则可以通过附录文件的 qrcode 库文件(https://github.com/ricmoo/QRCode),经过简单的修改,即可将显示二维码功能快速移植到你自己的显示屏中。
订单验证
显示完二维码以后,我们给定一个超时时间,例如 60 秒:
- 若 60 秒内完成付款,应当显示付款成功的提示图标,并停留一段时间后返回主界面;
- 若超时限定时间,则显示付款失败提示图标,并停留一段时间后返回主界面。
此处我们可以用一个 for 循环来验证订单状态,为了确保能够随时取消订单,for 循环应当调用 M5.update()
函数用来更新触摸屏的检测状态,我们可以令任意 ABC 3 个按钮被按下时主动跳出订单验证的 for 循环,当二维码显示的时候,我们触摸屏幕会发现原来定义的触摸按键还在生效,按下屏幕会显示按键,显示完二维码后应当禁用屏幕的触摸功能,最直接的方法就是重新设置所有按键的属性,我们把按键显示的起点坐标与按钮长宽均设置为 0,如此所有按钮将失效,当我们付款成功或者是超时的时候重新设置回原来的按钮属性,并口重新启用按键功能,以上方法将在附录的完整程序中演示。
震动反馈
M5Core2 内置了一个振动马达,在这里我们按下定义的按键以及显示二维码时,可以让振动马达发出震动,增强作品的体感效果,控制振动马达的主要程序如下:
```c++
M5.Axp.SetLDOEnable(3, true); // 开启震动
M5.Axp.SetLDOEnable(3, false); // 关闭震动
### 语音提示
为了增加作品的趣味性,我们可以在收款成功后播放语音提示,ESP32 的 i2s 能够让我们直接播放简短音频,可以利用 M5Core2 的扬声器播放零钱到账的声音,程序如下:
```c++
#include <M5Core2.h>
#include <driver/i2s.h>
#include "play_WAV.h"
extern const unsigned char previewR[90882];
void setup() {
M5.begin(true, true, true, true);
M5.Axp.SetSpkEnable(true);
InitI2SSpeakOrMic(1);
}
void loop() {
size_t bytes_written = 0;
i2s_write(I2S_NUM_0, previewR, 90882, &bytes_written, portMAX_DELAY);
delay(1000);
}
在上面的程序当中,M5Core2 将每隔一秒播放一次音频,这里我们省略了音频数据,获取音频数据方法如下,先准备一段 WAV 的简短音频,通过软件 HxDSetup 打开该音频,并按下图所示导出:
该程序及软件的完整例子请查看附录文件。
代码组合
最后,按照上述功能之间的逻辑关系,将代码组合在一起即可。由于篇幅限制,这里就不放完整的代码了,文中的所有案例以及完整程序大家可以通过下载附件进行查看。
小结
本项目中,我们以近乎零成本的方式实现了二维码支付的问题,你甚至可以仅通过 1 块 ESP8266 开发板而不需要屏幕来完成本项目。支付方式取决于你使用的第三方服务,在这里仅支持了主流的微信支付与支付宝,在最新一代的第三方服务当中你甚至可以不需要监听软件就可以实现本项目,但他们可能需要少许的费用。
本项目以体验为主,让大家以最低成本去实现属于你自己的共享经济项目。以本项目为基础可以扩展很多共享经济作品,比如自动贩卖机,或者你也可以制作一个笑话售卖机,一分钱看一则笑话。你的脑洞决定了你的作品,让我们一起脑洞大开吧!
资源下载
最后,欢迎关注公众号:铁熊玩创客,不定期更新创客制作、技术教程、创客教育等相关内容。
回复 掌上POS机 即可获取完整资源的下载链接。