简介
在上一篇文章中我对当前系统的整个构成进行了概述, 虽然系统的传感器使用的比较多,但是使用的是Arduino 平台,所以对每个传感器的驱动并不复杂, 因此在本篇文章中我将完成所有组件的构建.
传感器外围部件清单
1 - 土壤湿度传感器(Analog)
2- 水流量传感器YF-S401
3- OLED 0.96
4- SHT30
5- LTR-329
6- NMOS 用于PWM驱动LED灯板
7- LED灯板
8- 继电器 (如果使用5V可以使用NMOS控制开关, 不需要使用继电器)
9- 12V 水泵 (可以调整为5V)
实物俯视图
实物平视图
OLED 显示
Flask上位机监控界面
图图表一为系统上电时 PID动态调整灯光亮度的曲线图.
系统稳定时即PID调整后的光照输出 (稳定在了亮度200)
Arduino程序设计
#include <Wire.h> #include "Adafruit_SSD1306.h" #include "Adafruit_GFX.h" #include "Adafruit_LTR329_LTR303.h" #include "Adafruit_SHT4x.h" #include <ArduinoJson.h> // OLED 屏幕配置 #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 #define OLED_RESET -1 Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire1, OLED_RESET); // 传感器对象 Adafruit_LTR329 ltr = Adafruit_LTR329(); Adafruit_SHT4x sht4; // PID 控制相关 const int pwmPin = 3; // IO2 PWM 输出 const int targetCh0 = 200; // 目标 CH0 值 float Kp = 0.18, Ki = 0.0825, Kd = 0.002; float integral = 0, last_error = 0; unsigned long lastTime = 0; volatile unsigned int pulseCount = 0; unsigned long lastFlowCalcTime = 0; float flowRate_mLps = 0; // 毫升每秒 float totalMilliLiters = 0; const int pumpPin = 4; // IO4 控制继电器 const int soilThreshold = 600; // 土壤湿度阈值(需根据实际情况调整) bool pumping = false; float pumpStartVolume = 0; volatile bool isTrunonLight = true; #define FLOW_SENSOR_PIN 2 void flowPulseISR() { pulseCount++; } void setup() { Serial.begin(115200); delay(1000); Serial.println("Initializing..."); pinMode(pwmPin, OUTPUT); // 初始化 OLED if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { Serial.println(F("SSD1306 initialization failed!")); while (true); } display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); // 初始化 LTR329 if (!ltr.begin(&Wire1)) { Serial.println("Couldn't find LTR sensor!"); while (1) delay(10); } ltr.setGain(LTR3XX_GAIN_2); ltr.setIntegrationTime(LTR3XX_INTEGTIME_100); ltr.setMeasurementRate(LTR3XX_MEASRATE_200); // 初始化 SHT40 if (!sht4.begin(&Wire1)) { Serial.println(F("SHT40 sensor not found!")); while (1); } sht4.setPrecision(SHT4X_HIGH_PRECISION); sht4.setHeater(SHT4X_NO_HEATER); pinMode(FLOW_SENSOR_PIN, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(FLOW_SENSOR_PIN), flowPulseISR, RISING); pinMode(pumpPin, OUTPUT); digitalWrite(pumpPin, LOW); // 默认关闭水泵 } void loop() { if(Serial.available() >0) { char c = Serial.read(); Serial.println(c); if(c == '1') { isTrunonLight = !isTrunonLight; } } uint16_t ch0 = 0, ch1 = 0; float temperature = 0, humidity = 0; // 读取光照 if (ltr.newDataAvailable()) { StaticJsonDocument<256> doc; bool valid = ltr.readBothChannels(ch0, ch1); if (valid && isTrunonLight) { // PID 计算 float error = targetCh0 - ch0; unsigned long now = millis(); float dt = (now - lastTime) / 1000.0; lastTime = now; integral += error * dt; float derivative = (error - last_error) / dt; last_error = error; float output = Kp * error + Ki * integral + Kd * derivative; output = constrain(output, 0, 255); analogWrite(pwmPin, (int)output); // 每 1000ms 计算一次水流量 if (millis() - lastFlowCalcTime >= 1000) { noInterrupts(); unsigned int pulses = pulseCount; pulseCount = 0; interrupts(); // 每脉冲 ≈ 2.22 毫升(YF401) flowRate_mLps = pulses * 2.22; totalMilliLiters += flowRate_mLps; lastFlowCalcTime = millis(); } display.print("PWM: "); display.println((int)output); doc["pwm"] = (int)output; }else{ analogWrite(pwmPin, 0); } // 读取温湿度 sensors_event_t temp_event, hum_event; sht4.getEvent(&hum_event, &temp_event); temperature = temp_event.temperature; humidity = hum_event.relative_humidity; // 读取土壤湿度 int soilValue = analogRead(A5); // 显示到 OLED display.clearDisplay(); display.setCursor(0, 0); display.print("Temp: "); display.print(temperature, 1); display.println(" C"); display.print("Humidity: "); display.print(humidity, 1); display.println(" %"); display.print("Light CH0: "); display.println(ch0); display.print("Target: "); display.println(targetCh0); display.print("Flow: "); display.print(flowRate_mLps, 1); display.println(" mL/s"); display.print("Total: "); display.print(totalMilliLiters, 0); display.println(" mL"); if (!pumping && soilValue > soilThreshold) { digitalWrite(pumpPin, HIGH); pumping = true; pumpStartVolume = totalMilliLiters; } // 已在泵水 -> 检查是否达到100mL if (pumping && (totalMilliLiters - pumpStartVolume >= 100.0)) { digitalWrite(pumpPin, LOW); pumping = false; } int barLength = map(soilValue, 1023, 0, 0, 100); // 显示湿度条(反映湿度) display.drawRect(0, 58, 100, 5, SSD1306_WHITE); // 条边框 display.fillRect(0, 58, barLength, 5, SSD1306_WHITE); // 填充条 display.display(); doc["temperature"] = temperature; doc["humidity"] = humidity; doc["light_ch0"] = ch0; doc["light_ch1"] = ch1; doc["target"] = targetCh0; doc["flow_mLps"] = flowRate_mLps; doc["total_mL"] = totalMilliLiters; doc["soil"] = soilValue; doc["pump"] = pumping; serializeJson(doc, Serial); Serial.println(); } delay(500); // 避免刷新过快 }
在上述的程序中对PID的调整稍微有一点麻烦, 这里有一个调整的思路就是, 首先将KI和KD都设置为0, 首先调整KP, 这里调整KP的时候可能会出现抖动的情况. 当抖动并不是很大的时候(LED闪烁频率没有那么大), 然后调整KI用来消除稳态误差. 当光照逐渐变稳定的时候再尝试调整KD来减缓超调与振荡. 此时LED的亮度已经已经稳定了. 如果觉得LED的亮度上升太慢或者下降太慢的话再逐渐改变KP的值. 到最后我系统中所调整的值为:
float Kp = 0.18, Ki = 0.0825, Kd = 0.002;
使其程序在运行的时候光照强度(可见光 + 红外光) 的亮度稳定在了 200. (上述的PID算法同样可以用于温控系统, 比如说恒温箱等)
核心代码如下所示
float error = targetCh0 - ch0; unsigned long now = millis(); float dt = (now - lastTime) / 1000.0; lastTime = now; integral += error * dt; float derivative = (error - last_error) / dt; last_error = error; float output = Kp * error + Ki * integral + Kd * derivative; output = constrain(output, 0, 255); analogWrite(pwmPin, (int)output);
Python 上位机程序设计
python的上位机是运行在虚拟的树莓派OS中的, 其中和Arduino的通讯主要是采用串口通讯. 其后端框架采用的是Flask, 支持将数据保存到数据库中.
其主要的核心逻辑即读取串口的JSON输入, 然后通过HTML的定时器定时发送HTTP请求来读取串口数据,然后使用chart.js进行绘图.
串口线程(读取Arduino串口数据)
# 串口线程函数 def read_serial(): global latest_data try: while True: line = ser.readline().decode('utf-8').strip() if line: try: data = json.loads(line) # 解析 JSON 数据 latest_data = data # 更新最新的数据 print("Received:", data) insert_data_to_db(data) # 插入数据到数据库 except json.JSONDecodeError: print("Invalid JSON:", line) except serial.SerialException as e: print("Serial error:", e) # 启动串口线程 serial_thread = threading.Thread(target=read_serial, daemon=True) serial_thread.start()
返回串口数据用于前端绘图
@app.route('/data') def get_data(): return jsonify(latest_data)
定时请求串口数据:
async function fetchData() { try { const res = await fetch('/data'); const data = await res.json(); document.getElementById("json").textContent = JSON.stringify(data, null, 2); const now = new Date().toLocaleTimeString(); if (labels.length >= maxDataPoints) { labels.shift(); lightData.shift(); tempData.shift(); humidityData.shift(); soilData.shift(); } labels.push(now); lightData.push(data.light_ch0); tempData.push(data.temperature); humidityData.push(data.humidity); soilData.push(data.soil); [lightChart, tempChart, humidityChart, soilChart].forEach((chart, i) => { chart.data.labels = labels; chart.data.datasets[0].data = [lightData, tempData, humidityData, soilData][i]; chart.update(); }); } catch (err) { console.error("Failed to fetch data:", err); } } setInterval(() => { fetchData(); fetchStats(); }, 2000);
附件
注意: 系统并不需要使用上位机程序进行监控, 不运行上位机对Arduino的执行没有任何影响