简介

去年我升级了乌龟的自动喂食器:乌龟自动喂食器2.0升级,基于 Arduino cloud 的 APP 来远程管理,就发现有时候连不上,控制按钮免费版也最多使用五个,还有不知道是不是 ESP8266 模块问题,连 Wi-Fi 有时也断。趁着春节假期,换 ESP32,远程也改用 Cloudflare workers 来控制。

一个遗憾的消息是,去年年底的时候,一只最小的乌龟死了,之前一直烂脚,我隔三岔五的用药水涂脚,但最终还是没救了,不知道是不是因为烂脚死的。现在就只剩下两只了。

update: 2026/08/22

  1. 增加了局域网 OTA 升级
  2. 增加了局域网控制面板,没有互联网时也能控制
  3. 修复 CDN 源超时,国外节点延时高等导致设备无法运转的问题,更新 MQTT 服务器配置到 EMQX 深圳节点,以及 CDN 源

硬件设计

主控换成了 ESP32 开发板。这板我也不知道啥时候买的了。不用模块一个是因为省钱,一个是比较方便我升级和调试固件,直接拔出来连到电脑就好了,以前还得把连接线拔了拆整个板。
另外把继电器换成了 PMOS,水位监测,喂食电机驱动和之前一样。
sch_V2
电路设计我使用 KiCAD 9 了,3D 库也一起打包了。
Autofeed_V2

KiCAD 源文件百度云链接: https://pan.baidu.com/s/1gtLMKz2DZfkLErWBy8DLeQ?pwd=6duk
提取码: 6duk

软件设计

软件设计还是用的 Arduino 框架,通过 Cloudflare Workers 远程控制,使用免费的 MQTT 服务通信(HiveMQ),国内也可以选用 EMQX,同样有免费额度,但一个账号只能建一个免费服务。

推荐一下 Google antigravity,opus 4.6,gemini 3.0 都有免费额度,现在已经有 gemini 3.7 flash 了。
这种简单的软件设计很容易就搞定,bug 修复只要告诉 AI 详细信息,很快就修复了。

主要文件如下:

文件 说明
Autofeed2.2.ino ESP32 Arduino 固件
worker.js Cloudflare Worker 控制面板

系统架构

graph LR
    A["Cloudflare Workers<br/>(Web 控制面板)"] -- "MQTT over WebSocket" <--> B["HiveMQ Cloud Broker"]
    B -- "MQTT over TLS" <--> C["ESP32-WROOM-32D"]
    C --> D["电机 (GPIO14/27)"]
    C --> E["进水泵 (GPIO19)"]
    C --> F["循环泵 (GPIO18)"]
    C --> G["水位传感器 (GPIO16/17)"]

Arduino 固件功能

  • WiFi:使用 ESPTouch SmartConfig 配网,凭据持久化自动重连
  • NTP: 自动同步东八区时间
  • MQTT: TLS 8883 连接 HiveMQ Cloud,订阅命令/发布状态
  • 电机: GPIO14/27 PWM 正反转控制,支持多时间点定时调度 + 间隔天数
  • 进水泵: GPIO19,由水位传感器自动控制(GPIO17 低水位 / GPIO16 高水位),开启时间超过3分钟自动关闭所有水泵
  • 循环泵: GPIO18,可配置开/关秒数循环工作
  • 持久化: 所有配置保存到 ESP32 Flash (Preferences),断电不丢失
  • 状态上报: 每 10 秒通过 MQTT 上报完整设备状态

ESP32 Arduino 代码

实现以下功能:

  1. WiFi 连接 (ESPTouch/SmartConfig)

    • 使用 WiFi.beginSmartConfig() 等待配网
    • 连接成功后保存并自动重连
  2. NTP 时间同步

    • configTime() 设置东八区 (UTC+8),从网络获取当地时间
  3. HiveMQ MQTT 连接

    • 使用 PubSubClient 库连接 HiveMQ Cloud (TLS 8883)
    • 订阅 autofeed/cmd,发布 autofeed/status
    • 接收命令后解析 JSON 更新本地配置
  4. 电机控制 (PWM)

    • GPIO14 (pwm1Pin) + GPIO27 (pwm2Pin)
    • 正转: GPIO14 HIGH, GPIO27 LOW
    • 反转: GPIO14 LOW, GPIO27 HIGH
    • 停止: 两个都 LOW
    • 根据 runSeconds 控制每次运行时长
  5. 电机调度器

    • 支持 intervalDays(1=每天, 2=隔天, 3=隔两天…)
    • 支持一天内多个时间点执行
    • 基于 NTP 时间判断是否触发
  6. 进水泵控制

    • GPIO19 (pumpInPin)
    • 由水位传感器自动控制(开关打开时)
    • GPIO17 HIGH → 水位低 → 启动进水泵
    • GPIO16 HIGH → 水位高 → 停止进水泵
  7. 循环泵控制

    • GPIO18 (pumpOutPin)
    • 循环工作模式: 开 N 秒 → 关 M 秒 → 循环
  8. 状态上报

    • 每 10 秒通过 MQTT 上报一次设备状态

Arduino 必须安装依赖库:

  • PubSubClient
  • ArduinoJson

Arduino 代码如下:

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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
/*
* Autofeed 2.2 - ESP32 自动喂食/水泵控制系统
*
* 硬件: ESP32-WROOM-32D
* 通信: EMQX Cloud MQTT (TLS) / 本地局域网 WebServer
* 配网: ESPTouch SmartConfig
*
* 依赖库 (请在 Arduino Library Manager 中安装):
* - PubSubClient (by Nick O'Leary)
* - ArduinoJson (by Benoit Blanchon, v6 或 v7)
*/

#include <ArduinoJson.h>
#include <Preferences.h>
#include <PubSubClient.h>
#include <Update.h>
#include <WebServer.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <time.h>

// ==================== EMQX 配置 ====================
#define MQTT_BROKER "***.emqxsl.cn"
#define MQTT_PORT 8883 // TLS 端口
#define MQTT_USER "user" // MQTT 用户名
#define MQTT_PASS "password" // MQTT 密码
#define MQTT_CLIENT "autofeed-esp32" // 客户端 ID

// ==================== MQTT 主题 ====================
#define TOPIC_CMD "autofeed/cmd"
#define TOPIC_STATUS "autofeed/status"

// ==================== 引脚定义 ====================
#define PWM1_PIN 14 // 电机 PWM1 — HIGH 时正转方向
#define PWM2_PIN 27 // 电机 PWM2 — HIGH 时反转方向
#define PUMP_IN_PIN 19 // 进水泵 — HIGH 工作
#define PUMP_OUT_PIN 18 // 循环泵 — HIGH 工作
#define WATER_LOW_PIN 17 // 低水位传感器 — HIGH 表示水位低
#define WATER_HIGH_PIN 16 // 高水位传感器 — HIGH 表示水位已满

// ==================== NTP 配置 ====================
#define NTP_SERVER "ntp.aliyun.com" // 使用国内阿里云 NTP 服务器更稳定快速
#define NTP_SERVER2 "pool.ntp.org"
#define GMT_OFFSET 28800 // UTC+8 (8 * 3600秒)
#define DST_OFFSET 0

// ==================== 全局对象 ====================
WiFiClientSecure espClient;
PubSubClient mqtt(espClient);
Preferences prefs;
WebServer server(80);
bool webServerStarted = false;

// ==================== 电机配置 ====================
struct MotorConfig {
bool enabled;
int runSeconds; // 每次运行秒数
int intervalDays; // 间隔天数 (1=每天, 2=隔天, 3=隔两天...)
String times[10]; // 每天的执行时间列表 "HH:MM"
int timeCount; // 时间点数量
} motorCfg = {false, 30, 1, {}, 0};

// ==================== 进水泵配置 ====================
struct PumpInConfig {
bool enabled;
} pumpInCfg = {false};

// ==================== 循环泵配置 ====================
struct PumpOutConfig {
bool enabled;
int onSeconds; // 循环开启秒数
int offSeconds; // 循环关闭秒数
} pumpOutCfg = {false, 30, 30};

// ==================== 运行状态 ====================
bool motorRunning = false;
unsigned long motorStartTime = 0;

bool pumpInRunning = false;
unsigned long pumpInStartTime = 0;
bool pumpOutRunning = false;
unsigned long pumpOutCycleStart = 0;
bool pumpOutCycleState = false; // true = 开启阶段

bool waterLow = false;
bool waterHigh = false;
bool pumpError = false; // 水泵超时保护报警标志

// ==================== 定时器与控制变量 ====================
unsigned long lastStatusReport = 0;
const unsigned long STATUS_INTERVAL = 10000; // 10 秒上报一次

unsigned long lastScheduleCheck = 0;
const unsigned long SCHEDULE_INTERVAL = 5000; // 5 秒检查一次调度

unsigned long lastMqttReconnect = 0;
const unsigned long MQTT_RECONNECT_INTERVAL = 10000; // 10 秒重试一次 MQTT

unsigned long lastWifiReconnect = 0;
const unsigned long WIFI_RECONNECT_INTERVAL = 10000; // 10 秒检查一次 WiFi

// 调度追踪: 记录今天是否已执行过各时间点
bool scheduledToday[10] = {false};
int lastCheckedDay = -1;

// ==================== 函数声明 ====================
void setupWiFi();
void setupMQTT();
void setupWebServer();
void mqttCallback(char *topic, byte *payload, unsigned int length);
void reconnectMQTT();
void handleMotorSchedule();
void updateMotor();
void updatePumpIn();
void updatePumpOut();
void reportStatus();
void applyCommand(const char *json);
void saveConfig();
void loadConfig();
void stopMotor();
void startMotor();

// ==================== Setup ====================
void setup() {
Serial.begin(115200);
Serial.println("\n=== Autofeed 2.2 启动 ===");

// 引脚初始化
pinMode(PWM1_PIN, OUTPUT);
pinMode(PWM2_PIN, OUTPUT);
pinMode(PUMP_IN_PIN, OUTPUT);
pinMode(PUMP_OUT_PIN, OUTPUT);
pinMode(WATER_LOW_PIN, INPUT);
pinMode(WATER_HIGH_PIN, INPUT);

// 初始状态: 全部关闭
digitalWrite(PWM1_PIN, LOW);
digitalWrite(PWM2_PIN, LOW);
digitalWrite(PUMP_IN_PIN, LOW);
digitalWrite(PUMP_OUT_PIN, LOW);

// 加载已保存的配置
loadConfig();

// WiFi 连接
setupWiFi();

// NTP 时间同步 (主备双 NTP 源)
configTime(GMT_OFFSET, DST_OFFSET, NTP_SERVER, NTP_SERVER2);
Serial.println("配置 NTP 时间同步...");

// MQTT 初始化
setupMQTT();
}

// ==================== Loop ====================
void loop() {
unsigned long now = millis();

// 保持 WiFi 连接 (非阻塞检查)
if (WiFi.status() != WL_CONNECTED) {
if (now - lastWifiReconnect >= WIFI_RECONNECT_INTERVAL) {
lastWifiReconnect = now;
Serial.println("WiFi 断开,尝试后台重连...");
WiFi.reconnect();
}
} else {
// 启动与处理本地 Web 服务器 (用于局域网控制与 OTA)
if (!webServerStarted) {
setupWebServer();
}
server.handleClient();

// MQTT 连接与消息处理
if (!mqtt.connected()) {
if (now - lastMqttReconnect >= MQTT_RECONNECT_INTERVAL) {
lastMqttReconnect = now;
reconnectMQTT();
}
} else {
mqtt.loop();
}
}

// 读取水位传感器
waterLow = (digitalRead(WATER_LOW_PIN) == HIGH);
waterHigh = (digitalRead(WATER_HIGH_PIN) == HIGH);

// 更新各硬件模块 (即使断网,仍能自主按预设逻辑运行)
handleMotorSchedule();
updateMotor();
updatePumpIn();
updatePumpOut();

// 定时上报状态
if (now - lastStatusReport >= STATUS_INTERVAL) {
lastStatusReport = now;
reportStatus();
}
}

// ==================== WiFi SmartConfig ====================
void setupWiFi() {
prefs.begin("wifi", true);
String ssid = prefs.getString("ssid", "");
String pass = prefs.getString("pass", "");
prefs.end();

if (ssid.length() > 0) {
Serial.printf("尝试连接已保存的 WiFi: %s\n", ssid.c_str());
WiFi.begin(ssid.c_str(), pass.c_str());

int timeout = 0;
while (WiFi.status() != WL_CONNECTED && timeout < 20) {
delay(500);
Serial.print(".");
timeout++;
}

if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\nWiFi 已连接! IP: %s\n",
WiFi.localIP().toString().c_str());
setupWebServer();
return;
}
Serial.println("\n已保存的 WiFi 连接失败, 启动 SmartConfig...");
}

// SmartConfig 配网
WiFi.mode(WIFI_STA);
WiFi.beginSmartConfig(SC_TYPE_ESPTOUCH_V2);
Serial.println("等待 SmartConfig 配网... (请使用 ESPTouch App)");

while (!WiFi.smartConfigDone()) {
delay(500);
Serial.print(".");
}
Serial.println("\nSmartConfig 配网完成!");

while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.printf("\nWiFi 已连接! IP: %s\n", WiFi.localIP().toString().c_str());

// 保存 WiFi 凭据
prefs.begin("wifi", false);
prefs.putString("ssid", WiFi.SSID());
prefs.putString("pass", WiFi.psk());
prefs.end();
Serial.println("WiFi 凭据已保存");

setupWebServer();
}

// ==================== 本地 Web 控制与 OTA 页面 ====================
const char indexHtml[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Autofeed 2.2 局域网控制面板</title>
<style>
:root {
--bg: #0b0f19;
--card: #151d30;
--card-border: #232f4a;
--accent: #3b82f6;
--accent-hover: #2563eb;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--text: #f1f5f9;
--text2: #94a3b8;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); padding: 16px; display: flex; justify-content: center; }
.container { width: 100%; max-width: 520px; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--card-border); }
.header h1 { font-size: 1.25rem; font-weight: 700; display: flex; align-items: center; gap: 8px; color: #60a5fa; }
.badge { font-size: 0.75rem; padding: 3px 8px; border-radius: 9999px; background: rgba(59,130,246,0.15); color: #93c5fd; border: 1px solid rgba(59,130,246,0.3); }
.card { background: var(--card); border: 1px solid var(--card-border); border-radius: 14px; padding: 16px; margin-bottom: 14px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
.card-title { font-size: 1rem; font-weight: 600; color: #e2e8f0; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }

/* Grid & Items */
.status-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
.status-item { background: #0c1220; padding: 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.05); }
.status-item .label { font-size: 0.75rem; color: var(--text2); margin-bottom: 4px; }
.status-item .value { font-size: 0.95rem; font-weight: 600; }

/* Control Rows */
.ctrl-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-top: 1px solid var(--card-border); }
.ctrl-row:first-child { border-top: none; }
.ctrl-info { flex: 1; margin-right: 12px; }
.ctrl-title { font-size: 0.95rem; font-weight: 600; display: flex; align-items: center; gap: 6px; }
.ctrl-desc { font-size: 0.75rem; color: var(--text2); margin-top: 2px; }

/* Switch */
.switch { position: relative; display: inline-block; width: 48px; height: 26px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #334155; transition: .3s; border-radius: 26px; }
.slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px; background-color: white; transition: .3s; border-radius: 50%; }
input:checked + .slider { background-color: var(--success); }
input:checked + .slider:before { transform: translateX(22px); }

/* Inputs */
.param-group { display: flex; align-items: center; gap: 8px; margin-top: 8px; font-size: 0.85rem; color: var(--text2); }
.num-input { background: #0c1220; border: 1px solid var(--card-border); color: var(--text); border-radius: 6px; padding: 5px 8px; font-size: 0.85rem; width: 64px; text-align: center; }

/* Buttons */
.btn { background: var(--accent); color: white; border: none; padding: 10px 16px; font-size: 14px; font-weight: 600; border-radius: 8px; cursor: pointer; width: 100%; transition: 0.2s; display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.btn:hover { background: var(--accent-hover); }
.btn:active { transform: scale(0.98); }
.btn.danger { background: var(--danger); }
.btn.secondary { background: #334155; color: #e2e8f0; }
.btn.secondary:hover { background: #475569; }
.btn.sm { padding: 5px 12px; font-size: 0.8rem; width: auto; }

/* Alerts & Tags */
.alert { background: rgba(239,68,68,0.15); border: 1px solid var(--danger); color: #fca5a5; padding: 12px; border-radius: 10px; margin-bottom: 14px; font-size: 0.85rem; }
.file-box { background: #0c1220; padding: 12px; border: 1px dashed var(--card-border); border-radius: 8px; margin-bottom: 10px; }
.tag { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
.tag.green { background: rgba(16,185,129,0.2); color: var(--success); }
.tag.gray { background: rgba(148,163,184,0.2); color: var(--text2); }
.tag.yellow { background: rgba(245,158,11,0.2); color: var(--warning); }
.tag.red { background: rgba(239,68,68,0.2); color: var(--danger); }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🐢 Autofeed 2.2</h1>
<span class="badge">局域网直连控制</span>
</div>

<!-- 异常报警栏 -->
<div id="alertBox" style="display:none;" class="alert">
⚠️ <strong>水泵保护已触发 (进水连续超过5分钟)</strong>!
<button class="btn danger sm" style="margin-top:8px;" onclick="resetPumpError()">复位报警并恢复</button>
</div>

<!-- 实时状态概览 -->
<div class="card">
<div class="card-title">
<span>📊 传感器与系统状态</span>
<span id="stTime" style="font-size:0.75rem;color:var(--text2);font-weight:normal;">--:--:--</span>
</div>
<div class="status-grid">
<div class="status-item">
<div class="label">低水位传感器</div>
<div class="value" id="stWaterLow">--</div>
</div>
<div class="status-item">
<div class="label">满水传感器</div>
<div class="value" id="stWaterHigh">--</div>
</div>
<div class="status-item">
<div class="label">MQTT 云端连接</div>
<div class="value" id="stMqtt">--</div>
</div>
<div class="status-item">
<div class="label">系统保护状态</div>
<div class="value" id="stProtect">正常</div>
</div>
</div>
</div>

<!-- 设备控制与开关 -->
<div class="card">
<div class="card-title">🎛️ 设备开关与控制</div>

<!-- 自动喂食/电机控制 -->
<div class="ctrl-row">
<div class="ctrl-info">
<div class="ctrl-title">
<span>🐢 定时喂食总开关</span>
<span id="tagMotor" class="tag gray">已关闭</span>
</div>
<div class="ctrl-desc">按设定定时调度自动旋转喂料</div>
<div class="param-group">
<span>每次运行:</span>
<input type="number" id="inpMotorSec" class="num-input" min="1" max="300" value="30">
<span>秒</span>
<button class="btn secondary sm" onclick="saveMotorSec()">保存</button>
</div>
</div>
<label class="switch">
<input type="checkbox" id="swMotor" onchange="toggleMotor(this.checked)">
<span class="slider"></span>
</label>
</div>

<div style="padding: 10px 0;">
<button class="btn" onclick="triggerFeed()">⚡ 立即手动喂食一次 (点动电机)</button>
</div>

<!-- 进水泵控制 -->
<div class="ctrl-row">
<div class="ctrl-info">
<div class="ctrl-title">
<span>💧 自动进水泵 (水位感应)</span>
<span id="tagPumpIn" class="tag gray">已关闭</span>
</div>
<div class="ctrl-desc">低水位时自动抽水加水,满水自动停</div>
</div>
<label class="switch">
<input type="checkbox" id="swPumpIn" onchange="togglePumpIn(this.checked)">
<span class="slider"></span>
</label>
</div>

<!-- 循环泵控制 -->
<div class="ctrl-row">
<div class="ctrl-info">
<div class="ctrl-title">
<span>🔄 循环泵 (间歇循环)</span>
<span id="tagPumpOut" class="tag gray">已关闭</span>
</div>
<div class="ctrl-desc">间歇性抽水循环过滤</div>
<div class="param-group">
<span>开</span>
<input type="number" id="inpPumpOn" class="num-input" min="1" max="3600" value="30">
<span>秒 / 关</span>
<input type="number" id="inpPumpOff" class="num-input" min="1" max="3600" value="30">
<span>秒</span>
<button class="btn secondary sm" onclick="savePumpOutSec()">保存</button>
</div>
</div>
<label class="switch">
<input type="checkbox" id="swPumpOut" onchange="togglePumpOut(this.checked)">
<span class="slider"></span>
</label>
</div>
</div>

<!-- OTA 固件升级 -->
<div class="card">
<div class="card-title">📦 OTA 固件空中升级</div>
<form id="upload_form" enctype="multipart/form-data">
<div class="file-box">
<input type="file" id="firmware_file" name="update" accept=".bin" style="width:100%;color:#cbd5e1;" required>
</div>
<button type="submit" class="btn" id="upload_btn">上传并刷写固件 (.bin)</button>
</form>
<div id="uploadStatus" style="margin-top:10px;font-size:0.85rem;color:var(--text2)"></div>
</div>
</div>

<script>
let isUserInteracting = false;

async function loadStatus() {
if (isUserInteracting) return;
try {
const res = await fetch('/api/status');
const d = await res.json();

// 传感器
document.getElementById('stWaterLow').innerHTML = d.waterLow ? '<span class="tag red">缺水(低水位)</span>' : '<span class="tag green">正常</span>';
document.getElementById('stWaterHigh').innerHTML = d.waterHigh ? '<span class="tag yellow">已满</span>' : '<span class="tag gray">未满</span>';
document.getElementById('stMqtt').innerHTML = d.mqtt ? '<span class="tag green">已连接</span>' : '<span class="tag gray">未连接</span>';
document.getElementById('stProtect').innerHTML = d.pumpError ? '<span class="tag red">⚠️ 超时报警</span>' : '<span class="tag green">正常</span>';
document.getElementById('stTime').textContent = d.time || '--';
document.getElementById('alertBox').style.display = d.pumpError ? 'block' : 'none';

// 电机
document.getElementById('swMotor').checked = !!d.motor.enabled;
document.getElementById('tagMotor').className = 'tag ' + (d.motor.running ? 'green' : (d.motor.enabled ? 'yellow' : 'gray'));
document.getElementById('tagMotor').textContent = d.motor.running ? '喂食中...' : (d.motor.enabled ? '已启用' : '已关闭');
if (document.activeElement !== document.getElementById('inpMotorSec')) {
document.getElementById('inpMotorSec').value = d.motor.runSeconds || 30;
}

// 进水泵
document.getElementById('swPumpIn').checked = !!d.pumpIn.enabled;
document.getElementById('tagPumpIn').className = 'tag ' + (d.pumpIn.running ? 'green' : (d.pumpIn.enabled ? 'yellow' : 'gray'));
document.getElementById('tagPumpIn').textContent = d.pumpIn.running ? '进水中...' : (d.pumpIn.enabled ? '已启用(自动)' : '已关闭');

// 循环泵
document.getElementById('swPumpOut').checked = !!d.pumpOut.enabled;
document.getElementById('tagPumpOut').className = 'tag ' + (d.pumpOut.running ? 'green' : (d.pumpOut.enabled ? 'yellow' : 'gray'));
document.getElementById('tagPumpOut').textContent = d.pumpOut.running ? '抽水中...' : (d.pumpOut.enabled ? '已启用(循环)' : '已关闭');
if (document.activeElement !== document.getElementById('inpPumpOn')) {
document.getElementById('inpPumpOn').value = d.pumpOut.onSeconds || 30;
}
if (document.activeElement !== document.getElementById('inpPumpOff')) {
document.getElementById('inpPumpOff').value = d.pumpOut.offSeconds || 30;
}
} catch(e) {}
}

async function sendCmd(cmd) {
isUserInteracting = true;
try {
await fetch('/api/cmd', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(cmd)
});
} finally {
setTimeout(() => { isUserInteracting = false; loadStatus(); }, 300);
}
}

function toggleMotor(checked) { sendCmd({ motor: { enabled: checked } }); }
function saveMotorSec() {
const sec = parseInt(document.getElementById('inpMotorSec').value) || 30;
sendCmd({ motor: { runSeconds: sec } });
alert('已保存单次喂食运行时长: ' + sec + ' 秒');
}

function togglePumpIn(checked) { sendCmd({ pumpIn: { enabled: checked } }); }

function togglePumpOut(checked) { sendCmd({ pumpOut: { enabled: checked } }); }
function savePumpOutSec() {
const onSec = parseInt(document.getElementById('inpPumpOn').value) || 30;
const offSec = parseInt(document.getElementById('inpPumpOff').value) || 30;
sendCmd({ pumpOut: { onSeconds: onSec, offSeconds: offSec } });
alert('已保存循环泵参数: 开启 ' + onSec + 's / 停止 ' + offSec + 's');
}

function triggerFeed() { sendCmd({ action: 'feedNow' }); }
function resetPumpError() { sendCmd({ resetError: true }); }

setInterval(loadStatus, 2000);
loadStatus();

// OTA 上传
const form = document.getElementById('upload_form');
form.addEventListener('submit', function(e) {
e.preventDefault();
const fileInput = document.getElementById('firmware_file');
if (!fileInput.files.length) return;
const formData = new FormData();
formData.append('update', fileInput.files[0]);
document.getElementById('upload_btn').disabled = true;
document.getElementById('uploadStatus').textContent = '固件上传刷写中,请勿断电...';
const xhr = new XMLHttpRequest();
xhr.open('POST', '/update', true);
xhr.onload = function() {
if (xhr.status === 200) {
document.getElementById('uploadStatus').textContent = '升级成功!设备正在重启,10秒后刷新...';
setTimeout(() => location.reload(), 10000);
} else {
document.getElementById('uploadStatus').textContent = '升级失败: ' + xhr.responseText;
document.getElementById('upload_btn').disabled = false;
}
};
xhr.send(formData);
});
</script>
</body>
</html>
)rawliteral";

void setupWebServer() {
if (webServerStarted)
return;

server.on("/", HTTP_GET,
[]() { server.send_P(200, "text/html", indexHtml); });

server.on("/api/status", HTTP_GET, []() {
struct tm timeinfo;
char timeStr[30] = "N/A";
if (getLocalTime(&timeinfo)) {
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo);
}

JsonDocument doc;
JsonObject motor = doc["motor"].to<JsonObject>();
motor["enabled"] = motorCfg.enabled;
motor["running"] = motorRunning;
motor["runSeconds"] = motorCfg.runSeconds;

JsonObject pumpIn = doc["pumpIn"].to<JsonObject>();
pumpIn["enabled"] = pumpInCfg.enabled;
pumpIn["running"] = pumpInRunning;

JsonObject pumpOut = doc["pumpOut"].to<JsonObject>();
pumpOut["enabled"] = pumpOutCfg.enabled;
pumpOut["running"] = pumpOutRunning;
pumpOut["onSeconds"] = pumpOutCfg.onSeconds;
pumpOut["offSeconds"] = pumpOutCfg.offSeconds;

doc["waterLow"] = waterLow;
doc["waterHigh"] = waterHigh;
doc["pumpError"] = pumpError;
doc["mqtt"] = mqtt.connected();
doc["time"] = timeStr;

String json;
serializeJson(doc, json);
server.send(200, "application/json", json);
});

server.on("/api/cmd", HTTP_POST, []() {
if (server.hasArg("plain")) {
String body = server.arg("plain");
JsonDocument doc;
deserializeJson(doc, body);
if (doc.containsKey("action") && doc["action"] == "feedNow") {
startMotor();
}
if (doc.containsKey("resetError") && doc["resetError"].as<bool>()) {
pumpError = false;
Serial.println("局域网请求: 水泵故障状态已复位");
}
applyCommand(body.c_str());
}
server.send(200, "application/json", "{\"status\":\"ok\"}");
});

// OTA 处理
server.on(
"/update", HTTP_POST,
[]() {
server.send(Update.hasError() ? 500 : 200, "text/plain",
Update.hasError() ? "OTA 升级失败" : "OK");
delay(500);
ESP.restart();
},
[]() {
HTTPUpload &upload = server.upload();
if (upload.status == UPLOAD_FILE_START) {
Serial.printf("OTA 升级开始: %s\n", upload.filename.c_str());
if (!Update.begin(UPDATE_SIZE_UNKNOWN)) {
Update.printError(Serial);
}
} else if (upload.status == UPLOAD_FILE_WRITE) {
if (Update.write(upload.buf, upload.currentSize) !=
upload.currentSize) {
Update.printError(Serial);
}
} else if (upload.status == UPLOAD_FILE_END) {
if (Update.end(true)) {
Serial.printf("OTA 升级成功! 字节数: %u\n", upload.totalSize);
} else {
Update.printError(Serial);
}
}
});

server.begin();
webServerStarted = true;
Serial.printf("局域网 Web 控制台已启动: http://%s/\n",
WiFi.localIP().toString().c_str());
}

// ==================== MQTT 设置 ====================
void setupMQTT() {
espClient.setInsecure(); // 跳过证书验证 (EMQX TLS)
espClient.setTimeout(2); // 设置 2 秒超时,避免握手异常时长时间阻塞主循环
mqtt.setServer(MQTT_BROKER, MQTT_PORT);
mqtt.setCallback(mqttCallback);
mqtt.setBufferSize(1024);
mqtt.setKeepAlive(30);
reconnectMQTT();
}

void reconnectMQTT() {
if (mqtt.connected())
return;

Serial.print("连接 EMQX MQTT...");
// 传入 ClientID, User, Pass
if (mqtt.connect(MQTT_CLIENT, MQTT_USER, MQTT_PASS)) {
Serial.println("连接成功!");
mqtt.subscribe(TOPIC_CMD);
Serial.printf("已订阅命令主题: %s\n", TOPIC_CMD);
reportStatus();
} else {
Serial.printf("连接失败, rc=%d\n", mqtt.state());
}
}

// ==================== MQTT 回调 ====================
void mqttCallback(char *topic, byte *payload, unsigned int length) {
char json[1024];
if (length >= sizeof(json))
length = sizeof(json) - 1;
memcpy(json, payload, length);
json[length] = '\0';

Serial.printf("收到 MQTT 命令 [%s]: %s\n", topic, json);

if (String(topic) == TOPIC_CMD) {
applyCommand(json);
}
}

// ==================== 解析并应用命令 ====================
void applyCommand(const char *json) {
JsonDocument doc;
DeserializationError err = deserializeJson(doc, json);
if (err) {
Serial.printf("JSON 解析失败: %s\n", err.c_str());
return;
}

// 手动单次喂食动作
if (doc.containsKey("action") && doc["action"] == "feedNow") {
startMotor();
}

// 重置水泵错误
if (doc.containsKey("resetError") && doc["resetError"].as<bool>()) {
pumpError = false;
Serial.println("水泵保护状态已复位");
}

// 电机配置
if (doc.containsKey("motor")) {
JsonObject motor = doc["motor"];
if (motor.containsKey("enabled")) {
motorCfg.enabled = motor["enabled"].as<bool>();
if (!motorCfg.enabled) {
stopMotor();
}
}
if (motor.containsKey("runSeconds")) {
motorCfg.runSeconds = motor["runSeconds"].as<int>();
if (motorCfg.runSeconds < 1)
motorCfg.runSeconds = 1;
}
if (motor.containsKey("schedule")) {
JsonObject schedule = motor["schedule"];
if (schedule.containsKey("intervalDays")) {
motorCfg.intervalDays = schedule["intervalDays"].as<int>();
if (motorCfg.intervalDays < 1)
motorCfg.intervalDays = 1;
}
if (schedule.containsKey("times")) {
JsonArray times = schedule["times"];
motorCfg.timeCount = 0;
for (int i = 0; i < times.size() && i < 10; i++) {
motorCfg.times[i] = times[i].as<String>();
motorCfg.timeCount++;
}
// 重置今日调度标记
for (int i = 0; i < 10; i++) {
scheduledToday[i] = false;
}
}
}
}

// 进水泵配置
if (doc.containsKey("pumpIn")) {
JsonObject pumpIn = doc["pumpIn"];
if (pumpIn.containsKey("enabled")) {
pumpInCfg.enabled = pumpIn["enabled"].as<bool>();
if (!pumpInCfg.enabled) {
digitalWrite(PUMP_IN_PIN, LOW);
pumpInRunning = false;
} else {
pumpError = false; // 用户重新开启时自动清除报警
}
}
}

// 循环泵配置
if (doc.containsKey("pumpOut")) {
JsonObject pumpOut = doc["pumpOut"];
if (pumpOut.containsKey("enabled")) {
pumpOutCfg.enabled = pumpOut["enabled"].as<bool>();
if (!pumpOutCfg.enabled) {
digitalWrite(PUMP_OUT_PIN, LOW);
pumpOutRunning = false;
} else {
// 启动循环
pumpOutCycleStart = millis();
pumpOutCycleState = true;
digitalWrite(PUMP_OUT_PIN, HIGH);
pumpOutRunning = true;
}
}
if (pumpOut.containsKey("onSeconds")) {
pumpOutCfg.onSeconds = pumpOut["onSeconds"].as<int>();
if (pumpOutCfg.onSeconds < 1)
pumpOutCfg.onSeconds = 1;
}
if (pumpOut.containsKey("offSeconds")) {
pumpOutCfg.offSeconds = pumpOut["offSeconds"].as<int>();
if (pumpOutCfg.offSeconds < 1)
pumpOutCfg.offSeconds = 1;
}
}

// 保存配置到 Flash
saveConfig();
Serial.println("配置已更新并持久化保存");

// 立即上报最新状态
reportStatus();
}

// ==================== 电机调度 ====================
void handleMotorSchedule() {
if (!motorCfg.enabled || motorCfg.timeCount == 0)
return;
if (motorRunning)
return;

unsigned long now = millis();
if (now - lastScheduleCheck < SCHEDULE_INTERVAL)
return;
lastScheduleCheck = now;

struct tm timeinfo;
if (!getLocalTime(&timeinfo))
return;

int today = timeinfo.tm_yday;

// 跨天重置调度记录
if (today != lastCheckedDay) {
lastCheckedDay = today;
for (int i = 0; i < 10; i++) {
scheduledToday[i] = false;
}
}

// 检查间隔天数
if (motorCfg.intervalDays > 1 && (today % motorCfg.intervalDays != 0)) {
return;
}

// 当前时间 "HH:MM"
char currentTime[6];
snprintf(currentTime, sizeof(currentTime), "%02d:%02d", timeinfo.tm_hour,
timeinfo.tm_min);

for (int i = 0; i < motorCfg.timeCount; i++) {
if (scheduledToday[i])
continue;

if (motorCfg.times[i] == String(currentTime)) {
Serial.printf("定时喂食调度触发: %s\n", currentTime);
scheduledToday[i] = true;
startMotor();
break;
}
}
}

// ==================== 电机控制 ====================
void startMotor() {
if (motorRunning)
return;
Serial.println("电机启动 (喂食中)");
digitalWrite(PWM1_PIN, HIGH);
digitalWrite(PWM2_PIN, LOW);
motorRunning = true;
motorStartTime = millis();
reportStatus();
}

void stopMotor() {
Serial.println("电机停止");
digitalWrite(PWM1_PIN, LOW);
digitalWrite(PWM2_PIN, LOW);
motorRunning = false;
}

void updateMotor() {
if (!motorRunning)
return;

unsigned long elapsed = millis() - motorStartTime;
if (elapsed >= (unsigned long)motorCfg.runSeconds * 1000UL) {
stopMotor();
reportStatus();
}
}

// ==================== 进水泵控制 ====================
void updatePumpIn() {
// 如果处于超时保护状态或未启用,则确保进水泵关闭
if (pumpError || !pumpInCfg.enabled) {
if (pumpInRunning) {
digitalWrite(PUMP_IN_PIN, LOW);
pumpInRunning = false;
}
return;
}

// 水位逻辑:
// waterHigh=true → 水满,停止进水
// waterLow=true → 水位低,开启进水
if (waterHigh) {
if (pumpInRunning) {
Serial.println("水位已满,进水泵停止");
digitalWrite(PUMP_IN_PIN, LOW);
pumpInRunning = false;
reportStatus();
}
} else if (waterLow) {
if (!pumpInRunning) {
Serial.println("检测到低水位,启动进水泵");
digitalWrite(PUMP_IN_PIN, HIGH);
pumpInRunning = true;
pumpInStartTime = millis();
reportStatus();
}
}

// 5分钟连续抽水超时安全保护 (防止无水干抽或浮球卡死)
if (pumpInRunning) {
if (millis() - pumpInStartTime > 300000UL) { // 300秒 (5分钟)
Serial.println("!!! 警告: 进水持续超过5分钟,触发防干抽保护停止水泵 !!!");
digitalWrite(PUMP_IN_PIN, LOW);
pumpInRunning = false;
pumpError = true; // 仅置保护标志,不篡改持久化的 user 配置
reportStatus();
}
}
}

// ==================== 循环泵控制 ====================
void updatePumpOut() {
// 若进水泵严重报警或未启用,停止循环泵
if (pumpError || !pumpOutCfg.enabled) {
if (pumpOutRunning) {
digitalWrite(PUMP_OUT_PIN, LOW);
pumpOutRunning = false;
}
return;
}

unsigned long now = millis();
unsigned long elapsed = now - pumpOutCycleStart;

if (pumpOutCycleState) {
// 开启阶段
if (elapsed >= (unsigned long)pumpOutCfg.onSeconds * 1000UL) {
pumpOutCycleState = false;
pumpOutCycleStart = now;
digitalWrite(PUMP_OUT_PIN, LOW);
pumpOutRunning = false;
}
} else {
// 关闭阶段
if (elapsed >= (unsigned long)pumpOutCfg.offSeconds * 1000UL) {
pumpOutCycleState = true;
pumpOutCycleStart = now;
digitalWrite(PUMP_OUT_PIN, HIGH);
pumpOutRunning = true;
}
}
}

// ==================== 上报状态 ====================
void reportStatus() {
if (!mqtt.connected())
return;

struct tm timeinfo;
char timeStr[30] = "N/A";
if (getLocalTime(&timeinfo)) {
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo);
}

JsonDocument doc;

JsonObject motor = doc["motor"].to<JsonObject>();
motor["enabled"] = motorCfg.enabled;
motor["running"] = motorRunning;
motor["runSeconds"] = motorCfg.runSeconds;
JsonObject schedule = motor["schedule"].to<JsonObject>();
schedule["intervalDays"] = motorCfg.intervalDays;
JsonArray times = schedule["times"].to<JsonArray>();
for (int i = 0; i < motorCfg.timeCount; i++) {
times.add(motorCfg.times[i]);
}

JsonObject pumpIn = doc["pumpIn"].to<JsonObject>();
pumpIn["enabled"] = pumpInCfg.enabled;
pumpIn["running"] = pumpInRunning;

JsonObject pumpOut = doc["pumpOut"].to<JsonObject>();
pumpOut["enabled"] = pumpOutCfg.enabled;
pumpOut["running"] = pumpOutRunning;
pumpOut["onSeconds"] = pumpOutCfg.onSeconds;
pumpOut["offSeconds"] = pumpOutCfg.offSeconds;

doc["waterLow"] = waterLow;
doc["waterHigh"] = waterHigh;
doc["pumpError"] = pumpError;
doc["time"] = timeStr;
doc["wifi"] = (WiFi.status() == WL_CONNECTED);
doc["ip"] = WiFi.localIP().toString();
doc["otaUrl"] = "http://" + WiFi.localIP().toString() + "/";

char buf[600];
serializeJson(doc, buf, sizeof(buf));
mqtt.publish(TOPIC_STATUS, buf);
}

// ==================== 配置持久化 ====================
void saveConfig() {
prefs.begin("config", false);

prefs.putBool("mEnabled", motorCfg.enabled);
prefs.putInt("mRunSec", motorCfg.runSeconds);
prefs.putInt("mIntDays", motorCfg.intervalDays);
prefs.putInt("mTimeCnt", motorCfg.timeCount);
for (int i = 0; i < motorCfg.timeCount; i++) {
char key[8];
snprintf(key, sizeof(key), "mT%d", i);
prefs.putString(key, motorCfg.times[i]);
}

prefs.putBool("piEnabled", pumpInCfg.enabled);

prefs.putBool("poEnabled", pumpOutCfg.enabled);
prefs.putInt("poOnSec", pumpOutCfg.onSeconds);
prefs.putInt("poOffSec", pumpOutCfg.offSeconds);

prefs.end();
}

void loadConfig() {
prefs.begin("config", true);

motorCfg.enabled = prefs.getBool("mEnabled", false);
motorCfg.runSeconds = prefs.getInt("mRunSec", 30);
motorCfg.intervalDays = prefs.getInt("mIntDays", 1);
motorCfg.timeCount = prefs.getInt("mTimeCnt", 0);
for (int i = 0; i < motorCfg.timeCount && i < 10; i++) {
char key[8];
snprintf(key, sizeof(key), "mT%d", i);
motorCfg.times[i] = prefs.getString(key, "");
}

pumpInCfg.enabled = prefs.getBool("piEnabled", false);

pumpOutCfg.enabled = prefs.getBool("poEnabled", false);
pumpOutCfg.onSeconds = prefs.getInt("poOnSec", 30);
pumpOutCfg.offSeconds = prefs.getInt("poOffSec", 30);

prefs.end();
Serial.println("配置已从 Flash 加载");
}


Cloudflare Workers 控制面板

js 单文件,内含完整 HTML/CSS/JS :

  1. Web 界面 — 控制面板 UI

    • 电机控制区:开关、运行秒数、调度设置(间隔天数 + 多时间点)
    • 进水泵控制区:开关
    • 循环泵控制区:开关、开启秒数、关闭秒数
    • 设备状态区:实时显示各组件运行状态、水位、时间
  2. MQTT 通信 — 浏览器端通过 WebSocket 连接 HiveMQ

    • 使用 mqtt.js CDN 版在浏览器内直连 HiveMQ WebSocket 端口 (8884)
    • 发送命令到 autofeed/cmd
    • 订阅 autofeed/status 实时显示状态
  3. Worker 路由fetch handler 返回 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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
// Autofeed 2.2 — Cloudflare Worker 控制面板
// 部署方式: wrangler deploy 或在 Cloudflare Dashboard 中粘贴此代码

export default {
async fetch(request) {
return new Response(HTML_CONTENT, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
},
};

const HTML_CONTENT = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="https://fav.farm/🐢" />
<title>Autofeed 控制面板</title>
<meta name="description" content="ESP32 远程控制面板">
<link rel="preconnect" href="https://googlefonts.mirrors.sjtug.sjtu.edu.cn">
<link href="https://googlefonts.mirrors.sjtug.sjtu.edu.cn/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- 稳定国内高速 CDN 并带 fallback -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/mqtt/5.3.5/mqtt.min.js"></script>
<script>
if (typeof mqtt === 'undefined') {
document.write('<script src="https://cdn.jsdelivr.net/npm/mqtt@5/dist/mqtt.min.js"><\\/script>');
}
</script>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #242836;
--border: #2e3348;
--text: #e4e6f0;
--text2: #8b8fa8;
--accent: #6c5ce7;
--accent-glow: rgba(108,92,231,0.3);
--green: #00b894;
--green-glow: rgba(0,184,148,0.3);
--red: #e17055;
--red-glow: rgba(225,112,85,0.3);
--blue: #0984e3;
--blue-glow: rgba(9,132,227,0.3);
--yellow: #fdcb6e;
--radius: 16px;
--radius-sm: 10px;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 20px;
}

.container {
max-width: 680px;
margin: 0 auto;
}

/* Header */
.header {
text-align: center;
padding: 30px 0 20px;
}
.header h1 {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.header h1 span {
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
background: var(--surface);
border: 1px solid var(--border);
transition: all 0.3s;
}
.status-badge.connected {
border-color: var(--green);
box-shadow: 0 0 12px var(--green-glow);
}
.status-badge.disconnected {
border-color: var(--red);
box-shadow: 0 0 12px var(--red-glow);
}
.status-badge.connecting {
border-color: var(--yellow);
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--red);
transition: background 0.3s;
}
.status-badge.connected .status-dot { background: var(--green); }
.status-badge.connecting .status-dot { background: var(--yellow); }

/* Alert Card */
.alert-banner {
background: rgba(225, 112, 85, 0.15);
border: 1px solid var(--red);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 16px;
display: none;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.alert-banner.show { display: flex; }
.alert-text { font-size: 0.88rem; color: #fab1a0; }
.btn-reset {
background: var(--red);
color: white;
border: none;
padding: 8px 14px;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
}

/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 16px;
transition: border-color 0.3s, box-shadow 0.3s;
}
.card:hover {
border-color: rgba(108,92,231,0.4);
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.card-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.05rem;
font-weight: 600;
}
.card-icon {
font-size: 1.3rem;
}
.running-badge {
font-size: 0.7rem;
padding: 3px 10px;
border-radius: 12px;
font-weight: 600;
background: var(--green-glow);
color: var(--green);
display: none;
}
.running-badge.active { display: inline-block; }

/* Toggle */
.toggle {
position: relative;
width: 52px; height: 28px;
cursor: pointer;
}
.toggle input { display: none; }
.toggle .slider {
position: absolute;
inset: 0;
background: var(--surface2);
border: 2px solid var(--border);
border-radius: 14px;
transition: all 0.3s;
}
.toggle .slider::before {
content: '';
position: absolute;
width: 20px; height: 20px;
left: 2px; top: 2px;
background: var(--text2);
border-radius: 50%;
transition: all 0.3s;
}
.toggle input:checked + .slider {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
}
.toggle input:checked + .slider::before {
transform: translateX(24px);
background: white;
}

/* Form controls */
.field-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field.full { grid-column: 1 / -1; }
.field label {
font-size: 0.78rem;
color: var(--text2);
font-weight: 500;
}
.field input, .field select {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
color: var(--text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.field input:focus, .field select:focus {
border-color: var(--accent);
}

/* Time list */
.time-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.time-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
}
.time-tag .remove {
cursor: pointer;
color: var(--red);
font-size: 1rem;
line-height: 1;
opacity: 0.7;
transition: opacity 0.2s;
}
.time-tag .remove:hover { opacity: 1; }

.add-time-row {
display: flex;
gap: 8px;
margin-top: 10px;
}
.add-time-row input {
flex: 1;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text);
font-family: inherit;
outline: none;
}
.btn-add {
background: var(--accent);
border: none;
border-radius: var(--radius-sm);
padding: 8px 16px;
color: white;
font-weight: 600;
cursor: pointer;
font-size: 0.85rem;
transition: opacity 0.2s;
}
.btn-add:hover { opacity: 0.85; }

/* Buttons */
.btn-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 20px;
}
.btn-send {
padding: 14px;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
border: none;
border-radius: var(--radius-sm);
color: white;
font-size: 0.95rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
}
.btn-feed {
padding: 14px;
background: linear-gradient(135deg, #0984e3, #74b9ff);
border: none;
border-radius: var(--radius-sm);
color: white;
font-size: 0.95rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
}
.btn-send:hover, .btn-feed:hover { opacity: 0.9; }
.btn-send:active, .btn-feed:active { transform: scale(0.98); }

/* Status panel */
.status-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.status-item {
background: var(--surface2);
border-radius: var(--radius-sm);
padding: 14px;
}
.status-item .label {
font-size: 0.75rem;
color: var(--text2);
margin-bottom: 4px;
}
.status-item .value {
font-size: 0.95rem;
font-weight: 600;
}
.status-item .value.on { color: var(--green); }
.status-item .value.off { color: var(--text2); }
.status-item .value.warn { color: var(--yellow); }
.status-item.full { grid-column: 1 / -1; }

/* Toast */
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--surface);
border: 1px solid var(--accent);
color: var(--text);
padding: 12px 24px;
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 500;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
opacity: 0;
transition: all 0.3s;
z-index: 100;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}

/* Responsive */
@media (max-width: 500px) {
body { padding: 12px; }
.header h1 { font-size: 1.5rem; }
.field-group { grid-template-columns: 1fr; }
.status-grid { grid-template-columns: 1fr; }
.btn-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">

<!-- Header -->
<div class="header">
<h1>🐢 <span>Autofeed 控制面板</span></h1>
<div class="status-badge disconnected" id="mqttBadge">
<span class="status-dot"></span>
<span id="mqttStatusText">初始化中...</span>
</div>
</div>

<!-- 水泵报警横幅 -->
<div class="alert-banner" id="alertBanner">
<div class="alert-text">
<strong>⚠️ 水泵保护已触发</strong><br>进水持续抽水达 5 分钟已自动停止。请检查水桶水量与传感器。
</div>
<button class="btn-reset" onclick="resetPumpAlert()">复位保护</button>
</div>

<!-- 电机控制 -->
<div class="card" id="motorCard">
<div class="card-header">
<div class="card-title">
<span class="card-icon">⚙️</span> 电机控制
<span class="running-badge" id="motorRunBadge">运行中</span>
</div>
<label class="toggle">
<input type="checkbox" id="motorEnabled">
<span class="slider"></span>
</label>
</div>
<div class="field-group">
<div class="field">
<label>每次运行时长 (秒)</label>
<input type="number" id="motorRunSeconds" value="30" min="1" max="3600">
</div>
<div class="field">
<label>间隔天数</label>
<select id="motorIntervalDays">
<option value="1">每天</option>
<option value="2">隔天 (每2天)</option>
<option value="3">每3天</option>
<option value="4">每4天</option>
<option value="5">每5天</option>
</select>
</div>
<div class="field full">
<label>定时启动时间</label>
<div class="time-list" id="timeList"></div>
<div class="add-time-row">
<input type="time" id="newTime" value="08:00">
<button class="btn-add" onclick="addTime()">添加</button>
</div>
</div>
</div>
</div>

<!-- 进水泵控制 -->
<div class="card">
<div class="card-header">
<div class="card-title">
<span class="card-icon">💧</span> 进水泵
<span class="running-badge" id="pumpInRunBadge">运行中</span>
</div>
<label class="toggle">
<input type="checkbox" id="pumpInEnabled">
<span class="slider"></span>
</label>
</div>
<p style="color:var(--text2);font-size:0.85rem;">开启后由低/满水位传感器自动控制进水与防干抽保护</p>
</div>

<!-- 循环泵控制 -->
<div class="card">
<div class="card-header">
<div class="card-title">
<span class="card-icon">🔄</span> 循环泵
<span class="running-badge" id="pumpOutRunBadge">运行中</span>
</div>
<label class="toggle">
<input type="checkbox" id="pumpOutEnabled">
<span class="slider"></span>
</label>
</div>
<div class="field-group">
<div class="field">
<label>开启时间 (秒)</label>
<input type="number" id="pumpOutOn" value="30" min="1" max="3600">
</div>
<div class="field">
<label>关闭时间 (秒)</label>
<input type="number" id="pumpOutOff" value="30" min="1" max="3600">
</div>
</div>
</div>

<!-- 操作按钮组 -->
<div class="btn-grid">
<button class="btn-feed" onclick="triggerFeedNow()">⚡ 立即单次喂食</button>
<button class="btn-send" onclick="sendCommand()">📡 保存并下发配置</button>
</div>

<!-- 设备状态 -->
<div class="card" style="margin-top: 16px;">
<div class="card-header">
<div class="card-title">
<span class="card-icon">📊</span> 设备状态
</div>
</div>
<div class="status-grid">
<div class="status-item">
<div class="label">电机</div>
<div class="value off" id="stMotor">--</div>
</div>
<div class="status-item">
<div class="label">进水泵</div>
<div class="value off" id="stPumpIn">--</div>
</div>
<div class="status-item">
<div class="label">循环泵</div>
<div class="value off" id="stPumpOut">--</div>
</div>
<div class="status-item">
<div class="label">WiFi / IP</div>
<div class="value off" id="stWifi">--</div>
</div>
<div class="status-item">
<div class="label">低水位报警</div>
<div class="value off" id="stWaterLow">--</div>
</div>
<div class="status-item">
<div class="label">水位已满</div>
<div class="value off" id="stWaterHigh">--</div>
</div>
<div class="status-item full">
<div class="label">设备时间</div>
<div class="value" id="stTime">--</div>
</div>
</div>
</div>

</div>

<!-- Toast -->
<div class="toast" id="toast"></div>

<script>
// ==================== EMQX 配置 ====================
const MQTT_CONFIG = {
broker: 'wss://***.emqxsl.cn:8084/mqtt',
username: 'user',
password: 'password',
clientId: 'autofeed-web-' + Math.random().toString(16).slice(2, 8),
};

const TOPIC_CMD = 'autofeed/cmd';
const TOPIC_STATUS = 'autofeed/status';

// ==================== 全局状态 ====================
let client = null;
let scheduleTimes = [];
let lastInteraction = 0; // 上次用户操作时间
let hasReceivedStatus = false; // 是否已成功从设备同步状态

// ==================== MQTT 连接 ====================
function connectMQTT() {
const badge = document.getElementById('mqttBadge');
const statusText = document.getElementById('mqttStatusText');

badge.className = 'status-badge connecting';
statusText.textContent = '连接 EMQX...';

if (typeof mqtt === 'undefined') {
badge.className = 'status-badge disconnected';
statusText.textContent = 'MQTT 脚本加载失败';
showToast('❌ MQTT 库加载失败,请检查网络');
return;
}

client = mqtt.connect(MQTT_CONFIG.broker, {
username: MQTT_CONFIG.username,
password: MQTT_CONFIG.password,
clientId: MQTT_CONFIG.clientId,
protocolVersion: 4,
clean: true,
reconnectPeriod: 4000,
connectTimeout: 10000,
});

client.on('connect', () => {
badge.className = 'status-badge connected';
statusText.textContent = '已连接';
client.subscribe(TOPIC_STATUS);
showToast('✅ 已连接 EMQX 服务器');
});

client.on('error', (err) => {
badge.className = 'status-badge disconnected';
statusText.textContent = '连接异常';
console.error('MQTT error:', err);
});

client.on('offline', () => {
badge.className = 'status-badge disconnected';
statusText.textContent = '已离线';
});

client.on('reconnect', () => {
badge.className = 'status-badge connecting';
statusText.textContent = '重连中...';
});

client.on('message', (topic, message) => {
if (topic === TOPIC_STATUS) {
try {
const data = JSON.parse(message.toString());
hasReceivedStatus = true;
updateStatusPanel(data);
} catch (e) {
console.error('Parse error:', e);
}
}
});
}

// ==================== 更新状态面板 ====================
function updateStatusPanel(data) {
setStatus('stMotor', data.motor?.running, data.motor?.enabled ? '已启用' : '已禁用');
setStatus('stPumpIn', data.pumpIn?.running, data.pumpIn?.enabled ? '已启用' : '已禁用');
setStatus('stPumpOut', data.pumpOut?.running, data.pumpOut?.enabled ? '已启用' : '已禁用');

const wifiEl = document.getElementById('stWifi');
if (data.ip) {
wifiEl.innerHTML = '<a href="' + (data.otaUrl || ('http://' + data.ip + '/')) + '" target="_blank" style="color:var(--blue);text-decoration:none;">' + data.ip + ' ↗</a>';
wifiEl.className = 'value on';
} else {
wifiEl.textContent = data.wifi ? '已连接' : '未连接';
wifiEl.className = data.wifi ? 'value on' : 'value off';
}

setStatusWarn('stWaterLow', data.waterLow);
setStatusWarn('stWaterHigh', data.waterHigh);
document.getElementById('stTime').textContent = data.time || '--';

// 报警横幅控制
const alertBanner = document.getElementById('alertBanner');
if (data.pumpError) {
alertBanner.classList.add('show');
} else {
alertBanner.classList.remove('show');
}

// 更新运行徽章
toggleBadge('motorRunBadge', data.motor?.running);
toggleBadge('pumpInRunBadge', data.pumpIn?.running);
toggleBadge('pumpOutRunBadge', data.pumpOut?.running);

// 同步控制面板与设备状态 (如果用户最近 30 秒内没有在页面编辑)
if (Date.now() - lastInteraction > 30000) {
syncUI(data);
}
}

function setStatus(id, running, fallback) {
const el = document.getElementById(id);
if (running) {
el.textContent = '运行中';
el.className = 'value on';
} else {
el.textContent = fallback || '关闭';
el.className = 'value off';
}
}

function setStatusWarn(id, active) {
const el = document.getElementById(id);
if (active) {
el.textContent = '是';
el.className = 'value warn';
} else {
el.textContent = '否';
el.className = 'value off';
}
}

function toggleBadge(id, active) {
const el = document.getElementById(id);
el.className = active ? 'running-badge active' : 'running-badge';
}

// 将设备状态同步到 UI 控件
function syncUI(data) {
if (data.motor) {
document.getElementById('motorEnabled').checked = data.motor.enabled;
document.getElementById('motorRunSeconds').value = data.motor.runSeconds || 30;
if (data.motor.schedule) {
document.getElementById('motorIntervalDays').value = data.motor.schedule.intervalDays || 1;
if (data.motor.schedule.times) {
scheduleTimes = [...data.motor.schedule.times];
renderTimes();
}
}
}
if (data.pumpIn) {
document.getElementById('pumpInEnabled').checked = data.pumpIn.enabled;
}
if (data.pumpOut) {
document.getElementById('pumpOutEnabled').checked = data.pumpOut.enabled;
document.getElementById('pumpOutOn').value = data.pumpOut.onSeconds || 30;
document.getElementById('pumpOutOff').value = data.pumpOut.offSeconds || 30;
}
}

// ==================== 时间管理 ====================
function addTime() {
const input = document.getElementById('newTime');
const time = input.value;
if (!time) return;
const formatted = time.substring(0, 5); // "HH:MM"
if (scheduleTimes.includes(formatted)) {
showToast('⚠️ 该时间点已存在');
return;
}
scheduleTimes.push(formatted);
scheduleTimes.sort();
renderTimes();
recordInteraction();
}

function removeTime(index) {
scheduleTimes.splice(index, 1);
renderTimes();
recordInteraction();
}

function renderTimes() {
const container = document.getElementById('timeList');
container.innerHTML = scheduleTimes.map((t, i) =>
'<span class="time-tag">' + t +
' <span class="remove" onclick="removeTime(' + i + ')">×</span></span>'
).join('');
}

// ==================== 发送命令 ====================
function triggerFeedNow() {
if (!client || !client.connected) {
showToast('❌ MQTT 未连接');
return;
}
client.publish(TOPIC_CMD, JSON.stringify({ action: 'feedNow' }));
showToast('⚡ 已下发手动喂食指令');
}

function resetPumpAlert() {
if (!client || !client.connected) {
showToast('❌ MQTT 未连接');
return;
}
client.publish(TOPIC_CMD, JSON.stringify({ resetError: true, pumpIn: { enabled: true } }));
showToast('🔄 已请求复位水泵保护');
}

function sendCommand() {
if (!client || !client.connected) {
showToast('❌ MQTT 未连接');
return;
}

if (!hasReceivedStatus) {
if (!confirm('设备尚未同步初始状态,是否确认下发当前配置?')) {
return;
}
}

const cmd = {
motor: {
enabled: document.getElementById('motorEnabled').checked,
runSeconds: parseInt(document.getElementById('motorRunSeconds').value) || 30,
schedule: {
intervalDays: parseInt(document.getElementById('motorIntervalDays').value) || 1,
times: [...scheduleTimes],
},
},
pumpIn: {
enabled: document.getElementById('pumpInEnabled').checked,
},
pumpOut: {
enabled: document.getElementById('pumpOutEnabled').checked,
onSeconds: parseInt(document.getElementById('pumpOutOn').value) || 30,
offSeconds: parseInt(document.getElementById('pumpOutOff').value) || 30,
},
};

client.publish(TOPIC_CMD, JSON.stringify(cmd));
showToast('✅ 配置已发送至设备');
}

// ==================== Toast ====================
function showToast(msg) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2500);
}

// ==================== 交互检测 ====================
function recordInteraction() {
lastInteraction = Date.now();
}

function initInteractionListeners() {
document.querySelectorAll('input, select').forEach(el => {
el.addEventListener('input', recordInteraction);
el.addEventListener('change', recordInteraction);
});
}

// ==================== 初始化 ====================
renderTimes();
initInteractionListeners();
connectMQTT();
</script>
</body>
</html>`;

worker_V2

目前页面使用的是 Emoji 图标,可以根据喜好自行更换:
比如主标题图标 (第 352 行左右):
<h1>🐢 <span>Autofeed 2.2</span></h1>
可以将 🐢 换成其他 Emoji。

功能卡片图标:
电机控制: ⚙️ (第 363 行左右)
进水泵: 💧 (第 401 行左右)
循环泵: 🔄 (第 416 行左右)
设备状态: 📊 (第 443 行左右)

由于 Cloudflare Worker 是单文件部署,最简单的方法是使用 Emoji Favicon。所以我在 <head> 标签内添加一行代码作为网站的 Favicon:
<link rel="icon" href="https://fav.farm/🐢" />
注:fav.farm 是一个直接把 Emoji 变成图标的开源服务,也可以使用 Base64 图片代码

手动部署

  1. 访问 dash.cloudflare.com 并登录。

  2. 创建 Worker。

    • 在左侧菜单栏选择 Workers & Pages (Workers 和页面)。
    • 点击 Create application (创建应用程序)。
    • 点击 Create Worker (创建 Worker)。
    • 给你的 Worker 起个名字(比如 autofeed),然后点击右下角的 Deploy (部署)。
  3. 部署完成后,点击编辑代码按钮。

  4. 删掉代码编辑器里所有默认的代码。将本地的 worker.js 代码全部复制粘贴进 Cloudflare 的编辑器里。

  5. 点击右上角的 Save and deploy (保存并部署)。

  6. 部署成功后,会有一个 .workers.dev 结尾的网址。点击网址就能看到 Autofeed 2.2 的控制面板了。
    当然,如果有域名的话可以绑定自己的域名。

使用前配置

我使用的是 HiveMQ,国内的有 EMQX,设置都一样。
注册账号后新建一个 Cloud Clusters,可以获得 URL 了。再添加一个 credentials,设置用户名和密码,权限选择 publish and subscribe
免费版可以支持 100 个设备,流量 10G/month。发布者,订阅者都算设备,所以 worker 也算一台。

EMQX 免费版支持 1000 台设备,连接时间 100 万分钟/月,流量 1G/month。

Arduino — 修改 Autofeed2.2.ino 顶部的 HiveMQ 凭据:

1
2
3
#define MQTT_BROKER   "your-cluster.hivemq.cloud"
#define MQTT_USER "your-username"
#define MQTT_PASS "your-password"

Worker — 修改 worker.js 中的 MQTT_CONFIG:

1
2
3
broker: 'wss://your-cluster.hivemq.cloud:8884/mqtt',
username: 'your-username',
password: 'your-password',

功能验证

  1. WiFi 配网: 上电后,通过 ESPTouch App 配网,串口监视器查看连接状态。注意,Wi-Fi 只支持 2.4G,APP 里选择 EspTouch V2,加密模式 1
  2. MQTT 连接: 填写 HiveMQ 凭据后,串口监视器确认 MQTT 连接成功
  3. Cloudflare Worker: 访问 URL 查看控制面板
  4. 远程控制: 在面板上操作开关/设置参数,观察 ESP32 串口输出及硬件响应
    注意:web 面板新修改的设置需在 30 秒内点击发送按钮,不然会刷新到当前的配置参数。
  5. 水位检测: 模拟 GPIO16/17 信号变化,观察进水泵响应
  6. 定时调度: 设置定时任务后观察电机是否按时启动

ESPTouch App 下载:https://github.com/EspressifApp/EsptouchForAndroid/releases/download/v2.4.0/esptouch-v2.4.0.apk

不知道为什么乐鑫不把 APP 放个应用市场,Google Play 里倒是有其他人开发的配网应用,不过我还没试过。

以上所有源文件在此,仅供参考,有问题自己搜索或问 AI:
https://github.com/harry10086/Autofeed