1、项目简介
本项目参与的是2026贸泽电子M-Design创意设计大赛中任务一智能家居方向,本项目主要使用XIAO-ESP32S3 SENSE作为主控,每天在特定时间对当前盆栽进行拍照并上传到服务端,并在服务端对照片进行植物表型分析,可以获取盆栽的健康状况(包括缺水胁迫、缺肥胁迫和病虫害胁迫),方便直观了解盆栽的生长状况,由此组成了一个家庭盆栽监测系统。
2、硬件简介
本项目主要使用到到硬件为Seeed Studio厂家的XIAO-ESP32S3 SENSE,设备实物图片如下图1所示。Seeed Studio XIAO ESP32-S3 Sense 集成了摄像头传感器、数字麦克风以及 SD 卡支持。结合嵌入式机器学习算力与成像能力,这块开发板可以成为你入门智能语音与视觉 AI 的得力工具。该硬件集成了ESP32S3 32位双核Xtensa处理器芯片,最高运行频率240MHz,板载多种开发接口,支持Arduino/MicroPython;该硬件还具备锂电池充电管理能力,提供 4 种功耗模式,在深度睡眠模式下功耗低至 14μA;在存储上该硬件提供8MB的PSRAM和8MB的FLASH,支持SD卡槽扩展32GB FAT存储;另外该硬件还支持 2.4GHz Wi-Fi 和 BLE 双无线通信,配合 U.FL 天线时支持 100m+ 远距离通信。

图1 设备实物图
3、项目设计思路
3.1、方案框图
本项目方案框图如下图所示,以ESP32S3为主控,板载锂电池充电管理功能,可以直接接入电池供电;通过拨动开关选择运行模式(安装模式或监测模式),安装模式下将从OV3660读取到到图像直接显示在LCD屏幕上方便安装与调整角度,监测模式下则使用OV3660拍一张照上传到服务器进行植物表型分析且不激活LCD。

图2 方案框图
3.2、设计思路
本项目主要功能为每天特定时间拍一张盆栽的照片进行植物表型分析,从而能够准确判断盆栽的生长状态,从而辅助确定是否需要浇水施肥等。考虑到设备需要在某个位置固定摆放,使用电源适配器供电可能不方便,所以需要使用电池供电,并引出一个ADC引脚监测电池电量;考虑到设备在安装时需要实时根据拍照效果调整安装角度,所以需要一个LCD屏幕并用一个开关区分安装模式和监测模式;考虑到设备需要长时间运行,所以每次开机如果是监测模式的话需要在拍完照或者确认当前不需要拍照时需要立即休眠,减少电量消耗。
对于服务端的设计,初步考虑是设计两个页面,一个是主页面,用于绑定新设备和展示设备列表,另一个是设备详情页面,用于展示盆栽健康状态的折线图以及收到的历史照片。客户端访问到主要接口包括:心跳上传、获取拍照时间、上报电量、上报照片。心跳上传接口用于发现与绑定设备,只有上报过心跳的设备才能进行绑定;获取拍照时间接口用于设备确认拍照上传时间,默认为每天早上8点,可以手动修改与增加;上报电量接口用于在不需要上传照片时上报一次电池电量,及时更新电池电量,方便及时更换电池或给电池充电;上报照片接口则用于上报盆栽照片来进行植物表型分析。
4、原理图与PCB
本项目原理图与PCB如下图3与图4所示,相对比较简单,主要是对主控板XIAO-ESP32S3 SENSE进行转接,并增加了拨动开关、LCD屏幕和电池电压检测接口。拨动开关用于决定设备运行模式(安装模式或监测模式),LCD屏幕用于安装时实时展示拍照效果,电池接口用于监测电池电压。

图3 原理图

图4 PCB图
5、软件流程与关键代码简介
5.1、软件流程
本项目软件流程图如下图5所示,启动后首先检测当前运行模式。如果是安装模式,则只初始化摄像头与LCD屏幕,将从摄像头读取到到图像显示在LCD上并无限循环;如果是监测模式则不初始化LCD屏幕,连接WiFi后进行NTP对时获取当前时间,并访问服务端的心跳上报接口与获取拍照时间接口,再计算距离下次拍照时间,如果已到时间则立即拍照上传,上传完成后在休眠到下次拍照时间,如果没到拍照时间则休眠到下次拍照时间。

图5 流程图
5.2、关键代码简介
5.2.1、安装模式
安装模式的代码如下所示,主要是屏幕初始化和摄像头初始化,摄像头启动之后每次收到一帧图像就使用LCD显示出来,以达成实时显示成像效果方便调整安装位置与角度。
void lcd_display_init(esp_lcd_panel_handle_t *lcd_panel_hdl, esp_lcd_panel_io_handle_t lcd_io_hdl)
{
esp_lcd_panel_handle_t panel_handle = NULL;
//----------LEDC initialization------------//
const ledc_timer_config_t lcd_timer = {
.speed_mode = LEDC_LOW_SPEED_MODE,
.duty_resolution = LEDC_TIMER_10_BIT,
.timer_num = LEDC_LCD_BACKLIGHT,
.freq_hz = 5000,
.clk_cfg = LEDC_AUTO_CLK
};
ESP_ERROR_CHECK(ledc_timer_config(&lcd_timer));
const ledc_channel_config_t lcd_channel = {
.gpio_num = LCD_BACKLIGHT,
.speed_mode = LEDC_LOW_SPEED_MODE,
.channel = LEDC_CHANNEL_0,
.timer_sel = LEDC_LCD_BACKLIGHT,
.intr_type = LEDC_INTR_DISABLE,
.duty = 0,
.hpoint = 0,
.flags.output_invert = false,
};
ESP_ERROR_CHECK(ledc_channel_config(&lcd_channel));
//----------SPI initialization------------//
ESP_LOGI(TAG, "Init SPI bus");
const spi_bus_config_t bus_cfg = {
.sclk_io_num = LCD_SPI_CLK,
.mosi_io_num = LCD_SPI_MOSI,
.miso_io_num = GPIO_NUM_NC,
.quadwp_io_num = GPIO_NUM_NC,
.quadhd_io_num = GPIO_NUM_NC,
.max_transfer_sz = LCD_H_RES*LCD_V_RES*2,
};
ESP_ERROR_CHECK(spi_bus_initialize(LCD_SPI_NUM, &bus_cfg, SPI_DMA_CH_AUTO));
//----------Panel IO initialization------------//
ESP_LOGI(TAG, "New panel IO SPI");
const esp_lcd_panel_io_spi_config_t io_cfg = {
.dc_gpio_num = LCD_DC,
.cs_gpio_num = LCD_SPI_CS,
.pclk_hz = LCD_PIXEL_CLOCK_HZ,
.lcd_cmd_bits = LCD_CMD_BITS,
.lcd_param_bits = LCD_PARAM_BITS,
.spi_mode = 0,
.trans_queue_depth = 10,
};
ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(
(esp_lcd_spi_bus_handle_t)LCD_SPI_NUM,
&io_cfg,
&lcd_io_hdl
));
ili9341_vendor_config_t vendor_config = {
.init_cmds = ili9341_lcd_init_vendor,
.init_cmds_size = sizeof(ili9341_lcd_init_vendor) / sizeof(ili9341_lcd_init_cmd_t),
};
const esp_lcd_panel_dev_config_t panel_dev_cfg = {
.reset_gpio_num = GPIO_NUM_NC,
.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_BGR,
.bits_per_pixel = 16,
.vendor_config = &vendor_config,
};
ESP_ERROR_CHECK(esp_lcd_new_panel_ili9341(lcd_io_hdl, &panel_dev_cfg, &panel_handle));
ESP_LOGI(TAG, "Reset and init panel");
esp_lcd_panel_reset(panel_handle);
esp_lcd_panel_init(panel_handle);
esp_lcd_panel_mirror(panel_handle, true, false);
// esp_lcd_panel_invert_color(panel_handle, true);
ESP_LOGI(TAG, "Turn on display");
esp_lcd_panel_disp_on_off(panel_handle, true);
const int brightness = 100;
uint32_t duty = (1023 * brightness) / 100;
ESP_ERROR_CHECK(ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty));
ESP_ERROR_CHECK(ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0));
*lcd_panel_hdl = panel_handle;
}
void camera_lcd(void)
{
esp_lcd_panel_handle_t lcd_panel_hdl = NULL;
esp_lcd_panel_io_handle_t lcd_io_hdl = NULL;
lcd_display_init(&lcd_panel_hdl, lcd_io_hdl);
vTaskDelay(3000 / portTICK_PERIOD_MS);
//----------CAM Controller Init------------//
esp_err_t ret = ESP_FAIL;
esp_cam_ctlr_handle_t cam_handle = NULL;
esp_cam_ctlr_dvp_pin_config_t pin_cfg = {
.data_width = DVP_CAM_DATA_WIDTH,
.data_io = {
DVP_CAM_D0_IO,
DVP_CAM_D1_IO,
DVP_CAM_D2_IO,
DVP_CAM_D3_IO,
DVP_CAM_D4_IO,
DVP_CAM_D5_IO,
DVP_CAM_D6_IO,
DVP_CAM_D7_IO,
},
.vsync_io = DVP_CAM_VSYNC_IO,
.de_io = DVP_CAM_DE_IO,
.pclk_io = DVP_CAM_PCLK_IO,
.xclk_io = DVP_CAM_XCLK_IO,
};
esp_cam_ctlr_dvp_config_t dvp_config = {
.ctlr_id = 0,
.clk_src = CAM_CLK_SRC_DEFAULT,
.h_res = 240,
.v_res = 240,
#if CONFIG_CAM_INPUT_FORMAT_YUV422
.input_data_color_type = CAM_CTLR_COLOR_YUV422_UYVY,
.output_data_color_type = CAM_CTLR_COLOR_RGB565,
#else
.input_data_color_type = CAM_CTLR_COLOR_RGB565,
.output_data_color_type = CAM_CTLR_COLOR_RGB565,
#endif
.conv_std = COLOR_CONV_STD_RGB_YUV_BT601,
.input_range = COLOR_RANGE_LIMIT,
.output_range = COLOR_RANGE_LIMIT,
.dma_burst_size = 64,
.pin = &pin_cfg,
.bk_buffer_dis = 1,
.xclk_freq = DVP_CAM_XCLK_FREQ_HZ,
};
ret = esp_cam_new_dvp_ctlr(&dvp_config, &cam_handle);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "dvp init fail[%d]", ret);
return;
}
//--------Allocate Camera Buffer----------//
size_t cam_buffer_size = 115200;
void *cam_buffer = NULL;
cam_buffer = esp_cam_ctlr_alloc_buffer(cam_handle, cam_buffer_size, DVP_CAM_BUF_ALLOC_CAPS);
//--------Camera Sensor and SCCB Init-----------//
example_sensor_config_t cam_sensor_config = {
.i2c_port_num = I2C_NUM_0,
.i2c_sda_io_num = DVP_CAM_SCCB_SDA_IO,
.i2c_scl_io_num = DVP_CAM_SCCB_SCL_IO,
.port = ESP_CAM_SENSOR_DVP,
.format_name = CAM_FORMAT,
};
example_sensor_handle_t sensor_handle = {
.sccb_handle = NULL,
.i2c_bus_handle = NULL,
};
example_sensor_init(&cam_sensor_config, &sensor_handle);
//--------Register Camera Callbacks----------//
example_cam_context_t cam_ctx = {
.panel_hdl = lcd_panel_hdl,
.cam_trans = {
.buffer = cam_buffer,
.buflen = cam_buffer_size,
}
};
esp_cam_ctlr_evt_cbs_t cbs = {
.on_get_new_trans = s_camera_get_new_vb,
.on_trans_finished = s_camera_get_finished_trans,
};
if (esp_cam_ctlr_register_event_callbacks(cam_handle, &cbs, &cam_ctx) != ESP_OK) {
ESP_LOGE(TAG, "ops register fail");
return;
}
//--------Enable and start Camera Controller----------//
ESP_ERROR_CHECK(esp_cam_ctlr_enable(cam_handle));
if (esp_cam_ctlr_start(cam_handle) != ESP_OK) {
ESP_LOGE(TAG, "Driver start fail");
return;
}
while(1)vTaskDelay(5000 / portTICK_PERIOD_MS);
}
static bool s_camera_get_new_vb(esp_cam_ctlr_handle_t handle, esp_cam_ctlr_trans_t *trans, void *user_data)
{
example_cam_context_t *ctx = (example_cam_context_t *)user_data;
*trans = ctx->cam_trans;
return false;
}
static bool s_camera_get_finished_trans(esp_cam_ctlr_handle_t handle, esp_cam_ctlr_trans_t *trans, void *user_data)
{
example_cam_context_t *ctx = (example_cam_context_t *)user_data;
// ESP_LOGI(TAG, "capture!");
esp_lcd_panel_draw_bitmap(ctx->panel_hdl, 0, 0, 240, 240, trans->buffer);
return false;
}
5.2.2、监测模式
监测模式的主要代码如下所示,该模式下每次开机先连接WiFi使用NTP同步时间,再上报一次心跳并获取下次拍照时间,根据时间同步的时间计算到下次拍照的时间,如果距离时间近就立即拍照并上传然后休眠,如果距离时间比较远就直接休眠,休眠时间由距离下次拍照时间决定,如果时间超过3小时就先休眠3小时,起来后再次计算,否则休眠到下次拍照时间。通过深度休眠模式来节省电量,从而延长设备整体运行时间。
// ==================== 设备心跳上报 ====================
void device_send_heartbeat(void) {
ESP_LOGI(TAG, "上报设备ID: %s", device_id);
char post[128];
sprintf(post, "device_id=%s", device_id);
esp_http_client_config_t config = {
.url = SERVER_URL "/api/device_heartbeat",
.event_handler = http_event_handle,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_http_client_set_method(client, HTTP_METHOD_POST);
esp_http_client_set_header(client, "Content-Type", "application/x-www-form-urlencoded");
esp_http_client_set_post_field(client, post, strlen(post));
esp_http_client_perform(client);
esp_http_client_cleanup(client);
}
// ==================== 获取拍照时间 ====================
void device_get_capture_times(void) {
ESP_LOGI(TAG, "获取服务器拍照时间...");
capture_count = 0;
memset(capture_times, 0, sizeof(capture_times));
http_response_len = 0;
char post[128];
sprintf(post, "device_id=%s", device_id);
esp_http_client_config_t config = {
.url = SERVER_URL "/api/get_capture_times",
.event_handler = http_event_handle,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_http_client_set_method(client, HTTP_METHOD_POST);
esp_http_client_set_header(client, "Content-Type", "application/x-www-form-urlencoded");
esp_http_client_set_post_field(client, post, strlen(post));
esp_http_client_perform(client);
esp_http_client_cleanup(client);
cJSON *root = cJSON_Parse(http_response);
cJSON *times = cJSON_GetObjectItem(root, "capture_times");
int n = cJSON_GetArraySize(times);
for (int i = 0; i < n; i++) {
strncpy(capture_times[i], cJSON_GetArrayItem(times, i)->valuestring, 15);
capture_count++;
}
cJSON_Delete(root);
ESP_LOGI(TAG, "获取到 %d 个拍照时间", capture_count);
}
// ==================== 上传照片 + 电量 ====================
void device_upload_photo(void) {
ESP_LOGI(TAG, "=== taking photo ===");
take_photo();
int battery = get_battery_level();
ESP_LOGI(TAG, "photo size=%d, battery=%d", get_image_len(), battery);
char url[256];
snprintf(url, sizeof(url), "%s/api/upload_photo", SERVER_URL);
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_POST,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
const char *boundary = "------------------------1234567890";
char content_type[128];
snprintf(content_type, sizeof(content_type), "multipart/form-data; boundary=%s", boundary);
esp_http_client_set_header(client, "Content-Type", content_type);
// 发送数据
char buf[512];
char* post_data = (char*)malloc(get_image_len()+2048);
int post_len = 0;
// 1. device_id
snprintf(buf, sizeof(buf), "--%s\r\nContent-Disposition: form-data; name=\"device_id\"\r\n\r\n%s\r\n", boundary, device_id);
memcpy(post_data+post_len,buf,strlen(buf));
post_len += strlen(buf);
// 2. battery
snprintf(buf, sizeof(buf), "--%s\r\nContent-Disposition: form-data; name=\"battery\"\r\n\r\n%d\r\n", boundary, battery);
memcpy(post_data+post_len,buf,strlen(buf));
post_len += strlen(buf);
// 3. photo 文件头
snprintf(buf, sizeof(buf), "--%s\r\nContent-Disposition: form-data; name=\"photo\"; filename=\"plant.jpg\"\r\nContent-Type: image/jpeg\r\n\r\n", boundary);
memcpy(post_data+post_len,buf,strlen(buf));
post_len += strlen(buf);
// 4. 图片数据
esp_http_client_write(client, get_image(), get_image_len());
memcpy(post_data+post_len,get_image(),get_image_len());
post_len += get_image_len();
// 5. 结束
snprintf(buf, sizeof(buf), "\r\n--%s--\r\n", boundary);
memcpy(post_data+post_len,buf,strlen(buf));
post_len += strlen(buf);
// 执行
esp_http_client_set_post_field(client, post_data, post_len);
esp_http_client_perform(client);
esp_http_client_cleanup(client);
}
// ==================== 单独上报电量 ====================
void device_upload_battery(void) {
int battery = get_battery_level();
ESP_LOGI(TAG, "battery level:%d", battery);
char post[128];
sprintf(post, "device_id=%s&battery=%d", device_id, battery);
esp_http_client_config_t config = {
.url = SERVER_URL "/api/upload_battery",
.event_handler = http_event_handle,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_http_client_set_method(client, HTTP_METHOD_POST);
esp_http_client_set_header(client, "Content-Type", "application/x-www-form-urlencoded");
esp_http_client_set_post_field(client, post, strlen(post));
esp_http_client_perform(client);
esp_http_client_cleanup(client);
}
// ==================== 深度休眠 ====================
void device_sleep_minutes(int minutes) {
ESP_LOGI(TAG, "deep sleep %d minutes...\n", minutes);
vTaskDelay(pdMS_TO_TICKS(500));
esp_sleep_enable_timer_wakeup((uint64_t)minutes * 60 * 1000000);
esp_deep_sleep_start();
}
void client_run(void) {
// 1. 联网
esp_netif_init();
wifi_init_sta();
vTaskDelay(pdMS_TO_TICKS(2000));
esp_sntp_config_t config = ESP_NETIF_SNTP_DEFAULT_CONFIG("ntp.aliyun.com");
esp_netif_sntp_init(&config);
// wait for time to be set
int retry = 0;
const int retry_count = 15;
while (esp_netif_sntp_sync_wait(5000 / portTICK_PERIOD_MS) == ESP_ERR_TIMEOUT && ++retry < retry_count) {
ESP_LOGI(TAG, "Waiting for system time to be set... (%d/%d)", retry, retry_count);
}
// Set timezone to China Standard Time
setenv("TZ", "CST-8", 1);
tzset();
// 2. 上报心跳 + 获取拍照时间
get_device_id(device_id);
device_send_heartbeat();
device_get_capture_times();
// 3. 计算距离下一次拍照还有多久
int to_next = calc_minutes_to_next_capture();
ESP_LOGI(TAG, "距离下一次拍照:%d 分钟", to_next);
// 开机传图测试,1小时传一次
// device_upload_photo();
// device_sleep_minutes(60);
// 4. 如果即将到达拍照时间(5分钟内),立即拍照
if (to_next <= 5) {
device_upload_photo();
// 拍完重新计算下下次,休眠最合理时长
int new_next = calc_minutes_to_next_capture();
if(new_next<5)new_next=60;//防止传完一次太快导致重复传
int sleep_min = (new_next > MAX_SLEEP_MIN) ? MAX_SLEEP_MIN : new_next;
device_sleep_minutes(sleep_min);
}
else {
// 没到时间:上报电量,动态休眠
device_upload_battery();
int sleep_min = (to_next > MAX_SLEEP_MIN) ? MAX_SLEEP_MIN : to_next;
device_sleep_minutes(sleep_min);
}
}
5.2.3、后端主要代码
后端主要代码如下所示,主要是客户端和前端需要访问的接口,包括设备心跳上报、设备获取拍照时间、设备电量上报、设备拍照上传、前端的绑定解绑和设置拍照时间等接口。
# 1. 设备心跳上报(进入待绑定)
@app.route('/api/device_heartbeat', methods=['POST'])
def device_heartbeat():
device_id = request.form.get('device_id')
if not device_id:
return jsonify({"code":400,"msg":"设备ID不能为空"})
db = get_db()
exist = db.execute('SELECT 1 FROM devices WHERE device_id=?',[device_id]).fetchone()
if exist:
return jsonify({"code":200,"msg":"已绑定"})
db.execute('INSERT OR REPLACE INTO wait_bind_device (device_id,report_time) VALUES (?,?)',
[device_id, datetime.now().strftime("%Y-%m-%d %H:%M")])
db.commit()
return jsonify({"code":200,"msg":"待绑定"})
# 2. 设备获取拍照时间
@app.route('/api/get_capture_times', methods=['POST'])
def get_capture_times():
device_id = request.form.get('device_id')
if not device_id:
return jsonify({"code":400,"msg":"设备ID不能为空"})
db = get_db()
times = [t['capture_time'] for t in db.execute(
'SELECT capture_time FROM capture_times WHERE device_id = ?', [device_id])]
return jsonify({
"code":200,
"device_id":device_id,
"capture_times": times or ["08:00"]
})
# 3. 单独上报电量
@app.route('/api/upload_battery', methods=['POST'])
def upload_battery():
device_id = request.form.get('device_id')
battery = request.form.get('battery')
if not device_id or not battery:
return jsonify({"code":400,"msg":"参数缺失"})
db = get_db()
db.execute('UPDATE devices SET battery = ? WHERE device_id = ?', [int(battery), device_id])
db.commit()
return jsonify({"code":200,"msg":"电量更新成功"})
# 4. 上传照片 + 分析
@app.route('/api/upload_photo', methods=['POST'])
def upload_photo():
device_id = request.form.get('device_id')
battery = request.form.get('battery', None)
print(f"设备ID: {device_id}")
print(f"上传电量: {battery}")
print(f"请求中是否包含 photo 文件: {'photo' in request.files}")
if not device_id:
return jsonify({"code": 400, "msg": "缺少设备ID"})
if 'photo' not in request.files:
return jsonify({"code": 400, "msg": "未收到图片"})
# 获取文件
file = request.files['photo']
print(f"文件是否为空: {file.filename == ''}")
if file.filename == '':
return jsonify({"code": 400, "msg": "图片名为空"})
# 生成保存路径
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{device_id}_{timestamp}.jpg"
save_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
print(f"图片将保存到: {save_path}")
try:
file.save(save_path)
except Exception as e:
return jsonify({"code": 500, "msg": f"保存失败: {str(e)}"})
# 更新电量
db = get_db()
if battery is not None:
db.execute('UPDATE devices SET battery = ? WHERE device_id = ?', [int(battery), device_id])
# 分析健康值和胁迫程度
try:
health, disease_stress, water_stress, fertilizer_stress = analyze_plant_health(save_path)
print(f"植物综合健康值: {health}")
print(f"病虫害胁迫: {disease_stress}, 缺水胁迫: {water_stress}, 缺肥胁迫: {fertilizer_stress}")
except Exception as e:
health = 50
disease_stress = 50
water_stress = 50
fertilizer_stress = 50
print(f"分析失败,使用默认值: {e}")
# 记录数据库
now = datetime.now().strftime("%Y-%m-%d %H:%M")
db.execute('''INSERT INTO plant_status
(device_id, time, health, disease_stress, water_stress, fertilizer_stress, photo)
VALUES (?,?,?,?,?,?,?)''',
[device_id, now, health, disease_stress, water_stress, fertilizer_stress, filename])
db.commit()
return jsonify({"code":200,"msg":"上传成功","health":health, "stress_levels":{"disease":disease_stress, "water":water_stress, "fertilizer":fertilizer_stress}})
# ====================== 前端操作 API ======================
@app.route('/api/bind', methods=['POST'])
def bind_device():
device_id = request.form.get('device_id')
device_name = request.form.get('device_name', f'设备{device_id[:6]}')
if not device_id:
return jsonify({"code":400,"msg":"请选择设备"})
db = get_db()
db.execute('INSERT OR IGNORE INTO devices (device_id,name,battery) VALUES (?,?,100)', [device_id, device_name])
db.execute('DELETE FROM wait_bind_device WHERE device_id=?', [device_id])
db.execute('INSERT OR IGNORE INTO capture_times (device_id,capture_time) VALUES (?,?)', [device_id, "08:00"])
db.commit()
return jsonify({"code":200,"msg":"绑定成功"})
@app.route('/api/set_capture_time', methods=['POST'])
def set_capture_time():
device_id = request.form.get('device_id')
# Handle multiple time values properly - use getlist for arrays
times = request.form.getlist('times[]')
db = get_db()
# Delete existing times for this device
db.execute('DELETE FROM capture_times WHERE device_id=?', [device_id])
# Insert new times
for t in times:
if t.strip(): # Only insert non-empty times
db.execute('INSERT INTO capture_times (device_id,capture_time) VALUES (?,?)', [device_id, t.strip()])
db.commit()
return jsonify({"code":200,"msg":"设置成功"})
# 解绑设备
@app.route('/api/unbind', methods=['POST'])
def unbind_device():
device_id = request.form.get('device_id')
if not device_id:
return jsonify({"code":400,"msg":"设备ID不能为空"})
db = get_db()
# 删除设备记录
db.execute('DELETE FROM devices WHERE device_id=?', [device_id])
db.execute('DELETE FROM capture_times WHERE device_id=?', [device_id])
db.execute('DELETE FROM plant_status WHERE device_id=?', [device_id])
db.execute('DELETE FROM wait_bind_device WHERE device_id=?', [device_id])
db.commit()
return jsonify({"code":200,"msg":"解绑成功"})
6、功能展示与说明
6.1、安装模式
设备安装模式状态如下图6 所示,安装模式下可以实时展示摄像头的拍摄效果,方便根据效果调整安装位置和角度。

图6 安装模式
6.2、监测模式
设备监测模式状态如下图所示,监测模式下设备99%以上的时间都在休眠,所以从外表上看与断电没什么两样。

图7监测模式
6.3、服务端界面
服务端的两个界面如下图8与图9所示,主界面主要是绑定、解绑设备与展示已绑定设备,点击详情按钮可以进图设备详情页面,设备详情页面可以看到盆栽的健康情况的折线图,往下翻还可以查看设备的历史图片。

图8 网站主界面

图9 设备详情界面
7、总结
7.1、设计中遇到的难题和解决方法
本项目设备侧的开发由于官方例子很多,开发起来相对容易,主要难点在于服务端代码的开发,只能界面怎么简单怎么来,数据库怎么方便怎么来,至于安全相关则是先完全没考虑,主打一个先把功能实现出来。开发时测试阶段可以先部署在开发机本地,正式长时间运行刚开始是准备部署在树莓派上的,毕竟相较于电脑主机功率低也没什么噪音,优点是管理方便,缺点是只能在局域网内访问。后来发现pythonanywhere可以提供一个免费的小型网站服务,所有后来将服务端部署在了pythonanywhere上,可以点击这里访问,优点是可以在任意地方查看,缺点是访问速度稍慢且每个月都需要登录到管理后台延长网站运行时间。
7.2、心得体会
通过本次竞赛不仅体验了ESP32S3的简单易用和丝滑开发体验,收获更大的是网站开发相关的知识,虽然只是初步体验了下入门级别的开发,但也为以后的深入学习打下了一定的基础,期待未来可以开发更有深度的项目。