1. 项目缘起:从“看”到“测”的硬件思维转变
做开源硬件项目,尤其是面向物联网和传感器应用,很多朋友都是从点亮一个LED灯开始的。这当然没错,但当我们想解决一个实际问题时,比如监测一个鱼缸的水位、一个花盆的土壤湿度,或者像我们这节课要做的——模拟监测湖水液位,就会发现仅仅“看”是不够的,我们需要“测”。这个“测”,就是从数字世界感知物理世界的关键一步,也是开源硬件从玩具走向工具的核心。
“湖水液位监测仪”这个项目,听起来有点宏大,但我们用行空板和Python可以把它做得既小巧又实用。它的核心目标很简单:利用一个模拟量传感器,持续获取水位高度信息,并通过行空板自带的屏幕实时显示出来,当水位超过或低于某个安全阈值时,还能发出警报。这背后涉及几个关键的技术点:模拟信号的采集与处理、阈值的判断逻辑、以及人机交互界面的设计。对于初学者来说,这是理解物联网数据流(感知-处理-反馈)的一个绝佳范例。
我之所以选择行空板来做这个项目,是因为它集成了Python运行环境、丰富的GPIO接口和一块高清屏幕,省去了额外连接显示屏和配置复杂开发环境的麻烦,让我们可以专注于逻辑本身。这节课,我会带你从零开始,手把手完成硬件连接、程序编写和调试优化,让你不仅知道怎么做,更明白为什么这么做。你会发现,用Python玩转硬件,远没有想象中那么难。
2. 核心硬件选型与连接:为什么是模拟量传感器?
在开始写代码之前,我们必须先搞清楚要用什么硬件,以及为什么要这么选。这是硬件项目成败的第一步,选型错误,后面代码写得再漂亮也是徒劳。
2.1 传感器选型:数字量与模拟量的本质区别
监测水位,市面上常见的传感器有两种工作方式:数字式和模拟式。
数字式水位传感器(比如一些简单的浮球开关或导电式探头)通常只有两个状态:有水(高电平)或无水(低电平)。它就像一个开关,只能告诉你“水位到了某个点”或者“没到”,无法得知具体的水位高度。这种传感器成本低、电路简单,适合只需要警戒水位(如防止溢出)的场景。
模拟式水位传感器(如我们本次要使用的)则完全不同。它的输出是一个连续变化的电压信号。以常见的“水位压力传感器”或“液位变送器”为例,其原理通常是测量水压(静压),而水压与水深成正比。传感器内部将这个压力信号转换成一个线性的电压信号(例如0-3.3V或0-5V)。行空板的ADC(模数转换器)引脚可以读取这个电压值,并将其转换为一个数字量(比如0-4095)。通过校准,我们就能将这个数字量映射为具体的水位高度(厘米或米)。
对于“湖水液位监测”这种需要知道精确水位变化趋势的场景,模拟量传感器是唯一的选择。它让我们能从“是否”的二元判断,升级到“多少”的定量分析。
注意:市面上还有一种“电容式水位传感器”,它通过检测电容变化来感知水位,输出也可能是模拟量。但其线性度和稳定性可能不如压力式,且容易受水质影响。对于学习项目,我们常用一种更简单、低成本的“水位检测模块”,它利用一排暴露的平行导线,通过水的导电性来改变输出电阻,从而引起输出电压变化。这种模块严格来说不是标准的模拟传感器,其线性度较差,但用于原理演示和定性观察完全足够,且价格极具优势。本次教程为了普及性,将以此类模块为例进行讲解,但原理和代码与使用标准模拟传感器完全一致。
2.2 硬件清单与连接图
我们需要准备以下硬件:
- 行空板:主控核心。
- 模拟水位传感器模块:建议选用工作电压为3.3V-5V的型号,输出信号为模拟电压。
- 杜邦线:若干(公对母)。
- 一杯水:用于测试。
连接步骤非常简单,但每个引脚的选择都有其道理:
- 传感器VCC引脚->行空板的5V或3.3V引脚。我推荐先接3.3V,因为行空板的ADC参考电压是3.3V,传感器供电电压与参考电压一致时,读数最稳定。如果传感器需要5V驱动,再改用5V引脚。
- 传感器GND引脚->行空板的任意GND引脚。形成共地,这是电路正常工作的基础。
- 传感器AO(模拟输出)引脚->行空板的Pin21。这是关键!行空板上有多个ADC引脚,但最常用且方便的是Pin21。为什么是Pin21?因为在行空板的Python库(
pinpong库)的默认映射中,Pin21对应了一个易于使用的ADC通道。查看行空板官方文档可以确认这一点。
连接完成后,硬件部分就准备好了。这里有一个新手常犯的错误:把传感器的DO(数字输出)引脚接到了ADC引脚上。DO引脚只有在水位超过模块上电位器设定的阈值时才会跳变,它无法提供连续的模拟量,务必确认你连接的是AO引脚。
3. 代码实战:从读取数据到构建监测界面
硬件连接妥当后,我们进入核心的编程环节。整个过程我们将分步实现,确保每一步都理解透彻。
3.1 环境准备与基础读数
首先,我们需要在行空板上创建一个Python脚本。行空板开机后,通过局域网访问其IP地址,在Web界面中的“文件”应用里新建一个.py文件,例如water_level_monitor.py。
# 导入必要的库 import time from pinpong.board import Board, Pin from pinpong.extension.unihiker import * # 初始化行空板 Board().begin() # 初始化ADC引脚,Pin21对应行空板上的ADC0通道 adc = Pin(Pin.P21, Pin.ANALOG) print("湖水液位监测仪启动...") print("当前ADC原始值 | 计算电压值") try: while True: # 读取ADC原始值(范围通常为0-4095) adc_value = adc.read_analog() # 将ADC值转换为电压值(假设参考电压为3.3V) voltage = (adc_value / 4095) * 3.3 # 打印读数 print(f"{adc_value:>15} | {voltage:.2f} V") # 每秒读取一次 time.sleep(1) except KeyboardInterrupt: print("\n程序停止。")代码解析与原理:
Board().begin():这是pinpong库初始化板子的标准语句,必须放在最前面。Pin(Pin.P21, Pin.ANALOG):这行代码声明我们使用Pin21引脚,并将其模式设置为模拟输入(ANALOG)。此时,行空板内部的ADC模块会开始对这个引脚上的电压进行采样。adc.read_analog():这个方法触发一次ADC转换,并返回一个0-4095之间的整数。为什么是4095?因为行空板使用的ADC通常是12位精度,2^12 = 4096个等级(从0到4095),对应0V到参考电压(通常是3.3V)。- 电压转换公式
voltage = (adc_value / 4095) * 3.3是ADC计算的基础。它建立了数字量(ADC值)与模拟量(电压)之间的线性关系。
运行这段代码,将传感器的感应部分放入水中,你会看到ADC值和电压值随着水位升降而变化。这就是我们监测的“源头数据”。
3.2 标定与换算:将电压转换为水位高度
原始的电压值对我们来说没有直观意义,我们需要将其转换为以厘米为单位的水位高度。这就需要“标定”。
标定是一个简单的两步过程:
- 获取零点值:将传感器完全脱离水面(干燥状态),记录此时的ADC值或电压值。这个值对应水位为0厘米。假设读数为
adc_dry = 120。 - 获取满量程值:将传感器浸入一个已知深度的水中(例如10.0厘米),确保感应部分完全被淹没,记录此时的ADC值。假设读数为
adc_wet = 2500。
有了这两个点,我们就可以建立一个线性方程。原理是假设传感器的输出与水位深度成线性关系(对于教学模块,在大部分区间内近似成立)。
# 标定参数(请根据你的实测值修改!) ADC_DRY = 120 # 干燥时的ADC值 ADC_WET = 2500 # 浸入10.0厘米水深的ADC值 WATER_DEPTH_CALIBRATION = 10.0 # 标定时用的水深,单位:厘米 def adc_to_cm(adc_val): """将ADC值转换为水位高度(厘米)""" # 防止读数超出标定范围 if adc_val <= ADC_DRY: return 0.0 elif adc_val >= ADC_WET: return WATER_DEPTH_CALIBRATION # 线性插值公式 # 比例系数 = (当前ADC值 - 干燥ADC值) / (湿润ADC值 - 干燥ADC值) ratio = (adc_val - ADC_DRY) / (ADC_WET - ADC_DRY) # 水位 = 比例系数 * 标定水深 water_level = ratio * WATER_DEPTH_CALIBRATION return round(water_level, 1) # 保留一位小数 # 在主循环中使用 while True: adc_value = adc.read_analog() current_level = adc_to_cm(adc_value) print(f"ADC: {adc_value}, 水位: {current_level} cm") time.sleep(1)实操心得:标定是硬件项目中最容易出错也最关键的环节。务必保证标定环境稳定(传感器静止、水面平静)。对于要求不高的项目,进行一次标定即可。但如果追求精度,可以进行多次标定取平均值,甚至在不同深度取多个点进行非线性拟合。另外,传感器的特性可能会随时间、温度、水质(导电率)发生变化,定期重新标定能保证长期准确性。
3.3 阈值判断与报警逻辑
监测仪不仅要显示,还要能预警。我们设定两个阈值:高水位警报线和低水位警报线。
# 报警阈值(单位:厘米) HIGH_LEVEL_ALARM = 8.0 LOW_LEVEL_ALARM = 2.0 # 报警状态变量 alarm_status = "正常" # 可以是“正常”、“过高”、“过低” def check_alarm(level): """检查水位并更新报警状态""" global alarm_status if level >= HIGH_LEVEL_ALARM: alarm_status = "过高" elif level <= LOW_LEVEL_ALARM: alarm_status = "过低" else: alarm_status = "正常" return alarm_status # 在主循环中整合 while True: adc_value = adc.read_analog() current_level = adc_to_cm(adc_value) status = check_alarm(current_level) # 根据状态决定输出信息 if status == "过高": print(f"[警报] 水位过高: {current_level} cm") elif status == "过低": print(f"[警报] 水位过低: {current_level} cm") else: print(f"[正常] 当前水位: {current_level} cm") time.sleep(1)现在,你的监测仪已经具备了核心的“大脑”。但它还缺少一个友好的“面孔”,我们接下来用行空板的屏幕来打造它。
4. 打造图形化监测界面:UI设计与优化
行空板最大的优势之一就是其内置的屏幕和简化的GUI库(unihiker)。我们可以轻松地创建一个包含数字、文字和图形的实时显示界面。
4.1 初始化GUI与绘制静态元素
首先,我们需要导入GUI库并创建画布。
from unihiker import GUI # 创建GUI对象 gui = GUI() # 设置背景色(浅蓝色,像湖水) gui.draw_rectangle(x0=0, y0=0, x1=240, y1=320, width=0, fill="#e0f7fa") # 绘制标题 title = gui.draw_text(x=120, y=30, text="湖水液位监测仪", font_size=18, color="#006064") # 绘制水位显示框 gui.draw_rectangle(x0=40, y0=80, x1=200, y1=140, width=2, fill="#ffffff") level_text = gui.draw_text(x=120, y=110, text="--.- cm", font_size=24, color="#00796b") # 绘制状态显示框 status_bg = gui.draw_rectangle(x0=40, y0=160, x1=200, y1=200, width=2, fill="#ffffff") status_text = gui.draw_text(x=120, y=180, text="状态: 初始化", font_size=16, color="#000000") # 绘制简单的液位指示条背景 gui.draw_rectangle(x0=60, y0=220, x1=180, y1=280, width=2, fill="#e0e0e0") level_bar = gui.draw_rectangle(x0=60, y0=280, x1=60, y1=280, width=0, fill="#0288d1") # 初始高度为0 # 绘制阈值刻度线 # 高水位线 (8cm对应在条状图中的位置,假设条总高60像素,对应10cm) high_line_y = 280 - int((HIGH_LEVEL_ALARM / 10.0) * 60) gui.draw_line(x0=55, y0=high_line_y, x1=65, y1=high_line_y, width=2, color="#d32f2f") gui.draw_text(x=70, y=high_line_y-5, text=f"高:{HIGH_LEVEL_ALARM}cm", font_size=10, color="#d32f2f") # 低水位线 low_line_y = 280 - int((LOW_LEVEL_ALARM / 10.0) * 60) gui.draw_line(x0=55, y0=low_line_y, x1=65, y1=low_line_y, width=2, color="#ff9800") gui.draw_text(x=70, y=low_line_y-5, text=f"低:{LOW_LEVEL_ALARM}cm", font_size=10, color="#ff9800")这段代码绘制了所有不会频繁变化的界面元素:标题、边框、刻度线、标签。draw_rectangle、draw_text、draw_line等方法返回的都是一个对象,我们可以通过修改这些对象的属性来动态更新内容。
4.2 实现动态数据更新与报警反馈
接下来,我们要修改主循环,在每次读取传感器数据后,更新屏幕上的动态元素:水位数值、状态文字和液位指示条。
import math def update_display(level_cm, status): """更新屏幕上的所有动态元素""" # 1. 更新水位数字 level_text.config(text=f"{level_cm:.1f} cm") # 2. 更新状态文字和背景色 status_text.config(text=f"状态: {status}") if status == "过高": status_bg.config(fill="#ffcdd2") # 红色背景 status_text.config(color="#c62828") elif status == "过低": status_bg.config(fill="#fff3e0") # 橙色背景 status_text.config(color="#ef6c00") else: status_bg.config(fill="#c8e6c9") # 绿色背景 status_text.config(color="#2e7d32") # 3. 更新液位指示条 # 计算条的高度 (0-10cm 映射到 0-60像素) bar_height = int((level_cm / 10.0) * 60) bar_height = max(0, min(60, bar_height)) # 限制在0-60像素之间 bar_top_y = 280 - bar_height # 通过重新配置矩形的位置和大小来“移动”它 level_bar.config(x0=60, y0=bar_top_y, x1=180, y1=280) # 主循环整合 print("图形界面初始化完成,开始监测...") try: while True: adc_value = adc.read_analog() current_level = adc_to_cm(adc_value) status = check_alarm(current_level) # 更新屏幕显示 update_display(current_level, status) # 控制台也输出,方便调试 print(f"水位: {current_level:.1f} cm, 状态: {status}") time.sleep(0.5) # 更新频率提高到0.5秒一次,使界面更流畅 except KeyboardInterrupt: gui.clear() # 程序退出时清屏 print("监测程序已停止。")现在,一个功能完整、界面直观的湖水液位监测仪就诞生了!屏幕会实时显示水位高度、用颜色区分的状态,以及一个动态升降的液位条,条上还有你设定的高低水位警戒线。
5. 深度优化与实战避坑指南
一个能跑通的Demo和一个健壮、可用的项目之间,往往隔着无数个“坑”。下面分享几个我从实际项目中总结的优化点和避坑经验。
5.1 数据滤波:让读数更稳定
传感器读数,尤其是模拟量,难免会有随机波动或噪声。直接使用原始数据会导致显示数值跳动,影响观感和阈值判断的准确性。常用的简单滤波方法是移动平均滤波。
class MovingAverageFilter: """一个简单的移动平均滤波器""" def __init__(self, window_size=5): self.window_size = window_size self.data_queue = [] def update(self, new_value): self.data_queue.append(new_value) if len(self.data_queue) > self.window_size: self.data_queue.pop(0) # 移除最旧的数据 return sum(self.data_queue) / len(self.data_queue) # 在程序初始化部分创建滤波器 level_filter = MovingAverageFilter(window_size=5) # 窗口大小为5 # 在主循环中应用滤波 while True: adc_value = adc.read_analog() # 先对ADC原始值进行滤波,再转换为水位 filtered_adc = level_filter.update(adc_value) current_level = adc_to_cm(filtered_adc) # ... 后续逻辑不变窗口大小选择:窗口越大,曲线越平滑,但对水位变化的响应也越迟钝。对于缓慢变化的湖水水位,窗口设为5-10比较合适。你可以根据实际传感器的噪声情况调整。
5.2 报警防抖:避免临界状态频繁切换
当水位在阈值线附近轻微波动时,程序可能会在“正常”和“报警”状态之间高速切换,导致屏幕闪烁和误报警。我们需要引入一个“死区”或“迟滞”逻辑。
# 修改报警检查函数,加入迟滞 ALARM_HYSTERESIS = 0.2 # 迟滞范围,0.2厘米 def check_alarm_with_hysteresis(level, previous_status): """带迟滞的报警检查""" if previous_status == "过高": # 如果之前是过高报警,只有当水位降到低于 (高阈值 - 迟滞) 时才恢复正常 if level < (HIGH_LEVEL_ALARM - ALARM_HYSTERESIS): new_status = "正常" else: new_status = "过高" elif previous_status == "过低": # 如果之前是过低报警,只有当水位升到高于 (低阈值 + 迟滞) 时才恢复正常 if level > (LOW_LEVEL_ALARM + ALARM_HYSTERESIS): new_status = "正常" else: new_status = "过低" else: # 之前状态是正常 if level >= HIGH_LEVEL_ALARM: new_status = "过高" elif level <= LOW_LEVEL_ALARM: new_status = "过低" else: new_status = "正常" return new_status # 在主循环中需要记录上一次的状态 previous_alarm_status = "正常" while True: # ... 获取当前水位 current_level ... current_status = check_alarm_with_hysteresis(current_level, previous_alarm_status) previous_alarm_status = current_status # 更新状态记录 # ... 更新显示 ...这个逻辑确保了水位必须越过阈值线并“离开”一小段距离后,报警状态才会改变,有效避免了抖动。
5.3 传感器维护与常见问题排查
读数漂移/不准:
- 原因A:供电不稳。确保行空板供电充足(连接充电宝或电源适配器),传感器供电引脚连接牢固。可以尝试在传感器VCC和GND之间并联一个10uF-100uF的电解电容,用于滤波。
- 原因B:导线接触不良或过长。模拟信号易受干扰,尽量使用较短的杜邦线,并确保插接紧密。避免信号线与电源线平行捆扎。
- 原因C:传感器氧化或污染。对于导电式水位模块,探针长期使用可能氧化,导致电阻变化。定期清洁探针表面。对于更精密的项目,考虑使用光学或超声波水位传感器。
屏幕刷新卡顿:
- 行空板的GUI刷新需要一定资源。如果主循环中
time.sleep时间太短(如小于0.1秒),且进行了复杂的绘图操作,可能会导致界面卡顿。将刷新频率控制在0.2-0.5秒通常是一个平衡点。 - 优化
update_display函数,只更新真正需要变化的元素属性,而不是每次都重绘整个界面。
- 行空板的GUI刷新需要一定资源。如果主循环中
项目扩展思路:
- 数据记录:使用Python的
csv或sqlite3库,将时间戳和水位数据记录到文件中,用于后续分析趋势。 - 网络上报:利用行空板的Wi-Fi功能,通过MQTT协议将数据发送到物联网平台(如阿里云IoT、ThingsBoard等),实现远程监控。
- 多级报警:除了屏幕变色,可以连接一个蜂鸣器或LED,在报警时发出声音或闪光,实现声光报警。
- 校准界面:可以在屏幕上增加按钮,引导用户进行一键校准,提升产品的易用性。
- 数据记录:使用Python的
这个项目虽然以“湖水”为名,但其内核——模拟量采集、数据处理、阈值判断和本地显示——是无数物联网监测应用的通用模板。无论是监测花盆土壤湿度、鱼缸水温,还是仓库的温湿度,只需更换传感器和调整标定参数,整套代码框架都可以复用。