ESP32Cam人工智能教学20
ESP32Cam专用APP
这次我们专门为ESP32Cam量身定制一个手机APP。手机APP是客户端,利用Socket连接ESP32Cam,ESP32Cam成了服务器,实现Socket全双工的数据传输模式,还可以一边显示摄像头图像,一边传送自定义的数据,功能非常齐全。
这一课涉及的内容非常多,涉及的知识非常广,又非常的难。为了避免吓跑了大家,我准备从后面倒着往前讲,由易到难逐层推进。
- 使用效果展示
我们按照上节课的内容(第十九课 UDP Socket服务器),把ESP32Cam设计成一个视频及数据收发的服务器,通电后开启192.168.1.180:8080端口服务。手机安装了专用的APP,连接ESP32Cam的服务端口,成为客户端。(这时候手机和ESP32Cam在同一个网络中,也就是内网中。当然如果你利用星空隧道或者花生壳进行内网穿透FRP,可以把这个ESP32Cam的服务器端口推到公网中,实现用户的异地查看与操控)。
手机APP界面上面有一个连接按钮和两个文本框,用于连接到ESP32Cam,或者断开连接。一个图片控件,显示摄像头的图片。一个文本框和发送按钮,用于发送文本消息到ESP32Cam。一个文本显示标签控件,用于显示从ESP32Cam发送过来的消息。
我们对这个程序进行了一些优化。当ESP32Cam通电开机后,不断地检查客户端是否连接,堵塞并等待客户端的连接,也就是执行在主程序Loop的循环中。当检测到手机APP已连接在线的时候,会进入一个新的循环while (wclient.connected())当中,这时候,只要手机APP在线,就会一直执行这个收发模式的循环。在这个工作状态的循环体中,如果接收到了来自APP的数据(字符串hello),那么就把接收到的字符串前面加上“MSG”的标志,然后返回给手机APP(返回MSGhello)。同时每隔一定的延时,就发送一张摄像头的图片到手机APP中。
当然,如果我们按动手机APP上面的断开按钮,ESP32Cam会退出工作状态的while (wclient.connected())循环,串口显示“client out”,并重新返回到检测客户端连接的主循环Loop中,等待客户端的下一次连接成功。有了这样的机制后,我们可以随时断开手机APP的连接、或者重连,都不会造成程序崩溃。(当然这个ESP32Cam只能有一个客户端连接,因为这个开发板还是比较弱的,所以我们就设置了只允许一个客户端连接。如果需要允许多个客户端同时连接的话,就需要使用线程了,开发出一个主线程,然后给每个连接的客户端都开发出一个新的独立的工作线程。)
#include <WiFi.h>
#include <WiFiClient.h>
#include <WiFiServer.h>
#include "esp_camera.h"
// 摄像头引脚 CAMERA_MODEL_WROVER_KIT
#define PWDN_GPIO_NUM -1
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 21
#define SIOD_GPIO_NUM 26
#define SIOC_GPIO_NUM 27
#define Y9_GPIO_NUM 35
#define Y8_GPIO_NUM 34
#define Y7_GPIO_NUM 39
#define Y6_GPIO_NUM 36
#define Y5_GPIO_NUM 19
#define Y4_GPIO_NUM 18
#define Y3_GPIO_NUM 5
#define Y2_GPIO_NUM 4
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM 23
#define PCLK_GPIO_NUM 22
const char* ssid = "ChinaNet-xxVP";
const char* password = "123456789";
#define CLIENTS_MAX_NUMS 1
WiFiServer server(8080);
WiFiClient serverClients[CLIENTS_MAX_NUMS];
int ac=0;
void setup() {
Serial.begin(115200);//开启串口
camera_config_t config;
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM;
config.pin_d1 = Y3_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM;
config.pin_d3 = Y5_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM;
config.pin_d5 = Y7_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM;
config.pin_d7 = Y9_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM;
config.pin_pclk = PCLK_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM;
config.pin_href = HREF_GPIO_NUM;
config.pin_sccb_sda = SIOD_GPIO_NUM;
config.pin_sccb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 20000000;
//config.frame_size = FRAMESIZE_QVGA; //320 * 240
config.frame_size = FRAMESIZE_HQVGA; //240 * 176
config.pixel_format = PIXFORMAT_JPEG; // for streaming
config.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
config.fb_location = CAMERA_FB_IN_PSRAM;
config.jpeg_quality = 10;
config.fb_count = 1;
if (psramFound()) {
config.jpeg_quality = 10;
config.fb_count = 2;
config.grab_mode = CAMERA_GRAB_LATEST;
} else {
// Limit the frame size when PSRAM is not available
config.frame_size = FRAMESIZE_HQVGA;
config.fb_location = CAMERA_FB_IN_DRAM;
}
// camera init
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("Camera init failed with error 0x%x", err);
return;
}
WiFi.begin(ssid, password);
WiFi.setSleep(false);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("");
Serial.println("WiFi connected");
Serial.println(WiFi.localIP());
delay(500);
Serial.println("Start tcp server...");
server.begin();
server.setNoDelay(true);
}
void loop() {
if (server.hasClient())
{
if (!serverClients[0] || !serverClients[0].connected())
{
if (serverClients[0])
{
serverClients[0].stop();
}
serverClients[0] = server.available();
ac=0;
}
}
if (serverClients[0] && serverClients[0].connected())
{ //程序堵塞,等待客户端连接
WiFiClient wclient = serverClients[0];
camera_fb_t *fb = NULL;
size_t _jpg_buf_len = 0;
uint8_t *_jpg_buf = NULL;
int bs, bss;
Serial.println("clinet in");
while (wclient.connected())
{
if (wclient.available())
{ //当接收到来自客户端的数据时
while (wclient.available())
{
String recv_data = wclient.readStringUntil('\r');
Serial.println(recv_data);
recv_data = "MSG" + recv_data;
wclient.println(recv_data);
}
}
fb = esp_camera_fb_get();
if (fb)
{ // 从摄像头获取图片的数据
_jpg_buf_len = fb->len;
_jpg_buf = fb->buf;
bs = _jpg_buf_len / 1024;
bss = _jpg_buf_len % 1024;
//分成几个数据包进行发送
for(int j=0; j<bs; j++)
{
wclient.write(_jpg_buf, 1024);
for(int i=0; i<1024; i++)
{
_jpg_buf++;
}
delay(40);
}
wclient.write(_jpg_buf, bss);
Serial.println(_jpg_buf_len);
}
if (fb)
{
esp_camera_fb_return(fb);
fb = NULL;
_jpg_buf = NULL;
}
else if (_jpg_buf)
{
free(_jpg_buf);
_jpg_buf = NULL;
}
//这里做个延时,根据你的网络速度调整,在调试时可以延时三四秒发一张
delay(5000);
}
Serial.println("clinet out");
}
delay(1000);
}
- APPInventor制作手机APP
APPInventor是一款适合小学生学习的手机APP制作软件,采用可视化的积木块程序编辑模式,可以在线的方式制作自己的手机APP。网上提供APPInventor服务的服务器有华南理工、广州服务器、上海服务器等,现在好像只剩下广州服务器是免费的,其他两个都已经VIP收费使用了。
我们访问APPInventor广州服务器官网,用QQ账号注册,登录后就可以免费使用了。
这个程序需要两个自定义的组件支持,其中一个网络连接的组件是我自己编写的,另外一个BASE64编码的组件,是我花了40大洋从上海服务器购买的。为了方便大家,我把这个程序的源代码打包到百度网盘,大家可以去下载
链接:https://pan.baidu.com/s/1_PsfLAwq-NRnC5LCCkBJMw?pwd=k4h9
下载到电脑中的是一个aia后缀名的文件。接下来我们可以登录APPInventor广州服务器,点击顶端的“项目——导入项目”菜单,把这个aia文件上传,就能看到这个APP的源代码了。
手机APP的设计分为前端和后台两个部分,组件设计是前端,是和用户之间面对面的。可以看到,我们把一些文本输入框、按钮控件、图片控件、文字显示控件等,按照一定的顺序在手机屏幕中进行排列。这样就给用户提供了操作的方便,这个前端也就是手机中打开APP看到的样子了。
如果说前端的组件设计提供了漂亮的皮囊,那么后台的程序就是提供了有趣的灵魂。点击窗口右上角的“逻辑设计”可以切换到后台的程序设计界面。在这里我们可以看到按钮1是“连接 / 断开”按钮,通过自定义组件SocketClient1,进行网络连接或端口。右边的三个是当APP接收到来自摄像头的图像时,经过自定义组件进行BASE64解码,并发送到图片框进行显示。当接收到文本信息时(文本信息以MSG这三个字符开头为标志),则显示在文本标签中。当按动按钮2发送消息的按钮时,则调用自定义组件的发送消息程序块。
这时候,我们可以点击顶端的“打包APK——显示二维码”菜单,程序会进行编译,编译结束后,会显示一个二维码。我们用手机中的微信扫一扫,然后用浏览器打开,就能下载到一个APK安装文件,接着会自动安装,安装完成后,我们就可以在手机的桌面上看到这个ESP32Cam的专用APP了。打开这个APP,就能进行前面第一步的ESP32Cam的连接测试了。
我们可以看到,有了APPInventor,制作一个手机APP都是积木化的编程操作了。所以说小学生也能制作自己的手机APP,这下你应该相信了吧。不过我们从这个程序设计界面可以看到,所有的复杂的程序代码,都已经并编译成自定义组件,编程一个个可以拖动拼接的程序积木块了。
- 打包自定义组件
有些人可能会好奇,究竟这样的程序块,后面隐藏着怎样复杂的程序代码呢?我们要怎样才能制作自己的自定义组件呢。前面程序中,负责网络连接的那个sockclient自定义组件就是由我自己编写代码,并打包完成的。
接下来,我记录一下自己是怎样制作和打包自定义模块的学习过程吧,怕有一天要用了,忘得一干二净了呢。这里很多都参考了下面博主“作业乃身外之物”的这篇博客
App Inventor插件开发(一)配置与测试_app inventor 插件 树树-CSDN博客
(一). 下载、安装、配置环境变量
想要制作和打包自己的自定义模块,需要在电脑中至少安装四个东西:
GIT 我下的是2.26.2
JDK 版本不要超过8,所以我下载的是7u79
ANT 我下的是1.9.14
appinventor 这个是好像是inventor的离线环境吧
note 我是用这个编辑插件的java代码的。
我的电脑是64位win7的(我怕这些软件版本低了,在高版本的Windows会不兼容出错),我会把相关的软件放在百度网盘中,需要的也可以自行去下载。
链接:https://pan.baidu.com/s/1pY9NsuAbPzORsm8Tm7knEQ?pwd=7tp9
把GIT安装在C盘默认目录中
JDK安装在C盘默认目录(我怕装在其他盘配置会出问题),装完后,要配置系统环境变量:
新建JAVA_HOME为安装目录如
C:\Program Files\Java\jdk1.7.0_79
新建CLASSPATH为
.;%JAVA_HOME%/lib/dt.jar;%JAVA_HOME%/lib/tools.jar
Path末尾添加(如果原来的末尾没有分号,要补上)
%JAVA_HOME%/bin;%JAVA_HOME%/jre/bin;
检验JAVA安装是否正确:cmd输入java -version,输出版本信息即为成功。
Ant环境变量配置
新建ANT_HOME 为安装(解压)位置如
C:\Program Files\ant\apache-ant-1.9.14
CLASSPATH末尾添加 ;%ANT_HOME%\lib;
Path中添加 ;%ANT_HOME%\bin;C:\Program Files\Git\cmd
检验:cmd输入ant,输出如下则为配置成功
把appinventor解压到D盘。
(二). 打包 aix 插件
我提供的安装程序中,有一个测试插件,把里面的cn文件夹解压到appinventor的安装文件夹下面的指定文件夹中。
解压出来的就是两个java后缀名的文件,可以用记事本打开查看。
大家千万要把文件的路径做对了,否则出错是没商量的。我们可以看到,这两个文件夹的路径中,src文件夹下面的路径,和这个 java文件的第一行中的路径是一致的。
我们在appinventor文件夹中,按住键盘的shift,用鼠标在空白的地方右击,选“在此处打开命令窗口”。
然后在打开的cmd命令窗口中,输入ant extensions,按回车。
程序就开始执行打包了,如果出现后面的画面,就说明打包成功了。我们可以在里面的显示的路径下面,找到了已经打包好的 aix 的文件了。
这样,就有了自己的自定义组件了,也就可以把他导入到网上的APP项目中进行使用了。我们在组件前端的编辑界面,选择窗口左侧的积木仓库的底部,选择导入自定义组件按钮,然后选择刚才打包得到的aix文件,这样这个socketclient自定义组件就会显示在仓库的底部了。我么可以把他们拖入到APP的界面中了进行使用了。
四. 编辑 java 程序代码
刚才展示的仅仅是自定义组件的打包过程而已,是已经提供了程序的代码,在此基础上完成打包的动作。
当然,你如果你会 java代码,可以用note这个小程序打开文件进行程序代码的编写了。这样,我们就可以编写专属于自己的自定义模块了。这就是开源设计的优势,要怎样的功能插件都可以自己编写、打包、上传、分享、使用。
百度网盘中下载到的是我两年前的JAVA程序代码,主要是对博主“作业乃身外之物”的原来的插件进行修改而成的。他原来的客户端插件中,客户端只有发送,没有从服务端接收的功能块。所以这次我着重修改、增加了客户端的接收部分。
这里有许多的知识点稍微提一下:
(1)在完成Socket连接后,创建一个线程,用于无限循环地监听,是否接收到来自服务器端的(ESP32Cam)的消息。
(2)如果接收到了新的消息(字符串或二进制数据),则转换成字符串,然后利用Message消息的方式,传递给Handle,用于修改UI的内容。这个是JAVA的运行机制,UI界面的一些属性修改,是放在主线程里进行的;而接收监听的子线程,是无法直修改UI的内容,需要把字符串传递给Handle进行修改。
(3)我们对接收到来自ESP32Cam的数据进行识别。接收到的数据无非只有两种,要么是字符串,要么是JPG图片。所以我们在这里做了个界定,ESP32Cam发送过来的字符串,都必须以“MSG”这三个字母为开头作为标志,APP接收到一组数据后,先检测是否MSG开头,如果是则由getMessage函数把字符发送到文本标签中显示。
JPG图片的数据比较大,所以我们采用分段发送(前一课的内容),每次发送1024个字节。JPG图片数据都是以0xFF 0xD8为开头,以0xFF 0xD9为结束。我们在接收数据的时候,会一组一组地接收,并把数据串接在一起。
(4)当图片数据结束时(检测到了结束标志),我们需要把图片的二进制数据byte数组,经过BASE64,转换成一个非常长的字符串,把这个字符串通过getJpg函数传递出来。
(5)经过我的这个自定义网络连接组件,可以负责APP与ESP32Cam之间的连接、数据传递收发。但是也存在一个问题,那就是我这个自定义组件接收到的摄像头图片,最后转化成了一个BASE64字符串。这个字符串是无法之间给图片控件显示的。
经过网上查找,我发现了上海的APPInventor服务器fun123.cn,可以提供BASE64字符串转换成图片的自定义组件。于是我就花了40大洋,注册了一个月的VIP用户,下载到了这个组件。
(7)其实网上也有人提供了一些其他的方法,就是当你接收到了图片数据后,把这些数据写入到手机的临时文件中(图片文件)。然后让图片控件去读取手机临时的图片文件并显示。这种方法是每收到一张图片,就要写一次的临时图片文件。这样频繁的存储操作是很低效的,也是我很反感的。
我虽然已经花钱得到了那个BASE64转图片的组件,但是我也看不到这个组件的源代码,我想这个组件应该不会使用存储临时文件这种拙劣的方法吧,否则怎么对得起我花的钱呢?
package cn.roger.socket;
import com.google.appinventor.components.annotations.*;
import com.google.appinventor.components.common.ComponentCategory;
import com.google.appinventor.components.runtime.*;
import com.google.appinventor.components.runtime.util.*;
import com.google.appinventor.components.runtime.errors.YailRuntimeError;
import android.graphics.drawable.GradientDrawable;
import android.graphics.Color;
import android.content.res.ColorStateList;
import android.view.View;
import android.graphics.drawable.RippleDrawable;
import android.graphics.drawable.Drawable;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.app.Activity;
import android.content.Context;
import android.view.Menu;
import android.widget.TextView;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.util.Base64;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
@DesignerComponent(version = 1,
description = "by Roger Young",
category = ComponentCategory.EXTENSION,
nonVisible = true,
iconName = "images/extension.png")
@SimpleObject(external = true)
public class SocketClient extends AndroidNonvisibleComponent {
Socket socket = null;
OutputStream ou = null;
String buffer = "";
String geted1;
Message msg;
final int CONNECT = 100001;
final int SENDMESSAGE = 100002;
final int CLOSE = 100003;
public SocketClient(ComponentContainer container) {
super(container.$form());
}
public Handler myHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
if(msg.what == 1){
GetMessage(msg.obj.toString());
}else if(msg.what == 2){
GetJpg(msg.obj.toString());
}
}
};
@SimpleFunction(description = "start")
public void closeConnect(){
if(socket != null){
try {
ou.close();
socket.close();
socket = null;
msg = myHandler.obtainMessage();
msg.what = 1; // 消息标识
msg.obj = "关闭";
myHandler.sendMessage(msg);
}catch (IOException e) {
msg = myHandler.obtainMessage();
msg.what = 1; // 消息标识
msg.obj = "未知错误";
myHandler.sendMessage(msg);
}
}else{
GetMessage("连接未创建!");
}
}
@SimpleFunction(description = "start")
public void sendMessage(String s){
if(socket != null){
try {
ou.write(s.getBytes("utf-8"));
ou.write("\n".getBytes("utf-8"));
ou.flush();
msg = myHandler.obtainMessage();
msg.what = 1; // 消息标识
msg.obj = "发送完毕";
myHandler.sendMessage(msg);
}catch (IOException e) {
msg = myHandler.obtainMessage();
msg.what = 1; // 消息标识
msg.obj = "未知错误";
myHandler.sendMessage(msg);
}
}else{
GetMessage("连接未创建!");
}
}
@SimpleFunction(description = "start")
public void connect(String ip, String port){
if(socket == null){
try {
int po = Integer.valueOf(port);
socket = new Socket();
msg = myHandler.obtainMessage();
msg.what = 1; // 消息标识
msg.obj = "开始连接";
myHandler.sendMessage(msg);
socket.connect(new InetSocketAddress(ip, po), 3000);
ou = socket.getOutputStream();
Thread mt = new Thread(){
public void run() {
try {
InputStream inst = null;
byte[] buf = new byte[1060];
byte[] buffer = new byte[30720];
String msgstr = null;
int len = 0;
int pdd = 0;
inst = socket.getInputStream();
while(true){
if(!socket.isInputShutdown()) {
buf = new byte[1060];
len = inst.read(buf);
if(buf[0] == 0x4D && buf[1] == 0x53 && buf[2] == 0x47){
msgstr = null;
msgstr = new String(buf, 0, len);
if(msgstr != null){
msg = myHandler.obtainMessage();
msg.what = 1; // 消息标识
msg.obj = msgstr;
myHandler.sendMessage(msg);
}
}else if((buf[0] & 0xFF)== 0xFF && (buf[1] & 0xFF) == 0xD8){
buffer = new byte[30720];
for(int i=0; i<1024; i++){
buffer[i]=buf[i];
}
pdd = 1024;
}else if((buf[len-2] & 0xFF) == 0xFF && (buf[len-1] & 0xFF) == 0xD9){
for(int i=0; i<len; i++){
buffer[pdd + i]=buf[i];
}
pdd += len;
//msgstr = String.format("jpg size %d", pdd);
String jpgstr = Base64.getEncoder().encodeToString(buffer);
msg = myHandler.obtainMessage();
msg.what = 2; // 消息标识
msg.obj = jpgstr;
myHandler.sendMessage(msg);
}else if(len == 1024){
for(int i=0; i<1024; i++){
buffer[pdd + i]=buf[i];
}
pdd += 1024;
}
}
}
} catch (IOException e) {
msg = myHandler.obtainMessage();
msg.what = 1; // 消息标识
msg.obj = "他好像不见了";
myHandler.sendMessage(msg);
try{socket.close();}catch(Exception e1){}
}
}
};
mt.start();
msg = myHandler.obtainMessage();
msg.obj = "连接成功";
myHandler.sendMessage(msg);
} catch (SocketTimeoutException aa) {
msg = myHandler.obtainMessage();
msg.what = 1; // 消息标识
msg.obj = "连接超时";
myHandler.sendMessage(msg);
socket = null;
} catch (IOException e) {
msg = myHandler.obtainMessage();
msg.what = 1; // 消息标识
msg.obj = "未知错误";
myHandler.sendMessage(msg);
socket = null;
}
}else{
GetMessage("连接已创建!");
}
}
@SimpleEvent
public void GetMessage(String s){
EventDispatcher.dispatchEvent(this, "GetMessage", "\n"+s);
}
@SimpleEvent
public void GetJpg(String jpgstr){
EventDispatcher.dispatchEvent(this, "GetJpg", "\n"+jpgstr);
}
}
这样几百行的代码,看起来是否有密集恐惧症呢,这些没有一定的基础,确实是读起来很费劲,写起来就更难了,出了BUG调试那就难上加难了。程序猿就是这样一点一点地扣的。
为什么我会修改这段代码呢?因为我以前用eclipse做过手机APP,这些代码其实都是一样一样的。我这段时间也修改了eclipse做的用于连接开发板的手机客户端APP做了修改,也在之前的一些篇章中拿出来用过的。其实也没什么秘密武器,到最后都一点一点的拿出来了。