---
title: 我做了一个智能水杯秤，试着让喝水记录真正无感
slug: smart-water-cup-scale
description: 用 ESP32、称重传感器和稳定段算法，自动识别办公室里的喝水、加水与拿杯动作。
date: 2026-07-20
category: Projects
tags:
  - ESP32
  - HX711
  - 称重传感器
  - 3D 打印
  - IoT
draft: false
featured: false
hidden: false
archived: false
project: smart-water-cup-scale
---

「每天多喝水」是一件所有人都知道，却很难长期坚持的事情。

很多饮水 App 的问题不在提醒，而在记录：每喝一次水，都要拿起手机、打开 App、选择容量、再点确认。坚持几天后很容易放弃。

## 灵感来源

这个项目最初的触动，来自身边几个朋友陆续查出肾结石。医生的嘱咐很一致：必须长期、大量地喝水，才能降低复发风险。

但观察下来，真正卡住他们的不是「不知道要喝」，而是「喝了多少没记录、没反馈」，时间一长饮水量又悄悄回落。这让我想做一个不需要主动操作的记录设备——只是安静地记录数据和提醒，不改变原有习惯。它看起来只是工位上的一个杯垫，却能感知杯子重量变化，自动判断喝了多少、什么时候加了水，以及一天喝水的频率。

## 项目的基本想法

核心结构很简单：

```d2
direction: right

感知与采集: {
  style.fill: "transparent"
  style.stroke: "transparent"
  水杯 -> 承重面板 -> 称重传感器 -> HX711
}

上传与处理: {
  style.fill: "transparent"
  style.stroke: "transparent"
  本地 Flask 服务: {
    shape: rectangle
    style.fill: "#282840"
    style.stroke: "#A6E3A1"
    style.stroke-width: 2
    style.border-radius: 8
    label: "本地 Flask 服务（事件识别 + SQLite）"
  }
  Wi-Fi: {
    shape: rectangle
    style.fill: "#1E1E2E"
    style.stroke: "#585B70"
    style.border-radius: 8
  }
  ESP32: {
    shape: rectangle
    style.fill: "#282840"
    style.stroke: "#89B4FA"
    style.stroke-width: 1
    style.border-radius: 8
  }
  本地 Flask 服务 <- Wi-Fi <- ESP32
}

水杯: {
  shape: rectangle
  style.fill: "#282840"
  style.stroke: "#CBA6F7"
  style.stroke-width: 2
  style.border-radius: 8
}

承重面板: {
  shape: rectangle
  style.fill: "#1E1E2E"
  style.stroke: "#585B70"
  style.border-radius: 8
}

称重传感器: {
  shape: rectangle
  style.fill: "#1E1E2E"
  style.stroke: "#585B70"
  style.border-radius: 8
}

HX711: {
  shape: rectangle
  style.fill: "#282840"
  style.stroke: "#FAB387"
  style.stroke-width: 1
  style.border-radius: 8
}

感知与采集.HX711 -> 上传与处理.ESP32
```

ESP32 读取称重数据，通过 Wi-Fi 把重量样本推送到本地服务端，再由服务端识别出喝水和加水事件。但真正设计时马上遇到一个问题：

> 重量下降，并不一定意味着喝水。

用户可能只是碰了一下杯子，也可能端起来又原样放回，甚至把手机、钥匙临时放到杯垫上。如果只看前后重量差，误报会非常多。

因此，这个项目真正的核心不是称重，而是 **如何理解重量变化**。

## 不记录每一次波动，只记录稳定状态

称重传感器的数据一直在变。桌面震动、杯中水晃动、传感器漂移，都会带来几克甚至十几克的波动。

所以我没有直接使用瞬时重量，而是先判断「稳定段」。一个稳定值需要满足：

- 连续约 2 秒重量变化处于允许范围（空载判定放宽到约 0.8 秒，放回判定约 1.5 秒）
- 剔除明显异常值
- 多次采样后取平均或中位数
- 重量变化小于约 8 克时，不作为饮水事件上传

系统处理的不再是每一条原始数据，而是一串稳定状态：

```d2
direction: right

稳定 520g: {
  shape: rectangle
  style.fill: "#282840"
  style.stroke: "#89B4FA"
  style.stroke-width: 2
  style.border-radius: 8
  width: 180
}

稳定 0g: {
  shape: rectangle
  style.fill: "#1E1E2E"
  style.stroke: "#FAB387"
  style.stroke-width: 2
  style.border-radius: 8
  width: 180
}

稳定 460g: {
  shape: rectangle
  style.fill: "#282840"
  style.stroke: "#A6E3A1"
  style.stroke-width: 2
  style.border-radius: 8
  width: 180
}

稳定 520g -> 稳定 0g: 杯子被拿起 { style.stroke: "#6C7086" }
稳定 0g -> 稳定 460g: 杯子被放回 { style.stroke: "#6C7086" }
```

这三个状态，比一条不断抖动的重量曲线更有意义。

## 如何识别一次喝水

一次典型的喝水过程：

1. 杯子原本放在底座上
2. 用户拿起杯子
3. 杯垫重量变成 0
4. 用户喝水
5. 杯子重新放回
6. 新重量小于拿起前的重量

因此喝水事件可以表示为：

```d2
direction: right

稳定 A: {
  shape: rectangle
  style.fill: "#282840"
  style.stroke: "#89B4FA"
  style.stroke-width: 2
  style.border-radius: 8
}

杯子被拿起（0）: {
  shape: circle
  style.fill: "#313244"
  style.stroke: "#FAB387"
  style.stroke-width: 2
}

稳定 B: {
  shape: rectangle
  style.fill: "#282840"
  style.stroke: "#A6E3A1"
  style.stroke-width: 2
  style.border-radius: 8
}

稳定 A -> 杯子被拿起（0）: { style.stroke: "#6C7086" }
杯子被拿起（0） -> 稳定 B: { style.stroke: "#6C7086" }
```

当 `A > B` 时：

```text
喝水量 = A - B
```

例如 `520g → 0g → 455g`，可判断本次约喝水 65g。水的密度接近 1g/ml，可近似记为 65ml。

更明确的历史条件是：

> 上一次稳定重量是 0，并且上上次稳定重量大于当前重量，则判断为喝水。

这正是项目中最关键的一条规则。

## 如何识别加水

加水与喝水相似，只是最终重量变大：

```d2
direction: right

稳定 A: {
  shape: rectangle
  style.fill: "#282840"
  style.stroke: "#89B4FA"
  style.stroke-width: 2
  style.border-radius: 8
}

杯子被拿起（0）: {
  shape: circle
  style.fill: "#313244"
  style.stroke: "#FAB387"
  style.stroke-width: 2
}

稳定 C: {
  shape: rectangle
  style.fill: "#282840"
  style.stroke: "#A6E3A1"
  style.stroke-width: 2
  style.border-radius: 8
}

稳定 A -> 杯子被拿起（0）: { style.stroke: "#6C7086" }
杯子被拿起（0） -> 稳定 C: { style.stroke: "#6C7086" }
```

当 `C > A` 时，加水量 = `C - A`。例如 `180g → 0g → 610g`，可理解为拿起后加入了约 430g 水。

系统不需要用户点击「加水」，也不需要知道杯子标称容量，只需要观察一次完整的拿起和放回。

## 为什么必须经过 0

如果重量从 520g 直接变成 460g，并不能确认发生了喝水——可能是杯子被推到边缘受力异常，也可能是其他物品被拿走。

而经过稳定 0，意味着杯子曾经完整离开承重面板。系统才能把事件理解为：

```d2
direction: right

拿起杯子: {
  shape: rectangle
  style.fill: "#282840"
  style.stroke: "#89B4FA"
  style.stroke-width: 2
  style.border-radius: 8
}

发生某种行为: {
  shape: circle
  style.fill: "#313244"
  style.stroke: "#FAB387"
  style.stroke-width: 2
}

放回杯子: {
  shape: rectangle
  style.fill: "#282840"
  style.stroke: "#A6E3A1"
  style.stroke-width: 2
  style.border-radius: 8
}

拿起杯子 -> 发生某种行为: { style.stroke: "#6C7086" }
发生某种行为 -> 放回杯子: { style.stroke: "#6C7086" }
```

因此 `A → 0 → B` 比单纯的 `A → B` 稳定得多。

## 每天的统计方式

早期还考虑过更简单的逻辑：每天 0 点新开一轮、累积重量减少量、小于 10g 不上报。实现成本低，但换杯、移动和临时放置物品仍容易干扰。

更稳妥的方案是以事件为核心：

```json
{
  "event_type": "drink",
  "before_weight_g": 520,
  "after_weight_g": 455,
  "delta_g": 65,
  "recorded_at_local": "2026-07-20T10:32:14+08:00"
}
```

服务端再按天聚合：当日总量、次数、每小时喝水曲线、事件明细和加水次数。

## ESP32 的配网与运行

设备放在办公室工位上，不能依赖串口配网。首次启动时它会创建一个开放热点 `Drinkwater-Scale-XXXX`，手机直接连上后打开 `192.168.4.1` 完成配置：

1. ESP32 创建开放配置热点（无需 Wi-Fi 密码）
2. 手机连接热点，打开本地配置页
3. 输入设备密码（OLED 上显示，防止局域网内他人误操作）
4. 选择办公室 Wi-Fi 并输入密码
5. 配置本地 Flask 服务地址和认证 Token
6. 保存并重启
7. 页面提示如何开始运行

配网完成后进入普通模式，自动连 Wi-Fi 并向本地服务端推送样本。Wi-Fi 还有一层智能回退：如果保存的 Wi-Fi 怎么都搜不到，设备会自动清掉旧凭据退回纯热点模式，换环境也不用插串口擦配置。

校准是强制两步：先空载去皮归零，再放上已知重物输入克数校准。没去皮直接校准会被拒绝。去皮偏移和校准系数都存进 NVS，掉电不丢。

用 ESP-IDF 开发学习成本更高，但对长期运行、网络管理和状态控制更合适。

## 设备本身要能被维护

一个长期运行的小设备，维护成本往往被低估。所以我在固件里多做了几件事：

- **OLED 实时反馈**：一块 0.96 寸 SSD1306，四行显示当前重量、稳定状态、IP 和推送状态；没连 Wi-Fi 时显示热点名和设备密码，不用查文档就能配网
- **内嵌网页控制台**：设备自己跑一个单页应用，输入设备密码后可以去皮、校准、配网、配置推送地址，也能远程恢复出厂
- **串口命令**：Type-C 连上电脑，115200 波特率输入 `RESET` / `APMODE` / `STATUS` / `FLASH`，分别恢复出厂、仅清 Wi-Fi、查状态、进刷机模式
- **网页 USB 刷机**：浏览器配合 Web Serial，选串口就能一键烧录 bootloader + 分区表 + 应用三件套，不用装工具链

这些功能不是产品卖点，而是让设备坏了能修、换环境能配、固件能更新。对个人项目来说，可维护性比功能多更重要。

## 事件识别放在本地服务端，而不是 ESP32

一开始我倾向于让 ESP32 自己判断喝水事件，只把结果上传。但实际做下来，职责是这样拆分的：

- ESP32 负责 EMA 低通滤波、去皮、校准，以及一个轻量的稳定状态机（区分 `UNSTABLE` / `ZERO` / `STABLE` / `LIFTED`）
- ESP32 不上传每一条原始采样，只在状态变化、稳定重量变化超过 8 克，或超过 15 秒心跳时推送一次
- 真正的稳定段判定和喝水/加水识别，放在本地 Flask 服务上完成
- Flask 把事件写入 SQLite，并提供当天喝水次数、总量、每小时曲线和事件列表

这样拆分的好处是：ESP32 的固件保持简单稳定，事件逻辑可以随时在服务端调整阈值，而不必重新刷固件；同时网络流量极低，断网时 ESP32 仍能继续采样，网络恢复后补传。

饮水事件本来也不需要上云。整套数据都留在本地，更符合一个长期摆在工位上的小工具的定位。

## 结构设计比想象中更重要

为了适合长期摆在工位上，我已经做了一套 3D 打印外壳，大致四层：

```d2
direction: right

水杯与承重面板: {
  shape: rectangle
  style.fill: "#282840"
  style.stroke: "#CBA6F7"
  style.stroke-width: 2
  style.border-radius: 8
  width: 260
}

传感器受力结构: {
  shape: rectangle
  style.fill: "#1E1E2E"
  style.stroke: "#585B70"
  style.border-radius: 8
  width: 260
}

HX711、ESP32 与供电: {
  shape: rectangle
  style.fill: "#282840"
  style.stroke: "#89B4FA"
  style.stroke-width: 1
  style.border-radius: 8
  width: 260
}

防滑底壳: {
  shape: rectangle
  style.fill: "#1E1E2E"
  style.stroke: "#585B70"
  style.border-radius: 8
  width: 260
}

水杯与承重面板 -> 传感器受力结构: { style.stroke: "#6C7086" }
传感器受力结构 -> HX711、ESP32 与供电: { style.stroke: "#6C7086" }
HX711、ESP32 与供电 -> 防滑底壳: { style.stroke: "#6C7086" }
```

![成品外观：右侧是 3D 打印外壳与承重面板，左侧摊开的是 ESP32-C3、HX711 和称重传感器等电路零件](@media/posts/smart-water-cup-scale/device-photo.jpg)

设计重点包括：杯子放在不同位置仍准确；承重面板不碰底壳；泼洒后不易进水；外壳防滑；传感器不受侧向力；拆装和校准不能太复杂。

外观上，我不想做成普通电子秤，更喜欢类似《健身环大冒险》通关站立台那种轻微仪式感，同时保持简洁。它不是医疗设备，也不是强制管理工具，而是桌面上的轻量反馈装置。

## 产品真正的价值是什么

表面上在记录喝水量，更有价值的部分是「无感」。用户不必改变原有习惯：

- 仍然用自己的杯子
- 仍然像平时一样拿起放下
- 不需要点按钮
- 不需要每次打开 App
- 不需要买带特殊结构的智能水杯

设备只负责观察重量变化，并把行为转换成记录。这类产品的关键，不是增加更多提醒，而是尽量减少用户需要完成的步骤。

## 仍然存在的问题

还需要继续处理边界情况：

- 更换杯子
- 杯子没有完全放回中央
- 杯子和其他物品同时放在底座上
- 端起杯子但没有喝水
- 用吸管时杯子始终不离开底座
- 水温变化引起微小重量变化
- 长时间运行后零点漂移
- 桌面震动导致稳定段判断失败

其中吸管场景尤其特殊。如果杯子始终不离开底座，需要另一种模式：在足够长的稳定时间内，检测到持续、缓慢且超过阈值的重量下降。

但第一版我仍更倾向只支持「拿起杯子喝水」——清晰的行为边界，比覆盖所有场景更重要。

## 写在最后

我喜欢这个项目，是因为它没有试图发明一种新的喝水方式，只是让原本已经发生的行为可以被可靠地记录。

一块称重传感器、一个 HX711 和一块 ESP32 并不复杂。真正需要设计的，是设备如何从一串不稳定的重量数字中，理解用户刚刚做了什么。

当硬件不再要求用户配合，而是安静地适应用户原有习惯时，它才开始真正变得有用。
