这次项目的起因有点朴素:我的小米鱼缸最近突然长了很多绿藻,缸壁像被铺了一层绿色地毯。平时到处爬的苹果螺和黑金刚螺也不太对劲:苹果螺经常爬到水面以上,黑金刚螺一周几乎不动,但 3 条斑马鱼看起来又还挺正常,照样在水里游来游去。

我把现象描述给 Gemini 和 Grok,两个模型给出的结论差不多:鱼没事不代表水质没事。螺类对水质和缺氧更敏感,绿藻爆发加上螺的异常行为,很可能是水体营养物质累积、水质恶化、夜间缺氧共同造成的。

我继续补充背景:这是小米鱼缸 1 代,过滤全天开,氧气泵全天开,滤棉也每个月清洗,甚至换过几次。听起来日常维护并不算懒。但有一个关键问题:这一年多来,我基本没主动把水抽出来换掉,只是水蒸发了就补一点纯净水。

这下 Gemini 直接给了一个很准确的词:老水综合症。

过滤棉能拦住看得见的鱼便和残渣,但拦不住溶在水里的硝酸盐、磷酸盐、有机废物。水蒸发掉的也只是水,垃圾不会跟着蒸发。更有意思的是,我一直补的是 TDS 只有 5 到 7 的纯净水,所以这不是传统意义上的"矿物质越补越多",而是鱼粮和排泄物在封闭系统里一点点把水变成了另一种浓汤。

我用小米 TDS 笔测了一下,鱼缸是 325 ppm。这个数字微妙的地方在于,它本身不算离谱,有些地区自来水也能接近这个数。但 TDS 只告诉你水里"东西很多",不告诉你这些东西是什么。在健康鱼缸里,325 ppm 可能主要是钙镁矿物质;在我这一年只补纯水、不换水的缸里,这 325 ppm 更可能是硝酸盐、磷酸盐和有机废物。

这也解释了为什么斑马鱼还能扛住,螺和黑壳虾先出问题。斑马鱼皮实,而且水质是一点点变差的;螺类和虾对水质、渗透压、酸碱缓冲更敏感。尤其是长期纯水补水还有一个隐藏问题:自来水里的 KH,也就是碳酸盐硬度和酸碱缓冲能力,没有被持续补充;鱼粮和排泄物分解产生的酸性物质却一直在积累,水体可能已经在慢慢跌酸。对螺来说,这不是"水有点脏",而是壳和身体都在承受压力。

解决办法也不复杂:换水。但又不能一上来大换。Gemini 当时给的建议是对 20L 左右的小米鱼缸,先每 2 到 3 天换 10%,也就是大约 1.8 到 2L。我第一次手动抽了 1800 mL,再用滴滤方式慢慢补进背滤里,半小时补完,水温还是掉了 2°C。

这件事就很有意思了:为了不刺激鱼虾螺,换水应该少量、多次、缓慢;但越是缓慢,人越不适合盯着做。于是这个项目就从"鱼缸该换水了"变成了一个 ESP32 + ESPHome 的智能鱼缸串行微量换水系统。

目标不是炫技,是少出事

最开始跟 Gemini 讨论自动换水时,它给的是一个很典型的 AWC 方案:ESP32、两个泵、两个液位传感器、继电器、超时保护、防虹吸。后面一轮轮聊下来,方案也被不断修正。

普通潜水泵很安静,但必须整个泡在水里,不防虹吸,流速也偏大;而小米鱼缸本身空间就比较紧凑,再往背滤或主缸里塞一个泵并不理想。蠕动泵有机械噪音,但可以放在水外,自吸、防虹吸,而且按时间计量很准。因为我更在意"慢"和"可控",最后选了两个 12V 微型蠕动泵:一个排水,一个补水。

传感器也绕了一圈。便宜的接触式水位模块不能长期泡鱼缸,电化学腐蚀和重金属析出都不值得冒险;红外光电液位传感器可以放进背滤,但安装不如外贴干净;最后还是回到 XKC-Y25 非接触水位传感器,贴在鱼缸和废水桶外侧。

最终硬件清单收敛成:ESP32、两个 12V 微型蠕动泵、两路继电器、XKC-Y25 非接触水位传感器、DS18B20 防水温度探头、TDS 模拟模块、12V 电源和一个 DC-DC 降压模块。

但真正把这个东西做成能长期放在鱼缸旁边的设备,重点不在"能不能抽水",而在"出错时能不能停下来"。

我最后定了几个原则:

这些原则里有些是基础电路常识,有些却是 Gemini 在讨论里提醒我之后才意识到的。比如泵是感性负载,继电器能控制通断,不代表断电瞬间的反向电动势就不存在。如果不是 AI 在采购和接线阶段提前把这些坑点拎出来,我很可能会只把泵接上继电器,等它用着用着再坏。

电源拓扑:12V 是动力,5V 是系统,3.3V 是信号

整个系统只有一个 12V 3A 适配器输入。12V 直接给两个蠕动泵的动力侧,同时分一路进 DC-DC buck 降到稳定 5V。这个 5V 再给 ESP32 的 VIN、继电器模块、XKC-Y25 水位传感器供电。ESP32 自己的 3.3V 只用来做 TDS 模块、DS18B20 和 GPIO 上拉的信号参考。

简单画出来大概是这样:

12V adapter
  |
  +--> relay COM --> 12V peristaltic pumps
  |
  +--> buck 5V --> ESP32 VIN + relay VCC + XKC-Y25 VCC
                    |
                    +--> ESP32 3V3 --> TDS / DS18B20 / GPIO pull-up

这里最容易犯的错是让传感器输出把 5V 高电平送进 ESP32 GPIO。XKC-Y25 有些版本是开集电极 NPN 输出,有些模块化版本在未触发时可能会输出接近 VCC 的高电平。所以我最后的接法是:XKC 供 5V,但 OUT 经过二极管隔离到 ESP32 GPIO,GPIO 侧只用 3.3V 上拉。目标是允许传感器把 GPIO 拉低,但阻断 5V 高电平倒灌。

接完以后还要用万用表实测:所有进入 ESP32 的信号线,高电平都不能超过 3.3V。

换水算法:先抽后补,比同时跑两个泵可靠

一开始很自然会想到两个泵同时工作:一个抽旧水,一个补新水。但这在真实世界里并不靠谱。两个同型号蠕动泵,在不同管长、扬程、弯折、背压下,流量不会完全一样。只要每天误差一点点,鱼缸水位就会慢慢漂。

所以我改成串行流程:

1. 预检:废水桶没满、鱼缸没到高水位
2. 排水泵运行 drain_runtime_ms
3. 停 3 秒
4. 再检查鱼缸高水位
5. 补水泵运行 fill_runtime_ms
6. 停止并记录完成

每次换水目标体积是 700 mL。排水和补水的运行时间不是拍脑袋,而是按实际流速算:

runtime_ms = target_volume_ml / flow_rate_ml_min * 60 * 1000

我在项目配置里的示例标定值是:

这两个数字已经能说明问题:同样都是小蠕动泵,实际安装后就是不一样。串行换水的好处是,即便两个泵流速不同,只要各自标定准确,单次换水体积就是可控的。

ESPHome 固件:别在 lambda 里阻塞

固件用 ESPHome 写。

我比较在意的一点是,不在 lambda 里写一个长时间阻塞的循环。换水周期会跑十几分钟,如果把控制逻辑塞进阻塞式 lambda,安全联锁和 Wi-Fi/API 栈的响应都会变差。

现在的做法是用 ESPHome 的 script 和 action delay 来组织流程,运行时长保存在 globals 里:

script:
  - id: sync_water_change_script
    mode: single
    then:
      - lambda: |-
          id(drain_runtime_ms) = ...;
          id(fill_runtime_ms) = ...;          
      - switch.turn_on: drain_pump
      - delay: !lambda "return id(drain_runtime_ms);"
      - switch.turn_off: drain_pump
      - delay: 3s
      - switch.turn_on: fill_pump
      - delay: !lambda "return id(fill_runtime_ms);"
      - switch.turn_off: fill_pump

两个泵的 GPIO switch 都设置了 restore_mode: ALWAYS_OFF,继电器低电平触发则通过 inverted: true 处理。ESP32 上电时也会主动关掉两个泵,避免重启瞬间误动作。

鱼缸高水位传感器更像一个硬件保险丝。一旦触发,ESPHome 本地立刻停止脚本,并关掉两个泵。废水桶满水则无条件停止排水泵,如果当时正处在排水阶段,就中止整个换水流程。

这部分我不想交给 Home Assistant 做,因为网络、Wi-Fi、HA 重启都可能发生。真正会导致水漫出来的故障,必须在控制器本地处理。

Home Assistant 只是面板和通知

ESPHome 接入 Home Assistant 之后,我暴露了这些实体:

这里有个小坑:ESPHome 的内部 script 不会自动变成 Home Assistant 里的 script.xxx 实体。所以我另外用 button: 暴露了一个 Run Aquarium Water Change 按钮,让 HA 可以手动触发完整换水流程。

Home Assistant 自动化只负责发通知:如果换水周期运行中触发了高水位或废水桶满水,就推一条高优先级告警。真正的停泵动作仍然在 ESP32 本地完成。

3D 外壳:AI 最让我意外的一部分

电路和固件做完以后,桌面上就是一坨裸露的板子、线、泵和管子。鱼缸旁边有水,这样放肯定不合适,于是我又让 Claude 帮我设计一个外壳。

我原本以为 AI 可能只能给个大概结构,没想到它直接用 OpenSCAD 做了一个参数化模型,还能输出 STL。

鱼缸控制器外壳预览

最终外壳是一个两层加盖子的结构,外部尺寸大约 108 x 104 x 95 mm:

这里的设计目标不是防水密封,而是 splash separation:万一泵管附近有滴水,至少不要直接流到 ESP32 和继电器模块上。

打印出来以后尺寸基本准确,泵、板子、螺丝孔都能对上。唯一的小瑕疵是有个卡扣没有预留和另一个部件衔接的卡槽,我 review 的时候也没发现,最后只能自己用尖嘴钳修了一个槽。总体来说,这部分反而是我最惊喜的:OpenSCAD 这种"用代码描述几何"的方式,特别适合让 AI 参与机械结构设计。

最后实际用起来是什么样

后面我又买了两个 3L 的小储水盒,一个放困好的新水,一个放抽出来的废水。废水盒子上贴一个 XKC-Y25,水满就停止排水;鱼缸上贴另一个,水位到上限就停止补水。

现在这个系统能做到:

这不是一个很复杂的项目,但它把智能家居 DIY 里几个典型问题都串起来了:低压供电、电平保护、感性负载、传感器联锁、固件状态机、Home Assistant 集成、3D 打印结构件。

AI 在这里真正有用的地方

这个项目如果没有 AI,我自己当然也能做,但大概率要花几个周末:查资料、选型号、画接线、写 ESPHome、排查传感器电平、再学一下 OpenSCAD。

这次最有意思的地方是,AI 不是在一个已经定义好的需求里帮我写代码,而是一路参与了需求本身的形成。最开始我问的是"这两只螺怎么回事",后面变成"为什么绿藻没有把毒素消耗掉",再变成"能不能用 ESP32 做无人工介入换水",最后才变成接线、固件、焊板和外壳。

这就很像多了几个不同领域的搭子:

这个项目最后给我的感觉是,AI 确实让智能家居 DIY 的门槛低了很多。尤其是中间那些不容易一开始想到、但真做起来很可能踩到的坑,比如泵的反向电动势、传感器电平、虹吸、空转、废水桶满水、ESPHome 脚本怎么暴露给 Home Assistant,它都能提前帮你指出来。

最让我意外的其实是 3D 外壳。以前我会把这种机械结构设计默认归到"还得自己慢慢画"那一类,但这次 AI 直接用 OpenSCAD 把外壳做出来,尺寸也基本能用。这让我第一次觉得,个人硬件项目里,代码、电路、结构件这些原本分散的部分,真的可以被一个连续的对话串起来。

当然,真正花时间的地方并没有完全消失。淘宝上按照 Gemini 给的采购清单买东西,还是要自己挑型号、看接口、选商家;焊接、组装、放到真实鱼缸旁边测试,也还是得自己动手。短时间内让机器人完全替我完成这些物理世界里的工作还需要一些时日。

但如果类似淘宝这样的网站,未来能更开放地给 Agent 使用,让它不只是给出采购清单,而是能在约束条件里帮我筛型号、比店铺、检查兼容性,甚至把订单和后续装配说明连起来,那个人硬件 DIY 的体验会变得非常不一样。

鱼缸换水是一件小事,但这套小控制器做完以后,我对"AI + 个人硬件项目"这条路更有信心了。以后家里很多原本懒得做的自动化,也许都可以这样一点点变成真实设备。