外观
软件开发
开发环境
- 软件环境:VSCode+PlatformIO
- 开发语言:C/C++
API接入
为方便无服务器玩家开发,本项目采用接入在线API实现 - ASR服务:讯飞-语音听写(流式版)
- 大语言模型服务:讯飞-星火Spark4.0 Ultra
- TTS服务:万码云TTS-豆包
库
通过以下库完成本项目开发 - ArduinoJson:解析/生成Json文件
- ArduinoWebsockets:WebSockets通讯
- ESP32-audioI2S:通过URL获取音频播放
- ESPAsyncWebServer-esphome:页面配置
- TFT_eSPI:UI界面
- NTPClient:实时时间同步
函数定义
通过以下库完成本项目开发 - void handleWiFiConfig():管理WiFi配置(保存/读取)
- void loadWiFiConfig():加载WiFi配置文件
- void fetchWeather():从API获取天气数据
- String base64Encode(const uint8_t *data, size_t len):二进制数据Base64编码
- String base64EncodeUserInput(const String &userInput):用户输入Base64编码
- String getDate():获取当前系统时间
- String hmacSHA256(const String &key, const String &data):HMAC-SHA256加密
- String calculateDigest(const String &body):计算消息体摘要
- String calculateSignature(...):生成API请求签名
- String generateSpeechAuthURL():生成语音服务鉴权URL
- String generateChatAuthURL():生成聊天服务鉴权URL
- void sendHandshake():发送WebSocket握手请求
- void sendAudioData(...):发送音频流数据帧
- void startRecording():启动麦克风录音
- void stopRecording():停止麦克风录音
- void onSpeechMessage(...):处理语音服务响应
- void playTTS(String textToSpeak):文本转语音播放
- void Translation(...):执行文本翻译
- void sendChatRequest(...):发送聊天请求
- void onChatMessage(...):处理聊天服务响应
- void displayTask(...):显示屏刷新任务
- String removeNonUTF8(...):过滤非UTF8字符
- void setup():系统初始化
- void loop():主循环逻辑
- processChatResult():解析聊天结果
- processSpeechResult():解析语音结果
- void connectWebSocket():建立WebSocket连接
- void handleButtonPress(...):处理物理按钮事件
2.1 环境搭建
本项目的构建环境为VsCode、PlatformIO完成,当然也可以使用JetBrains CLion 2024、PlatfromIO或Arduino完成,根据个人喜好选择即可。在本教程中主要使用VsCode作为编译器开发
2.1.1 VsCode安装
方法1:官网下载
VsCode是一个强大的主流代码编辑器,也是完全免费使用的,我们可以在VsCode官网下载安装即可 https://code.visualstudio.com/insiders/
方法2:应用商店下载
这种方法更为简便,您可以使用Windows自带微软商店搜索并下载
2.1.2 PlatfromIO框架安装
PlatfromIO是一个更强大更专业的开发框架,支持ARM、FPGA、RISC-V以及8位单片机的开发,是一个嵌入式多平台开发工具,可以支持Arduino、ESPIDF、STM32Cube等多个固件的开发,且自定义程度较高、支持开源库的快速安装使用,非常方便。 在VsCode点击左侧插件图标,搜索安装PlatfromIO框架插件即可,使用ArduinoIDE也可直接用ArduinoIDE完成开发,但ArduinoIDE功能有限,修改库函数和板级配置困难,所以不推荐使用
2.1.3 程序下载说明
ESP8266模组主要使用TTL串口下载方式,准备一个USB转TTL或者DAP-Link即可
(1)安装CH340驱动
鼠标右键以管理员身份进行安装; 如果出现安装失败,那可能是你电脑已经有了该驱动,你可以尝试先点击卸载,再次安装即可。
2.2 流程设计
在开发之前,我们先要对整个程序的流程进行设计,确保知道我们的项目需要做什么
2.2.1 整体程序分析
对于本项目来说,我们应该实现表情显示与切换、时钟显示、天气信息显示和API对接功能并通过长按或短按按键完成交互,大致的程序流程如下。
当然,这仅局限于本教程,你也可以为你的小电视开发更多好玩有意思的功能
2.3 创建项目
2.3.1 构建项目框架
首先,我们新建一个PlatformIO项目,修改好项目名称,版型选择Espressif中的ESP32-S3-DevKitC-1-N8,使用Arduino固件然后点击创建。(版型的选择是随意的,主要是选对正确的芯片类型,因为ESP32S3有多种款式,我们使用的类型是ESP32,8M)
2.3.2 项目结构介绍
(1)项目文件夹
在项目文件夹下共有5个文件夹,这些文件夹我们在后面介绍。我们先来讲解该目录下最为重要的platformio.ini文件,这个文件是用于存放我们需要调用的第三方库和配置板级配置的文件,在这个文件中可以配置分区、配置处理器频率、固件类型等等 platformio.ini 默认的文件内容如下,platform指的是我们使用的平台,board指的是我们使用的版型,framework指的是我们使用的固件类型,完整的文件配置信息可以查看官方的说明文档 https://docs.platformio.org/en/latest/projectconf/index.html
(2).pio文件夹
.pio文件夹是用于存放我们依赖的第三方库文件以及构建后的项目固件及构建文件的地方,默认文件如下 build 用于存放构建文件,生成的固件也会在这个文件夹中 libdeps 当引入第三方库时才会生成该目录,在该目录下可以修改第三方库的源代码
(3)src文件夹
其他文件夹就不再讲解了,主要是用于分类和自行导入第三方库用的。 src文件夹是生成初始程序的文件夹,在新建的项目中会在这里生成main.cpp,这个就是我们的主程序了
2.3.3 程序结构介绍
这就是一个基本的Arduino程序结构,在程序最上面导入头文件和全局变量。 setup() 在这个方法中我们主要存放需要初始化的代码,在该方法中的所有程序仅在开机时执行一次 loop() 在这个方法在我们主要存放主代码,在该方法中的所有程序会进入死循环,无限次重复执行
2.4 驱动库
2.4.1 屏幕
TFT_eSPI 是一个用于 Arduino 和 ESP8266/ESP32 的图形库,主要用于驱动 TFT LCD 显示屏。它是一个高性能的库,具有良好的灵活性和兼容性,适用于多种不同的显示屏和驱动芯片。 主要特点 1.高性能: 使用 SPI 通信,可以快速地绘制图形和文本,适合需要流畅更新的应用。2.灵活性: 支持多种 TFT 显示屏,用户可以根据需要进行配置。3.丰富的功能: 提供了绘制点、线、圆、矩形、填充图形、文本显示等多种基本图形绘制功能。 支持位图显示,可以将图像加载到显示屏上。4.颜色支持: 支持 16 位颜色模式,允许丰富的图形和图像显示。
(1)TFT-eSPI驱动库导入
TFT_eSPI 是一个用于彩色 TFT 屏幕显示库,它支持使用 SPI 协议的显示屏,可以通过该库在屏幕上绘制各种形状、文本、图像等。TFT_eSPI 库的配置文件在 User_Setup.h 中,可以指定屏幕型号、通信引脚等。 这类SPI彩屏主要使用TFT-eSPI库开发完成,要使用TFT-eSPI库,我们可以直接在platformio.ini中最下面添加
lib_deps =
bodmer/TFT_eSPI @ ^2.5.43
1
2
2
然后我们点击左侧PlatformIO的图标,选择Dependencies,点击Update更新依赖
现在platformio会自动拉取第三方库的git仓库到项目
(2)驱动适配
因为我们使用的屏幕是240*198的异形屏,所以我们必须要修改库函数。
配置SPI连接
1.打开.pio/libdeps/nodemcuv2/TFT_eSPI文件夹2.打开该目录下的User_Setup.h头文件3.将配置修改如下
#define USER_SETUP_INFO "User_Setup"
#define ST7789_2_DRIVER // Minimal configuration option, define additional parameters below for this display
#define TFT_RGB_ORDER TFT_RGB // Colour order Red-Green-Blue
#define TFT_WIDTH 240 // ST7789 240 x 240 and 240 x 320
#define TFT_HEIGHT 240 // GC9A01 240 x 240
#define TFT_BL 38 // LED back-light control pin
#define TFT_BACKLIGHT_ON HIGH // Level to turn ON back-light (HIGH or LOW)
#define TFT_MOSI 11// In some display driver board, it might be written as "SDA" and so on.
#define TFT_SCLK 12
#define TFT_DC 47 // Data Command control pin
#define TFT_RST 48 // Reset pin (could connect to Arduino RESET pin)
#define LOAD_GLCD // Font 1. Original Adafruit 8 pixel font needs ~1820 bytes in FLASH
#define LOAD_FONT2 // Font 2. Small 16 pixel high font, needs ~3534 bytes in FLASH, 96 characters
#define LOAD_FONT4 // Font 4. Medium 26 pixel high font, needs ~5848 bytes in FLASH, 96 characters
#define LOAD_FONT6 // Font 6. Large 48 pixel font, needs ~2666 bytes in FLASH, only characters 1234567890:-.apm
#define LOAD_FONT7 // Font 7. 7 segment 48 pixel font, needs ~2438 bytes in FLASH, only characters 1234567890:-.
#define LOAD_FONT8 // Font 8. Large 75 pixel font needs ~3256 bytes in FLASH, only characters 1234567890:-.
#define LOAD_GFXFF // FreeFonts. Include access to the 48 Adafruit_GFX free fonts FF1 to FF48 and custom fonts
#define SMOOTH_FONT
#define SPI_FREQUENCY 80000000
#define SPI_READ_FREQUENCY 20000000
#define SPI_TOUCH_FREQUENCY 2500000
#define USE_HSPI_PORT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(3)屏幕代码初始化
在main.cpp中最顶部添加
#include <TFT_eSPI.h>
#include <SPI.h>
TFT_eSPI mylcd = TFT_eSPI();
1
2
3
2
3
在setup()中添加
mylcd.init();
mylcd.setRotation(0);
mylcd.fillScreen(TFT_BLACK);
1
2
3
2
3
2.4.2 NTP
NTPClient 是一个用于在 Arduino 和其他嵌入式设备上获取网络时间的库。它通过网络时间协议(NTP)从互联网的 NTP 服务器获取当前时间,适用于需要精确时间戳的项目,比如时钟、日志记录或定时器等。 主要特点
1.简单易用:
提供直观的 API,使得获取和管理时间变得容易。
2.支持时区:
可以设置时区偏移量,以获取本地时间。
3.定时更新:
支持定期更新时间,确保时钟保持准确。
(1)NTPClient库导入
lib_deps =
arduino-libraries/NTPClient @ ^3.2.1
1
2
2
然后我们重新加载
(2)NTP初始化
NTPClient 库是一个简单的 NTP 客户端库,可以从 NTP 服务器同步时间。在这个项目中,ESP8266 使用 NTPClient 获取当前时间,用于显示或其他时间相关的功能。 在main.cpp中最顶部添加
#include <NTPClient.h>
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "ntp1.aliyun.com", 8 * 3600, 60000);
1
2
3
2
3
2.4.3 Json格式提取
ArduinoJson 是一个用于在 Arduino 和其他嵌入式平台上处理 JSON 数据的轻量级库。它允许开发者轻松地解析、生成和操作 JSON 数据,非常适合用于处理来自网络请求的响应、配置文件或其他结构化数据。 主要特点
1.轻量级:
设计针对内存受限的环境,内存占用小。
2.易于使用:
提供直观的 API,简单的语法使得 JSON 的创建和解析变得方便。
3.支持动态和静态内存分配:
可以根据需求选择动态分配内存或使用静态数组,适应不同的使用场景。
4.文档生成:
支持生成和解析 JSON 文档,便于处理复杂的数据结构。
Json格式的读取与写入我们使用ArduinoJson库完成
(1)ArduinoJson库导入
lib_deps =
bblanchon/ArduinoJson @ ^7.2.0
1
2
2
然后我们重新加载
(2)ArduinoJson初始化
在main.cpp中最顶部添加
#include <ArduinoJson.h>
1
2.4.4 ESPWeb本地服务器
ESPAsyncWebServer 是一个为 ESP8266 和 ESP32 平台设计的异步 Web 服务器库。与传统的 Web 服务器库相比,它允许在处理 HTTP 请求时非阻塞地执行其他操作,适合于需要同时处理多个连接的应用。 主要特点
1.异步处理:
允许同时处理多个客户端连接,而不会阻塞主程序流。
2.简单的 API:
提供易于使用的 API,可以快速设置路由和处理 HTTP 请求。
3.支持多种 HTTP 方法:
支持 GET、POST、PUT、DELETE 等多种 HTTP 方法。
4.处理静态文件:
能够直接提供存储在 SPIFFS 或 LittleFS 文件系统中的静态文件(如HTML、CSS、JavaScript 文件等)。
5.WebSocket 支持:
支持 WebSocket,允许与客户端进行实时双向通信。
为了实现本地网页配网,我们需要一个Web本地服务器
(1)ESPAsyncWebServer库导入
lib_deps =
esphome/ESPAsyncWebServer-esphome @ ^3.2.2
1
2
2
然后我们重新加载
(2)ESPAsyncWebServer初始化
在main.cpp中最顶部添加
#include <ESP8266HTTPClient.h>
#include <ESPAsyncWebServer.h>
AsyncWebServer server(80);
1
2
3
2
3
2.4.5 FS文件系统
FS(文件系统)在微控制器和嵌入式开发中用于管理和存储文件。特别是在Arduino、ESP8266和ESP32等平台上,文件系统可以用来读取和写入数据,便于保存配置、网页文件和其他信息。 常见的文件系统
1.SPIFFS(SPI Flash File System):
设计用于闪存设备,适合于较小的存储需求。
支持文件读取、写入、删除等基本操作。
在ESP8266和ESP32上常用。
2.LittleFS:
提供更好的耐用性和性能,相比SPIFFS在特定场景下更为高效。
支持原子性写入和可变大小的文件,适合更复杂的应用。
(1)FS库导入
在main.cpp中最顶部添加
#include <FS.h>
1
2.4.6 WIFI网络
ESP8266WiFi 是一个专为 ESP8266 微控制器设计的库,用于处理 WiFi 连接。它提供了简单易用的 API,使得开发者可以方便地连接到 WiFi 网络、管理连接、发送和接收数据等。
(1)ESP8266WiFi库导入
在main.cpp中最顶部添加
#include <ESP8266WiFi.h>
1
2.4.7 WebSockets协议库
我们将通过这个库连接ASR和TTS的WebSockets服务器,实现实时数据发送与接收
(1)ArduinoWebsockets库导入
lib_deps =
gilmaimon/ArduinoWebsockets@^0.5.3
1
2
2
然后我们重新加载
(2)ArduinoWebsockets初始化
在main.cpp中最顶部添加
##include <ArduinoWebsockets.h>
WebsocketsClient wsSpeech; // 用于语音转文字
WebsocketsClient wsChat; // 用于大模型对话
1
2
3
2
3
2.4.8 I2S库
这个库可以获取网络URL音频数据并播放,在本项目中我们将用在TTS上实现语音播报
(1)ESP32-audioI2S库导入
lib_deps =
esphome/ESP32-audioI2S@^2.0.7
1
2
2
然后我们重新加载
(2)ArduinoWebsockets初始化
在main.cpp中最顶部添加
#include "Audio.h"
#include <driver/i2s.h>
Audio audio;
1
2
3
2
3
2.4.8 编码库
编码库将用于API鉴权方面的加密解密操作
(1)库导入
在main.cpp中最顶部添加
#include <mbedtls/md.h>
#include <mbedtls/base64.h>
#include <mbedtls/sha256.h>
1
2
3
2
3
2.5 图像库
2.5.1 分辨率
图像中我们尽可能统一分辨率
2.5.2 取模
取模的软件有很多,本文档中我们主要使用lcd-image-converter来完成图像和字库 附上截取完成的表情图片合集: https://drive.weixin.qq.com/s?k=AFIAGQdxAAY5NTKYhgAVIA5gYlAGw 项目创建 选择New Image创建一个图像取模项目 任意设定一个名称
图像导入 点击Image,选择Import,导入需要取模的图片
取模设置 点击Options,选择Conversion
点击Preset选择Color R5G6B5
选择Image,点击Block size选择16bit,再点击Show Preview即可查看到取模后的数值
右侧文本框内的就是我们需要的取模后的值了
2.5.2 存储
现在我们在src中新建一个image.cpp
const uint16_t 在此处自定义名称[] PROGMEM= {
//在此处粘贴取模后的数值
};
1
2
3
2
3
2.5.3 导入
#include "image.cpp"
1
2.6 程序开发
现在,你已经完成了所有准备工作,开始编写程序吧。 为方便理解,本项目教程的程序并没有进行性能和安全优化,如果有需求可以自行修改。
2.6.1 库的导入
这个项目旨在使用 ESP8266 芯片,通过 TFT_eSPI 库在屏幕上显示图像、字体以及其他信息,同时可以联网并使用 NTP 客户端进行时间同步,HTTPClient 用于访问网络数据,ArduinoJson 用于解析 JSON 数据,ESPAsyncWebServer 用于创建 Web 服务器。·#include <TFT_eSPI.h> // 引入 TFT_eSPI 库,用于控制 TFT 屏幕
// 显示相关库
#include <Arduino.h> // Arduino核心库
#include <TFT_eSPI.h> // TFT显示屏驱动库(适用于ESP32)
#include <SPI.h> // SPI通信协议库
// 网络通信相关库
#include <WiFi.h> // WiFi连接库
#include <HTTPClient.h> // HTTP客户端库(用于REST API请求)
#include <ArduinoJson.h> // JSON数据解析库
#include <ArduinoWebsockets.h>// WebSocket客户端库
// 时间相关库
#include <NTPClient.h> // 网络时间协议客户端库
#include <WiFiUdp.h> // UDP协议库(NTP所需)
// 加密安全相关库
#include <mbedtls/md.h> // 消息摘要算法(如SHA)
#include <mbedtls/base64.h> // Base64编解码
#include <mbedtls/sha256.h> // SHA256哈希算法
// 系统相关库
#include <time.h> // 时间处理库
#include <SPIFFS.h> // SPI Flash文件系统库(ESP32内置存储)
// 音频相关库
#include "Audio.h" // 音频解码播放库(支持多种格式)
#include <driver/i2s.h> // I2S音频总线驱动库
// Web服务器相关库
#include <ESPAsyncWebServer.h>// 异步Web服务器库
// 自定义文件
#include "image.cpp" // 自定义图像数据文件(可能包含位图数据)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2.6.2 全局变量与定义
在本项目程序中,我们需要用到很多全局变量,具体用处可以看注释
// WebSocket 命名空间声明
using namespace websockets;
// WiFi 配置文件路径(SPIFFS 存储)
const char *ssidFile = "/ssid.json";
// 异步Web服务器实例(端口80)
AsyncWebServer server(80);
// 天气API基础URL(需配合API密钥使用)
const char *weatherAPI = "http://api.seniverse.com/v3/weather/daily.json?key=";
////////////////////////////
// 全局状态变量
////////////////////////////
int weatherpic = 0; // 天气图标资源索引
String temperature = ""; // 温度数值
String humidity = ""; // 湿度数值
String weather = ""; // 天气描述文本
// 大模型对话相关
String chatAggregated = ""; // 累积的对话回复内容
unsigned long lastChatMsgTime = 0; // 最后接收消息时间戳
bool chatFinalized = false; // 对话是否完成标志
// 状态机控制(0-3为显示界面状态)
int currentState = 3; // 当前显示状态:0-默认 1-语音输入 2-机器人对话 3-时间天气
int lastState = 0; // 前次显示状态(用于状态恢复)
// 天气参数
String cityname = ""; // 目标城市名称
String weatherapi = ""; // 完整天气API地址
static bool initweather = false; // 天气初始化完成标志
////////////////////////////
// 网络配置(需填写)
////////////////////////////
const char *ssid = "TV-PRO"; // 默认WiFi SSID
const char *password = ""; // WiFi密码(示例保留空)
////////////////////////////
// 多线程显示控制
////////////////////////////
TaskHandle_t displayTaskHandle = NULL; // 显示任务句柄
volatile bool isTalkingDisplayActive = false; // 语音输入动画激活标志
////////////////////////////
// 硬件接口定义
////////////////////////////
#define BUTTON_MID 21 // 中键GPIO(功能选择)
#define BUTTON_LEFT 39 // 左键GPIO(状态切换)
#define BUTTON_RIGHT 40 // 右键GPIO(状态切换)
int BTNow = 0; // 当前按键状态缓存
TFT_eSPI mylcd = TFT_eSPI(); // TFT显示屏实例
// INMP441麦克风I2S参数(16KHz采样率,单声道)
#define INMP441_WS 4 // 字选择线GPIO
#define INMP441_SCK 5 // 时钟线GPIO
#define INMP441_SD 6 // 数据线GPIO
#define SAMPLE_RATE 16000 // 音频采样率
#define CHANNELS 1 // 音频通道数
#define BIT_DEPTH 16 // 采样位深
#define FRAME_SIZE 1280 // 音频帧尺寸(16000Hz * 40ms = 640 samples, 640*2 bytes=1280B)
// MAX98357音频输出I2S参数
#define I2S_BCLK 15 // 位时钟GPIO
#define I2S_LRC 16 // 左右声道时钟GPIO
#define I2S_DOUT 7 // 数据输出GPIO
////////////////////////////
// 讯飞API配置(需填写)
////////////////////////////
const char *hostName = "itrans.xfyun.cn"; // 语音平台域名
const char *urlPath = "/v2/its"; // 接口路径
const int httpsPort = 443; // HTTPS端口
String apiKey = ""; // 平台API Key(需申请)
String apiSecret = ""; // 平台API Secret(需申请)
String appId = ""; // 平台应用ID(需申请)
// 科大讯飞(语音转文字)API相关
const char *speechHost = "iat-api.xfyun.cn";
const char *speechPath = "/v2/iat";
// 大模型对话 API(spark接口)相关
const char *chatHost = "spark-api.xf-yun.com";
const char *chatPath = "/v4.0/chat";
// 文字转语音(TTS)API相关
const char *ttsApiUrl = "https://wcode.net/api/audio/gpt/text-to-audio/v3/transcription";
String ttsApiKey = "";
int bot=0;
// 全局变量定义
volatile bool minuteTimerExpired = false; // 定时器触发标志
////////////////////////////
// 全局对象及变量
////////////////////////////
WiFiUDP udp;
NTPClient timeClient(udp, "pool.ntp.org", 0, 60000);
Audio audio;
WebsocketsClient wsSpeech; // 用于语音转文字
WebsocketsClient wsChat; // 用于大模型对话
// 状态标记及结果存储
volatile bool isRecording = false; // 当前是否正在录音
volatile bool speechFinished = false; // 语音识别结果是否返回(结束帧)
volatile bool chatFinished = false; // 大模型对话回复是否返回
String speechText = ""; // 语音识别拼接后的文本
String chatResponse = ""; // 大模型回复文本
// 计时变量(音频发送)
unsigned long startTime = 0;
unsigned long lastSendTime = 0;
unsigned long globalEpochTime = 0;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
2.6.3 初始化
首先,我们先完成一些初始化设定
void setup() {
// 初始化TFT显示屏
mylcd.init(); // 初始化显示驱动
mylcd.setRotation(0); // 设置屏幕方向(0-3对应0°~270°)
mylcd.fillScreen(TFT_BLACK); // 清屏为黑色背景
mylcd.setTextSize(2); // 设置默认字体大小为2
// 显示启动LOGO(位置X60,Y0,尺寸120x106像素)
mylcd.pushImage(60, 0, 120, 106, openlogo);
// 显示热点信息(位置Y轴120/150)
mylcd.drawString("WIFI: TV-PRO", 0, 120, 2);
mylcd.drawString("URL: 192.168.4.1", 0, 150, 2);
// 初始化时间同步标志
minuteTimerExpired = true; // 强制首次时间更新
// 初始化串口调试(波特率115200)
Serial.begin(115200);
mylcd.setTextSize(1); // 设置小号字体
// 内存监控任务(示例中被注释,需要时启用)
//xTaskCreatePinnedToCore(memoryMonitor, "Memory", 2048, NULL, 1, NULL, 0);
// 显示网络连接状态
mylcd.drawString("wait wifi connect...", 0, 200, 2);
// 初始化按键引脚(启用内部上拉电阻)
pinMode(BUTTON_MID, INPUT_PULLUP); // 中键
pinMode(BUTTON_LEFT, INPUT_PULLUP); // 左键
pinMode(BUTTON_RIGHT, INPUT_PULLUP); // 右键
// 启动WiFi热点模式(默认SSID/TV-PRO)
WiFi.softAP(ssid, password);
Serial.println("热点已启动");
Serial.print("访问地址: ");
Serial.println(WiFi.softAPIP()); // 输出AP的IP地址
// 尝试加载已保存的WiFi配置
loadWiFiConfig(); // 从SPIFFS读取ssid.json
// 网络连接状态判断
if (WiFi.status() != WL_CONNECTED) {
Serial.println("Starting captive portal...");
handleWiFiConfig(); // 启动网页配网界面(阻塞式)
} else {
handleWiFiConfig(); // 仍启动配网界面(可后台运行)
Serial.println("WiFi connected");
// 显示NTP连接状态
mylcd.drawString("wait NTP connect...", 0, 220, 2);
timeClient.begin(); // 启动NTP客户端
timeClient.update(); // 强制首次时间同步
}
delay(5000); // 等待系统稳定(可能阻塞关键初始化)
// 获取天气数据
fetchWeather(); // 调用天气API
// 输出当前时间(RFC1123格式)
String rfc1123Time = getDate(); // 获取格式化时间
Serial.println("当前时间: " + rfc1123Time);
// 配置I2S音频输入(INMP441麦克风)
i2s_driver_install(I2S_NUM_1, &i2sIn_config, 0, NULL); // 安装I2S驱动
i2s_set_pin(I2S_NUM_1, &inmp441_pin_config); // 设置GPIO映射
i2s_zero_dma_buffer(I2S_NUM_1); // 清空DMA缓存
// 初始化音频输出(MAX98357模块)
audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT); // 配置I2S引脚
audio.setVolume(100); // 设置最大音量(0-100)
// 创建显示任务到核心1(避免与主循环冲突)
xTaskCreatePinnedToCore(displayTask, // 任务函数
"DisplayTask", // 任务名称
10000, // 堆栈大小(字节)
NULL,
1, // 优先级(1-24,越高越优先)
&displayTaskHandle,
1); // 核心编号(0或1)
// 调试输出关键配置参数(存在安全风险!)
Serial.println(ttsApiKey); // 语音合成密钥
Serial.println(apiSecret); // API密钥
Serial.println(appId); // 应用ID
Serial.println(apiKey); // 主API密钥
Serial.println(cityname); // 当前城市
Serial.println(weatherapi); // 天气API地址
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
2.6.5 天气
用于从网络 API 获取天气数据,并根据当前的天气状况在屏幕上显示相应的图标和数据(温度、湿度等)。 天气的程序和Bilibili的逻辑大致相似,都是先GET请求,再截取返回的Json值,然后做个简单的判断再显示即可 创建方法
void fetchWeather()
{ // 天气捕捉
if (initweather == false)
{
// mylcd.fillScreen(TFT_BLACK);
if (WiFi.status() == WL_CONNECTED)
{
WiFiClient client3;
HTTPClient http3;
if (http3.begin(client3, weatherAPI + weatherapi + "&location=" + cityname + "&language=zh-Hans&unit=c&start=0&days=1"))
{
int httpCode = http3.GET();
if (httpCode > 0)
{
String payload = http3.getString();
Serial.println("JSON Response:");
Serial.println(payload);
DynamicJsonDocument doc(2048);
deserializeJson(doc, payload);
String temperature2 = doc["results"][0]["daily"][0]["high"];
String humidity2 = doc["results"][0]["daily"][0]["humidity"];
String weathe2r = doc["results"][0]["daily"][0]["text_day"];
temperature = temperature2;
humidity = humidity2;
weather = weathe2r;
initweather = true;
Serial.print("Data received: ");
Serial.println(temperature);
Serial.println(humidity);
Serial.println(weather);
}
else
{
Serial.printf("HTTP GET request failed, error: %s\n", http3.errorToString(httpCode).c_str());
}
http3.end();
}
else
{
Serial.println("Failed to connect to server");
}
// mylcd.fillScreen(TFT_BLACK);
}
}
if (weather == "阴" || weather == "多云")
{
weatherpic=0;
}
else if (weather == "小雨" || weather == "大雨" || weather == "暴雨" || weather == "雨")
{
weatherpic=1;
}
else if (weather == "晴")
{
weatherpic=2;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
2.6.6 Web服务器
用于处理 ESP8266 的 Web 服务器的配置,通过 HTTP 请求管理 WiFi 和其他参数的设置。它加载 HTML 页面显示给用户,并接收 WiFi 信息、B站 UID、城市和 API 的参数。 在程序中,ESP8266通过从FS文件系统读取HTML网页,然后将其挂载到自身的本地网络端口上,这样用户就可以通过连接热点进入192.168.4.1进入HTML配网网页中,用户提交的form会以POST方式返回给ESP8266。 为了方便用户在重启后避免重复操作,还需要将form信息以JSON格式保存到FS文件系统中,以便下次开机直接读取,从而避免重复繁琐配网操作。 随后进入WIFI连接WiFi.begin方法输入WIFI名称和密码即可。 创建方法
前面我们在data目录下创建的index.html页面作为我们的配置页面,这里我们通过页面路由器将FS文件系统中的网页推送到页面
void handleWiFiConfig()
{
server.on("/connect", HTTP_POST, [](AsyncWebServerRequest *request)
{
// 获取POST参数:ssid、pass、uid、city、api
String ssid = request->getParam("ssid", true)->value();
String pass = request->getParam("pass", true)->value();
String ttsapikey = request->getParam("ttsapikey", true)->value();
String apisecret = request->getParam("apisecret", true)->value();
String apikey = request->getParam("apikey", true)->value();
String appid = request->getParam("appid", true)->value();
String city = request->getParam("city", true)->value();
String api = request->getParam("api", true)->value();
// 打印接收到的参数
Serial.println(ssid);
Serial.println(pass);
// 保存WiFi信息到JSON文件
DynamicJsonDocument doc(2048);
doc["ssid"] = ssid;
doc["pass"] = pass;
doc["ttsapikey"] = ttsapikey;
doc["apisecret"] = apisecret;
doc["apikey"] = apikey;
doc["appid"] = appid;
doc["city"] = city;
doc["api"] = api;
fs::File file = SPIFFS.open(ssidFile, "w"); // 打开文件进行写入
if (file) {
serializeJson(doc, file); // 将JSON内容写入文件
file.close(); // 关闭文件
}
// 更新全局变量
ttsApiKey = ttsapikey;
apiSecret = apisecret;
appId = appid;
apiKey = apikey;
cityname = city;
weatherapi = api;
// 开始连接WiFi
WiFi.begin(ssid.c_str(), pass.c_str());
// 发送HTML响应,告知用户正在连接
request->send(200, "text/html", "<h1>Connecting...</h1>"); });
server.on("/setting.html", HTTP_GET, [](AsyncWebServerRequest *request)
{
// 检查SPIFFS文件系统中是否存在index.html文件
if (SPIFFS.exists("/setting.html")) {
fs::File file = SPIFFS.open("/setting.html", "r"); // 打开index.html文件
if (file) {
size_t fileSize = file.size(); // 获取文件大小
String fileContent;
// 逐字节读取文件内容
while (file.available()) {
fileContent += (char)file.read();
}
file.close(); // 关闭文件
// 返回HTML内容
request->send(200, "text/html", fileContent);
return;
}
}
// 如果文件不存在,返回404错误
request->send(404, "text/plain", "File Not Found"); });
// 启动服务器
server.begin();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
2.6.7 WIFI自动连接
在前面提到如何通过WIFI配网并以JSON的格式保存到FS文件系统,那么在这里读取配置信息就相对容易了,直接读取JSON格式的数据,然后启动WIFI连接的步骤即可 主要功能是从 SPIFFS 文件系统中读取 WiFi 配置信息,尝试连接到 WiFi。如果连接失败,它会启动强制门户,以便用户可以输入新的 WiFi 信息。这个过程确保设备能够正常连接到网络,从而进行后续的数据获取和功能实现。 创建方法
void loadWiFiConfig()
{
if (SPIFFS.begin())
{
fs::File file = SPIFFS.open(ssidFile, "r");
if (file)
{
DynamicJsonDocument doc(2048);
DeserializationError error = deserializeJson(doc, file);
if (!error)
{
String ssid = doc["ssid"];
String pass = doc["pass"];
String appid = doc["appid"];
String apikey = doc["apikey"];
String apisecret = doc["apisecret"];
String ttsapikey = doc["ttsapikey"];
String city = doc["city"];
String api = doc["api"];
// 更新全局变量
ttsApiKey = ttsapikey;
apiSecret = apisecret;
appId = appid;
apiKey = apikey;
cityname = city;
weatherapi = api;
WiFi.begin(ssid.c_str(), pass.c_str());
// 尝试连接WiFi,最多等待10秒
unsigned long startAttemptTime = millis();
while (WiFi.status() != WL_CONNECTED && millis() - startAttemptTime < 5000)
{
delay(500);
}
// 如果连接失败,打印状态
if (WiFi.status() != WL_CONNECTED)
{
Serial.println("WiFi connection failed, starting captive portal...");
handleWiFiConfig();
}
else
{
Serial.println("WiFi connected");
}
}
file.close();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
2.6.8 鉴权函数
1.Base64编码
String
{
if (len == 0 || data == nullptr)
{
Serial.println("Base64编码错误:无数据");
return "";
}
size_t outputLen = 0;
size_t bufSize = ((len + 2) / 3) * 4 + 1;
char *buf = (char *)malloc(bufSize);
if (!buf)
return "";
int ret = mbedtls_base64_encode((unsigned char *)buf, bufSize, &outputLen, data, len);
if (ret != 0)
{
free(buf);
return "";
}
String encoded = String(buf);
free(buf);
return encoded;
}
String base64EncodeUserInput(const String &userInput)
{
// 将String转换为uint8_t数组
const uint8_t *data = reinterpret_cast<const uint8_t *>(userInput.c_str());
size_t len = userInput.length() * sizeof(char); // 计算字节长度
return base64Encode(data, len);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
RFC1123时间
String getDate()
{
// ntpClient.getEpochTime() 返回当前秒数(UTC)
time_t epochTime = timeClient.getEpochTime();
struct tm *ptm = gmtime(&epochTime); // 转换为 GMT 时间
char timeString[40];
// 格式化为 RFC1123 格式,例如 "Wed, 20 Nov 2019 03:14:25 GMT"
strftime(timeString, sizeof(timeString), "%a, %d %b %Y %H:%M:%S GMT", ptm);
return String(timeString);
}
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
HMAC-SHA256
String hmacSHA256(const String &key, const String &data)
{
unsigned char hmacResult[32];
mbedtls_md_context_t ctx;
mbedtls_md_init(&ctx);
mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 1);
mbedtls_md_hmac_starts(&ctx, (const unsigned char *)key.c_str(), key.length());
mbedtls_md_hmac_update(&ctx, (const unsigned char *)data.c_str(), data.length());
mbedtls_md_hmac_finish(&ctx, hmacResult);
mbedtls_md_free(&ctx);
size_t outLen;
unsigned char base64Result[64];
mbedtls_base64_encode(base64Result, sizeof(base64Result), &outLen, hmacResult, sizeof(hmacResult));
return String((char *)base64Result);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Digest计算
// 计算请求 body 的 SHA256 并 Base64 编码,结果形如 "SHA-256=xxxxx"
String calculateDigest(const String &body)
{
unsigned char hash[32];
// 使用 mbedtls 计算 SHA256
mbedtls_sha256_context sha_ctx;
mbedtls_sha256_init(&sha_ctx);
mbedtls_sha256_starts_ret(&sha_ctx, 0); // 0 表示 SHA256(而非 SHA224)
mbedtls_sha256_update_ret(&sha_ctx, (const unsigned char *)body.c_str(), body.length());
mbedtls_sha256_finish_ret(&sha_ctx, hash);
mbedtls_sha256_free(&sha_ctx);
// Base64编码
unsigned char base64Digest[64];
size_t olen = 0;
mbedtls_base64_encode(base64Digest, sizeof(base64Digest), &olen, hash, sizeof(hash));
String result = "SHA-256=";
result += String((char *)base64Digest);
return result;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
计算Signature签名
// 使用 HMAC-SHA256 签名并 Base64 编码
String calculateSignature(const String &signatureOrigin, const char *secret)
{
unsigned char hmacResult[32];
const mbedtls_md_info_t *md_info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md_context_t ctx;
mbedtls_md_init(&ctx);
mbedtls_md_setup(&ctx, md_info, 1);
mbedtls_md_hmac_starts(&ctx, (const unsigned char *)secret, strlen(secret));
mbedtls_md_hmac_update(&ctx, (const unsigned char *)signatureOrigin.c_str(), signatureOrigin.length());
mbedtls_md_hmac_finish(&ctx, hmacResult);
mbedtls_md_free(&ctx);
// Base64 编码
unsigned char base64Signature[64];
size_t sigLen = 0;
mbedtls_base64_encode(base64Signature, sizeof(base64Signature), &sigLen, hmacResult, sizeof(hmacResult));
return String((char *)base64Signature);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
生成ASR鉴权URL
// 生成科大讯飞语音转文字的鉴权URL
String wsSpeechURL = "";
String generateSpeechAuthURL()
{
String date = getDate();
if (date == "")
return "";
String tmp = "host: " + String(speechHost) + "\n";
tmp += "date: " + date + "\n";
tmp += "GET " + String(speechPath) + " HTTP/1.1";
String signature = hmacSHA256(apiSecret, tmp);
String authOrigin = "api_key=\"" + String(apiKey) + "\", algorithm=\"hmac-sha256\", headers=\"host date request-line\", signature=\"" + signature + "\"";
unsigned char authBase64[256] = {0};
size_t authLen = 0;
int ret = mbedtls_base64_encode(authBase64, sizeof(authBase64) - 1, &authLen, (const unsigned char *)authOrigin.c_str(), authOrigin.length());
if (ret != 0)
return "";
String authorization = String((char *)authBase64);
String encodedDate = "";
for (int i = 0; i < date.length(); i++)
{
char c = date.charAt(i);
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~')
{
encodedDate += c;
}
else if (c == ' ')
{
encodedDate += "+";
}
else if (c == ',')
{
encodedDate += "%2C";
}
else if (c == ':')
{
encodedDate += "%3A";
}
else
{
encodedDate += "%" + String(c, HEX);
}
}
String url = "ws://" + String(speechHost) + String(speechPath) +
"?authorization=" + authorization +
"&date=" + encodedDate +
"&host=" + speechHost;
wsSpeechURL = url;
return url;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
生成大模型鉴权URL
// 生成大模型对话的鉴权URL
String wsChatURL = "";
String generateChatAuthURL()
{
String date = getDate();
if (date == "")
return "";
String tmp = "host: " + String(chatHost) + "\n";
tmp += "date: " + date + "\n";
tmp += "GET " + String(chatPath) + " HTTP/1.1";
String signature = hmacSHA256(apiSecret, tmp);
String authOrigin = "api_key=\"" + String(apiKey) + "\", algorithm=\"hmac-sha256\", headers=\"host date request-line\", signature=\"" + signature + "\"";
unsigned char authBase64[256] = {0};
size_t authLen = 0;
int ret = mbedtls_base64_encode(authBase64, sizeof(authBase64) - 1, &authLen, (const unsigned char *)authOrigin.c_str(), authOrigin.length());
if (ret != 0)
return "";
String authorization = String((char *)authBase64);
String encodedDate = "";
for (int i = 0; i < date.length(); i++)
{
char c = date.charAt(i);
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~')
{
encodedDate += c;
}
else if (c == ' ')
{
encodedDate += "+";
}
else if (c == ',')
{
encodedDate += "%2C";
}
else if (c == ':')
{
encodedDate += "%3A";
}
else
{
encodedDate += "%" + String(c, HEX);
}
}
String url = "ws://" + String(chatHost) + String(chatPath) +
"?authorization=" + authorization +
"&date=" + encodedDate +
"&host=" + chatHost;
wsChatURL = url;
return url;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
2.6.9 ASR语音转文字
配置I2S
////////////////////////////
// 语音转文字相关函数(inmp441采集、数据发送及WebSocket回调)
////////////////////////////
// 配置 I2S 接收(麦克风)
i2s_config_t i2sIn_config = {
.mode = static_cast<i2s_mode_t>(I2S_MODE_MASTER | I2S_MODE_RX),
.sample_rate = SAMPLE_RATE,
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
.communication_format = I2S_COMM_FORMAT_I2S_MSB,
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = 4,
.dma_buf_len = 512};
const i2s_pin_config_t inmp441_pin_config = {
.bck_io_num = INMP441_SCK,
.ws_io_num = INMP441_WS,
.data_out_num = I2S_PIN_NO_CHANGE,
.data_in_num = INMP441_SD};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
录音
void startRecording()
{
Serial.println("开始录音...");
isRecording = true;
startTime = millis();
sendAudioData(true); // 第一帧 status = 0
}
1
2
3
4
5
6
7
2
3
4
5
6
7
结束录音
void stopRecording()
{
isRecording = false;
DynamicJsonDocument jsonDoc(2048);
jsonDoc["data"]["status"] = 2; // 结束传输
char buf[128];
serializeJson(jsonDoc, buf);
wsSpeech.send(buf);
Serial.println("录音结束,已发送结束信号");
}
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
发送ASR握手包
// 第一次握手
void sendHandshake()
{
currentState=1;
DynamicJsonDocument jsonDoc(2048);
jsonDoc["common"]["app_id"] = appId;
jsonDoc["business"]["language"] = "zh_cn";
jsonDoc["business"]["domain"] = "iat";
jsonDoc["business"]["accent"] = "mandarin";
jsonDoc["business"]["vad_eos"] = 3000;
jsonDoc["data"]["status"] = 0;
jsonDoc["data"]["format"] = "audio/L16;rate=16000";
jsonDoc["data"]["encoding"] = "raw";
char buf[512];
serializeJson(jsonDoc, buf);
wsSpeech.send(buf);
Serial.println("已发送语音握手数据");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
发送ASR数据包
void sendAudioData(bool firstFrame = false)
{
static uint8_t buffer[FRAME_SIZE]; // 音频数据缓冲区
size_t bytesRead = 0;
static unsigned long lastSendTime = 0;
unsigned long currentMillis = millis();
// 每40ms发送一次音频
if (currentMillis - lastSendTime < 40)
{
return; // 如果间隔不到40ms,不发送数据
}
lastSendTime = currentMillis; // 更新发送时间
// 读取 I2S 音频数据
esp_err_t result = i2s_read(I2S_NUM_1, buffer, FRAME_SIZE, &bytesRead, portMAX_DELAY);
if (result != ESP_OK || bytesRead == 0)
{
Serial.println("I2S Read Failed or No Data!");
return;
}
// Base64 编码
String base64Audio = base64Encode(buffer, bytesRead);
if (base64Audio.length() == 0)
{
Serial.println("Base64 Encoding Failed!");
return;
}
// 发送 JSON 数据
DynamicJsonDocument jsonDoc(2048);
jsonDoc["data"]["status"] = firstFrame ? 0 : 1; // 第一帧 status = 0,其他帧 status = 1
jsonDoc["data"]["format"] = "audio/L16;rate=16000";
jsonDoc["data"]["encoding"] = "raw";
jsonDoc["data"]["audio"] = base64Audio; // 确保 Base64 编码成功
char jsonBuffer[2048];
serializeJson(jsonDoc, jsonBuffer);
wsSpeech.send(jsonBuffer); // 发送音频数据
Serial.printf("Sent %d bytes, status: %d\n", bytesRead, firstFrame ? 0 : 1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
ASR数据回传
// 语音WebSocket回调:解析返回的 JSON 并拼接文字
void onSpeechMessage(WebsocketsMessage message)
{
Serial.println("语音服务器返回:" + message.data());
DynamicJsonDocument doc(2048);
DeserializationError err = deserializeJson(doc, message.data());
if (err)
{
Serial.print("语音JSON解析错误:");
Serial.println(err.c_str());
return;
}
String tempText = "";
if (doc.containsKey("data") && doc["data"].containsKey("result") && doc["data"]["result"].containsKey("ws"))
{
for (JsonObject wsObj : doc["data"]["result"]["ws"].as<JsonArray>())
{
for (JsonObject cwObj : wsObj["cw"].as<JsonArray>())
{
tempText += cwObj["w"].as<String>() + " ";
}
}
tempText.trim();
speechText = tempText;
Serial.println("识别结果:" + speechText);
int status = doc["data"]["status"];
if(speechText.length()>0) {
speechFinished = true;
bot=1;
minuteTimerExpired= false;
}else{
mylcd.fillScreen(TFT_BLACK);
mylcd.setTextSize(2);
minuteTimerExpired= true;
speechFinished = false;
speechText = "";
currentState=3;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
2.6.10 TTS文字转语音
//////////////////////////
// 文字转语音及播放函数(TTS via max98357)
////////////////////////////
void playTTS(String textToSpeak)
{
HTTPClient http2;
DynamicJsonDocument doc(2048);
doc["model"] = "cosyvoice-v2";
doc["text"] = textToSpeak;
String postData;
serializeJson(doc, postData);
int maxRetries = 5; // 最多重试 5 次
int retryDelay = 2000; // 每次重试间隔 2 秒
int attempt = 0;
int httpCode;
String payload;
while (attempt < maxRetries)
{
Serial.println("发送TTS请求 (尝试 " + String(attempt + 1) + " / " + String(maxRetries) + "):" + postData);
http2.begin(ttsApiUrl);
http2.addHeader("Content-Type", "application/json");
http2.addHeader("Authorization", String("Bearer ") + ttsApiKey);
httpCode = http2.POST(postData);
if (httpCode == HTTP_CODE_OK)
{
payload = http2.getString();
Serial.println("TTS响应: " + payload);
break; // 请求成功,跳出循环
}
else
{
Serial.println("TTS请求失败,错误码:" + String(httpCode));
attempt++;
if (attempt < maxRetries)
{
Serial.println("等待 " + String(retryDelay / 1000) + " 秒后重试...");
delay(retryDelay);
}
}
http2.end();
}
if (httpCode != HTTP_CODE_OK)
{
Serial.println("TTS请求多次失败,放弃!");
mylcd.fillScreen(TFT_BLACK);
mylcd.setTextSize(2);
return;
}
// 解析 JSON 并获取音频 URL
DynamicJsonDocument docResp(2048);
DeserializationError err = deserializeJson(docResp, payload);
if (err)
{
Serial.print("TTS JSON解析错误:");
Serial.println(err.c_str());
mylcd.fillScreen(TFT_BLACK);
mylcd.setTextSize(2);
return;
}
String audioUrl;
for (int i = 0; i < 10; i++) // 最多等待 10 次
{
audioUrl = docResp["data"]["result"]["audio_file_temp_url"].as<String>();
if (!audioUrl.isEmpty())
break;
delay(1000);
Serial.println("等待音频生成中...");
}
if (!audioUrl.isEmpty())
{
Serial.println("播放音频URL: " + audioUrl);
wsSpeech.close();
wsChat.close();
delay(100);
audio.connecttohost(audioUrl.c_str());
mylcd.fillScreen(TFT_BLACK);
mylcd.setTextSize(2);
currentState=3;
}
else
{
Serial.println("TTS生成失败,未获取到音频URL!");
}
mylcd.fillScreen(TFT_BLACK);
mylcd.setTextSize(2);
minuteTimerExpired = true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
2.6.11 翻译API
////////////////////////////
// 翻译API
////////////////////////////
void Translation(const String &userInput)
{
// 获取格式化时间
String rfc1123Time = getDate();
Serial.println("当前时间: " + rfc1123Time);
// 使用示例:
String encoded = base64EncodeUserInput(userInput);
// 构造请求 JSON 数据
String requestBody="";
String requestBodyCN = "{\"common\":{\"app_id\":\"" + String(appId) + "\"},"
"\"business\":{\"from\":\"cn\",\"to\":\"en\"},"
"\"data\":{\"text\":\"" +
encoded + "\"}}";
String requestBodyEN = "{\"common\":{\"app_id\":\"" + String(appId) + "\"},"
"\"business\":{\"from\":\"en\",\"to\":\"cn\"},"
"\"data\":{\"text\":\"" +
encoded + "\"}}";
if(BTNow==3){
requestBody=requestBodyCN;
}else{
requestBody=requestBodyEN;
}
Serial.println("请求体: " + requestBody);
// 计算 body 的 digest 值
String digest = calculateDigest(requestBody);
Serial.println("Digest: " + digest);
// 构造 signature 原始字符串(注意换行符及空格需严格按照接口要求)
String signatureOrigin = "host: " + String(hostName) + "\n" +
"date: " + rfc1123Time + "\n" +
"POST " + String(urlPath) + " HTTP/1.1" + "\n" +
"digest: " + digest;
Serial.println("Signature Origin: " + signatureOrigin);
// 使用 apiSecret 对 signatureOrigin 进行 HMAC-SHA256 签名后 Base64 编码
String signature = calculateSignature(signatureOrigin, apiSecret.c_str());
Serial.println("Signature: " + signature);
// 构造 Authorization 请求头
String authorization = "api_key=\"" + String(apiKey) + "\", algorithm=\"hmac-sha256\", headers=\"host date request-line digest\", signature=\"" + signature + "\"";
Serial.println("Authorization: " + authorization);
// 连接 HTTPS 服务端
WiFiClientSecure client;
client.setInsecure(); // 若没有证书,可先禁用验证,正式使用时请设置证书
Serial.print("连接到主机: ");
Serial.println(hostName);
if (!client.connect(hostName, httpsPort))
{
Serial.println("连接失败");
return;
}
// 构造 HTTP 请求
String httpRequest = String("POST ") + urlPath + " HTTP/1.1\r\n" +
"Host: " + String(hostName) + "\r\n" +
"Content-Type: application/json\r\n" +
"Accept: application/json,version=1.0\r\n" +
"Date: " + rfc1123Time + "\r\n" +
"Digest: " + digest + "\r\n" +
"Authorization: " + authorization + "\r\n" +
"Content-Length: " + String(requestBody.length()) + "\r\n" +
"\r\n" +
requestBody;
Serial.println("发送请求:");
Serial.println(httpRequest);
// 发送 HTTP 请求
client.print(httpRequest);
// 读取响应(先读取头部,再读取正文)
while (client.connected())
{
String line = client.readStringUntil('\n');
if (line == "\r")
break; // 空行表示 header 结束
}
String response = client.readString();
Serial.println("响应:");
Serial.println(response);
DynamicJsonDocument doc(2048); // 512字节的JSON解析缓冲区
DeserializationError error = deserializeJson(doc, response);
if (error) {
Serial.print("JSON解析失败: ");
Serial.println(error.c_str());
return;
}
// 提取 "dst" 字段的内容
const char* dst = doc["data"]["result"]["trans_result"]["dst"];
Serial.print("提取的 dst 内容: ");
Serial.println(dst);
playTTS(dst);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
2.6.12 大模型
发送请求数据包
////////////////////////////
// 大模型对话相关函数
////////////////////////////
void sendChatRequest(const String &userInput)
{
currentState=2;
DynamicJsonDocument doc(2048);
JsonObject header = doc.createNestedObject("header");
header["app_id"] = appId;
JsonObject parameter = doc.createNestedObject("parameter");
JsonObject chat = parameter.createNestedObject("chat");
chat["domain"] = "4.0Ultra";
chat["temperature"] = 0.5;
chat["max_tokens"] = 1024;
JsonObject payload = doc.createNestedObject("payload");
JsonObject message = payload.createNestedObject("message");
JsonArray textArr = message.createNestedArray("text");
// 系统提示(可根据需要修改)
JsonObject systemMsg = textArr.createNestedObject();
systemMsg["role"] = "system";
systemMsg["content"] = "你是小嘉,只能用一句话回答";
// 用户输入,使用语音识别的结果
JsonObject userMsg = textArr.createNestedObject();
userMsg["role"] = "user";
userMsg["content"] = userInput;
String output;
serializeJson(doc, output);
wsChat.send(output);
Serial.println("已发送大模型对话请求,内容:" + userInput);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
接收回传
void onChatMessage(WebsocketsMessage message)
{
bot = 1;
Serial.println("大模型服务器返回:" + message.data());
DynamicJsonDocument doc(2048);
DeserializationError err = deserializeJson(doc, message.data());
if (err)
{
Serial.print("大模型JSON解析错误:");
Serial.println(err.c_str());
return;
}
int code = doc["header"]["code"];
if (code == 0)
{
// 提取当前回复内容和序号
int seq = doc["payload"]["choices"]["seq"];
String content = doc["payload"]["choices"]["text"][0]["content"].as<String>();
// 如果是第一条回复,直接赋值;否则在前面添加“,”再累加
if (seq == 0)
{
chatAggregated = content;
}
else
{
chatAggregated += content;
}
// 更新最后一次收到回复的时间,并重置最终标志
lastChatMsgTime = millis();
chatFinalized = false;
Serial.println("当前累计回复:" + chatAggregated);
}
else
{
Serial.println("大模型请求失败,错误码:" + String(code));
}
// 如果累计回复为空,则执行清屏操作
if (chatAggregated.isEmpty())
{
chatFinalized = false;
Serial.println("未收到有效回复,执行清屏...");
mylcd.fillScreen(TFT_BLACK);
mylcd.setTextSize(2);
minuteTimerExpired = true;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
2.6.13 显示线程
void displayTask(void *parameter)
{
for (;;)
{
// 只有状态变化时才刷新屏幕
if (currentState != lastState)
{
mylcd.fillScreen(TFT_BLACK);
mylcd.setTextSize(2);
lastState = currentState;
}
switch (currentState)
{
case 1: // 说话状态
mylcd.setCursor(10, mylcd.height() / 2 - 10);
mylcd.pushImage(60, 60, 120, 120, speak);
break;
case 2: // 机器人状态
mylcd.setCursor(10, mylcd.height() / 2 - 10);
mylcd.pushImage(60, 60, 120, 120, robot);
break;
case 3: // 时间状态
timeClient.update();
int currentHour = (timeClient.getHours() + 8) % 24;
int currentMinute = timeClient.getMinutes();
char timeBuffer[6];
snprintf(timeBuffer, sizeof(timeBuffer), "%02d:%02d", currentHour, currentMinute);
time_t rawTime = (time_t)timeClient.getEpochTime();
struct tm *ptm = gmtime(&rawTime);
char dateStr[11];
snprintf(dateStr, sizeof(dateStr), "%04d.%02d.%02d", ptm->tm_year + 1900, ptm->tm_mon + 1, ptm->tm_mday);
const char* weekdays[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
int weekday = (ptm->tm_wday >= 0 && ptm->tm_wday < 7) ? ptm->tm_wday : 0;
mylcd.setTextSize(6);
mylcd.drawString(timeBuffer, 15, 25, 2);
mylcd.pushImage(0, 0, 158, 30, edalogo);
mylcd.pushImage(13, 155, 75, 75, sun);
mylcd.setTextSize(2);
mylcd.drawLine(0, 148, 240, 148, TFT_YELLOW);
mylcd.drawString(weekdays[weekday], 170, 0, 2);
mylcd.drawString(dateStr, 40, 115, 2);
mylcd.drawString(cityname, 108, 150, 2);
mylcd.drawString("H: " + humidity + " %", 108, 178, 2);
mylcd.drawString("T: " + temperature + " C ", 108, 205, 2);
break;
}
vTaskDelay(500 / portTICK_PERIOD_MS);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
2.6.14 移除非UTF-8字符
String removeNonUTF8(String chatAggregated)
{
String result = "";
for (size_t i = 0; i < chatAggregated.length(); i++)
{
uint8_t c = chatAggregated[i]; // 使用 uint8_t 来处理每个字节
// 检查英文字符
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))
{
result += (char)c;
}
// 检查数字
else if (c >= '0' && c <= '9')
{
result += (char)c;
}
// 检查英文符号 “,” “.”
else if (c == ',' || c == '.')
{
result += (char)c;
}
// 检查中文字符(UTF-8,3字节)
else if (i + 2 < chatAggregated.length() && (uint8_t)chatAggregated[i] >= 0xE4 && (uint8_t)chatAggregated[i] <= 0xE9)
{ // 判断UTF-8中文起始字节范围
uint8_t c2 = (uint8_t)chatAggregated[i + 1];
uint8_t c3 = (uint8_t)chatAggregated[i + 2];
if (c2 >= 0x80 && c2 <= 0xBF && c3 >= 0x80 && c3 <= 0xBF)
{
result += chatAggregated.substring(i, i + 3);
i += 2; // 跳过后两个字节
}
}
}
return result;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
2.6.15 WebSockets建立连接
// WebSocket 连接处理函数
void connectWebSocket()
{
if (wsSpeech.available())
{
wsSpeech.close();
}
String speechURL = generateSpeechAuthURL();
Serial.println("语音WS URL:" + speechURL);
wsSpeech.onMessage(onSpeechMessage);
wsSpeech.connect(speechURL);
// 等待 WebSocket 连接建立
unsigned long startTime = millis();
while (!wsSpeech.available() && millis() - startTime < 1000)
{
delay(10);
}
sendHandshake();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2.6.16 按键逻辑
// 按键处理函数
void handleButtonPress(int button, bool &lastState, bool currentState, int buttonID)
{
if (lastState == HIGH && currentState == LOW)
{
Serial.printf("BUTTON_%d 按键按下\n", buttonID);
connectWebSocket();
if (!isRecording)
{
startRecording();
}
BTNow = buttonID;
isTalkingDisplayActive = true;
}
if (lastState == LOW && currentState == HIGH)
{
Serial.printf("BUTTON_%d 按键松开\n", buttonID);
if (isRecording)
{
stopRecording();
}
isTalkingDisplayActive = false;
bot = 1;
displayTaskHandle = NULL;
}
lastState = currentState;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2.6.17 语音逻辑处理
// 处理语音识别结果
void rocessSpeechResult()
{
if (pspeechFinished)
{
if (speechText.length() > 0 && !chatFinished)
{
if (BTNow == 1)
{
if (!wsChat.available())
{
String chatURL = generateChatAuthURL();
Serial.println("大模型对话WS URL:" + chatURL);
wsChat.onMessage(onChatMessage);
wsChat.connect(chatURL);
unsigned long startTime = millis();
while (!wsChat.available() && millis() - startTime < 1000)
{
delay(10);
}
}
sendChatRequest(speechText);
}
else if (BTNow == 2 || BTNow == 3)
{
Translation(speechText);
}
}
else
{
mylcd.fillScreen(TFT_BLACK);
mylcd.setTextSize(2);
minuteTimerExpired = true;
}
// 重置变量
speechFinished = false;
speechText = "";
BTNow = 0;
bot = 0;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
2.6.18 模型返回逻辑处理
// 处理聊天结果
void processChatResult()
{
if (chatAggregated.length() > 0 && (millis() - lastChatMsgTime > 2000) && !chatFinalized)
{
chatFinalized = true;
Serial.println("最终回复:" + chatAggregated);
String filteredText = removeNonUTF8(chatAggregated);
playTTS(filteredText);
chatAggregated = "";
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
2.6.19 主循环逻辑
void loop()
{
wsSpeech.poll();
wsChat.poll();
audio.loop();
// 读取按键状态
static bool lastButtonMIDState = HIGH;
static bool lastButtonLEFTState = HIGH;
static bool lastButtonRIGHTState = HIGH;
bool currentButtonMIDState = digitalRead(BUTTON_MID);
bool currentButtonLEFTState = digitalRead(BUTTON_LEFT);
bool currentButtonRIGHTState = digitalRead(BUTTON_RIGHT);
// 处理按键
handleButtonPress(BUTTON_MID, lastButtonMIDState, currentButtonMIDState, 1);
handleButtonPress(BUTTON_LEFT, lastButtonLEFTState, currentButtonLEFTState, 2);
handleButtonPress(BUTTON_RIGHT, lastButtonRIGHTState, currentButtonRIGHTState, 3);
// 发送音频数据
if (isRecording)
{
sendAudioData(false);
}
// 处理语音识别结果
processSpeechResult();
// 处理聊天结果
processChatResult();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
2.7 网页开发
2.7.1 环境配置
创建文件夹 在项目目录下我们新建一个data文件夹,用于存放要烧录到FS文件系统的文件 index.html文件 在data文件夹下新建一个index.html文件,用于存放我们的配网页面
2.7.2 编写HTML
<!DOCTYPE html>
<html lang='zh'>
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>EDA-Robot</title>
<style>
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
text-align: center;
}
h1 {
text-align: center;
}
.button {
display: inline-block;
height: 30px;
width: 300px;
margin-top: 20px;
padding: 10px 20px;
background-color: deepskyblue;
color: #fff;
border: none;
border-radius: 20px; /* 添加圆角 */
text-decoration: none;
line-height: 2; /* 通过调整line-height的值来调整文字的垂直位置 */
text-align: center; /* 文字居中 */
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); /* 添加立体感 */
transition: all 0.3s ease; /* 添加过渡效果 */
}
.button:hover {
background-color: skyblue; /* 鼠标悬停时的背景颜色 */
transform: translateY(2px); /* 点击效果 */
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.3); /* 添加更多立体感 */
}
.search-box {
margin-top: 20px;
display: inline-block;
height: 30px;
width: 300px;
padding: 5px 10px;
background-color: #f0f0f0;
border: 1px solid #ccc;
border-radius: 20px;
text-align: center; /* 文字居中 */
}
.hidden {
display: none; /* 初始隐藏 */
}
</style>
</head>
<body>
<form action='/connect' method='POST'>
<div class='container'>
<h1>TV-PRO设备配置页</h1>
<p>本项目基于立创ESP32S3开发板开发</p>
<input type='text' name='ssid' placeholder='输入WIFI名称' class='search-box'>
<input type='password' name='pass' placeholder='输入WIFI密码' class='search-box'>
<input type='appid' name='appid' placeholder='输入讯飞Appid' class='search-box'>
<input type='apikey' name='apikey' placeholder='输入讯飞ApiKey' class='search-box'>
<input type='apisecret' name='apisecret' placeholder='输入讯飞ApiSecret' class='search-box'>
<input type='ttsapikey' name='ttsapikey' placeholder='输入万码云apikey' class='search-box'>
<input type='api' name='api' placeholder='输入心知天气API密钥' class='search-box'>
<input type='city' name='city' placeholder='输入城市拼音小写' class='search-box'>
<input type='submit' style="height: 50px;width: 320px" class='button' value="保存">
<a href="https://lceda.cn/">
<table class="container button" style="height: 200px">
<tr>
<th>设备名称:</th>
<td>EDA-Robot</td>
</tr>
<tr>
<th>内存大小:</th>
<td>4MB</td>
</tr>
<tr>
<th>控制台版本:</th>
<td>V1.0</td>
</tr>
<tr>
<th>官网:</th>
<td> <a href="https://lceda.cn/">嘉立创EDA</a></td>
</tr>
</table>
</a>
</div>
</form>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
2.8 编译
2.8.1 编译主程序
点击底下一排中的勾,开始构建 如果显示SUCCESS则表示构建成功
2.8.2 编译FS文件系统
点击左侧PlatformIO的图标,选择Build Filesystem Image 一样,出现SUCCESS则表示构建成功
2.9 烧录
2.9.1 接线
ESP32S3中板载了TTL芯片并且支持USB烧录,所有不需要再使用串口烧录器烧录 直接连接TypeC接口到电脑,此时在VsCode左下角应该有端口选择按钮,点击选择或使用默认的自动识别都可以。
2.9.2 烧录主程序
方法1:使用官方程序
构建成功后,在.pio/build/nodemcuv2文件夹下,应该会有bin文件和elf文件,这都是固件,只是类型不同,您可以通过这些固件使用官方烧录器烧录
方法2:通过编译器
点击底部箭头,等待烧录完成即可
2.9.3 烧录文件系统
方法1:使用官方程序
文件系统的固件同样位于.pio/build/nodemcuv2文件夹下,先烧录完主程序后再烧录文件系统
方法2:通过编译器
点击左侧的Upload Filesystem Image,等待烧录完成即可