零知派ESP32--AS5600磁吸旋钮音量控制器

项目概述

本项目是一个磁吸式 USB 旋钮音量控制器。核心思路是:

  • 一颗径向磁铁贴在旋钮底部,靠近 AS5600 芯片表面旋转时,芯片输出 0° 360° 的绝对角度
  • ESP32 以 30Hz 频率读取角度,计算多圈累计角度和角速度,通过 USB 串口发送给 PC
  • PC 端 Python 程序接收数据,将累计角度绝对映射为 5% 100% 的系统音量
  • 同时 ESP32 上的 ST7789 小屏幕实时显示当前角度,网页端可查看 3D 旋钮和波形

整个系统的数据流如下:

复制代码
磁铁旋转 → AS5600 (I2C) → ESP32 (30Hz采样)
  ├── ST7789 屏幕 (4Hz 显示角度)
  └── USB 串口 → PC Python (knob.py)
       ├── pycaw → Windows 系统音量
       └── WebSocket → 浏览器 (viz.html)
            ├── Three.js 3D 旋钮
            ├── Canvas 波形图
            └── Web Audio 八音盒

项目亮点

亮点 说明
非接触式传感 AS5600 基于霍尔效应,磁铁与芯片无物理接触,零磨损、寿命长
多圈追踪 软件自动处理 0°/360° 边界跨越,支持无限圈数旋转
绝对角度映射 旋钮位置直接对应音量值(-180°→5%,0°≈52%,+180°→100%),直觉操控
最低音量保护 音量下限锁定在 5%,避免误操作导致完全静音
双重可视化 ESP32 屏幕显示角度 + 网页 3D 旋钮/波形/八音盒实时同步
单 USB 供电 ESP32 通过 USB 供电并与 PC 通信,无需额外电源
零 WiFi 延迟 WebSocket 和 HTTP 均走 localhost,无网络延迟
一键启动 双击 run.bat 即可运行,自动打开网页

项目难点及解决方案

难点 1:多圈角度追踪

问题:AS5600 只输出 0° 360° 的单圈绝对角度。当磁铁从 359° 转到 1° 时,原始读数会从 359 跳到 1,看起来像是"反向转了 358°"。

解决方案 :在 ESP32 端计算相邻两帧的角度差(delta),通过判断差值是否超过 ±180° 来决定是正转还是反转跨越了边界。将 delta 累加到 degTot(累计角度)上,并限制单帧 delta 最大 ±10° 来过滤噪声。

复制代码
// 简化逻辑
float delta = curDeg - prevDeg;
if (delta > 180.0f)  delta -= 360.0f;   // 反向跨越 0°
if (delta < -180.0f) delta += 360.0f;   // 正向跨越 0°
delta = constrain(delta, -10.0f, 10.0f);  // 噪声过滤
degTot += delta;

难点 2:角速度平滑

问题:原始角速度(delta / dt)抖动很大,直接用于波形显示会产生锯齿状图形。

解决方案:使用指数移动平均(EMA)滤波,权重为 0.4/0.6(新值/旧值),将角速度限制在 ±180°/s 范围内。

复制代码
vel = vel * 0.6f + rawVel * 0.4f;  // EMA 平滑
vel = constrain(vel, -180.0f, 180.0f);

难点 3:屏幕刷新过快导致数字模糊

问题 :ST7789 屏幕以 10Hz 刷新时,快速变化的数字(如 123.45°)肉眼难以看清。

解决方案:三管齐下 ------

  1. 刷新率从 10Hz 降至 4Hz
  2. 角度变化不足 0.8° 时不刷新
  3. 显示格式从 %.2f(两位小数)改为 %.1f(一位小数)

难点 4:同步代码与异步 WebSocket 的桥接

问题knob.py 的主循环是同步阻塞的(serial.readline()),而 WebSocket 库 websockets 是基于 asyncio 的异步框架。

解决方案 :使用 queue.Queue 作为线程安全的桥梁。主线程每帧 put 数据到队列,WebSocket 线程的异步循环每 33ms get_nowait 取最新数据广播给所有浏览器客户端。

复制代码
# 主线程(同步)
ws_queue.put({"degTot": deg_tot, "vel": vel, ...})

# WS 线程(异步)
data = ws_queue.get_nowait()  # 取最新帧
for client in self.clients:
    await client.send(json.dumps(data))

难点 5:Windows 音量控制 API 兼容性

问题pycaw 库的不同版本对 Windows Core Audio API 的调用方式不同,直接调用可能崩溃。

解决方案 :实现双 API 兼容 ------ 优先尝试 IAudioEndpointVolume.SetMasterVolumeLevelScalar,失败则回退到 IAudioEndpointVolume.SetMasterVolumeLevel(需手动转换分贝值)。


目录

一、硬件系统部分

[1.1 硬件清单](#1.1 硬件清单)

[1.2 接线方案](#1.2 接线方案)

[1.3 硬件连接图](#1.3 硬件连接图)

[1.4 实物连接图](#1.4 实物连接图)

二、软件架构设计

[2.1 系统初始化](#2.1 系统初始化)

[2.2 主循环逻辑](#2.2 主循环逻辑)

三、代码拆分讲解

[3.1 ESP32 固件:AS5600_Visualizer.ino](#3.1 ESP32 固件:AS5600_Visualizer.ino)

[3.2 PC 端:knob.py](#3.2 PC 端:knob.py)

[3.3 网页端:viz.html](#3.3 网页端:viz.html)

四、操作过程及数据展示

[4.1 操作步骤](#4.1 操作步骤)

[4.2 演示视频](#4.2 演示视频)

五、技术原理

[5.1 工作原理](#5.1 工作原理)

[5.2 工作模式配置](#5.2 工作模式配置)

六、常见问题指引

[Q1:AS5600 读不到数据 / 角度始终为 0](#Q1:AS5600 读不到数据 / 角度始终为 0)

[Q2:角度跳变 / 不连续](#Q2:角度跳变 / 不连续)

Q3:音量不变化

[Q4:网页打不开 / WebSocket 连接失败](#Q4:网页打不开 / WebSocket 连接失败)

Q5:旋钮旋转方向与音量增减相反

Q6:八音盒不发声

Q7:想调整显示参数


一、硬件系统部分

1.1 硬件清单

序号 元件 型号/规格 数量 说明
1 主控芯片 零知ESP32 1 带 USB,支持 I2C + SPI
2 扩展板 零知ESP32扩展板 1 自带屏幕接口和按键,减少接线
3 磁编码器 AS5600 模块 1 I2C 地址 0x36,12-bit 分辨率
4 显示屏 ST7789 240×240 IPS TFT 1 SPI 接口
5 磁铁 径向磁化 N35 钕磁铁 Ø6mm 1 贴在旋钮底部
6 面包板 --- 1 按需选择
7 杜邦线 公对母 4 按接线表准备

1.2 接线方案

AS5600

AS5600 引脚 ESP32 引脚 说明
VCC 3.3V 供电
GND GND 共地
SDA GPIO 21 I2C 数据线
SCL GPIO 22 I2C 时钟线

1.3 硬件连接图

1.4 实物连接图


二、软件架构设计

2.1 系统初始化

系统上电后的初始化流程分为两部分:

ESP32 端(AS5600_Visualizer.ino)

复制代码
1. Wire.begin(21, 22)        →  初始化 I2C,SDA=21, SCL=22
2. tft.init()                 →  初始化 ST7789 (TFT_eSPI)
3. tft.fillScreen(BLACK)      →  清屏
4. Serial.begin(115200)       →  初始化 USB 串口
5. pinMode(BTN_PIN, INPUT_PULLUP) →  GPIO 27 内部上拉
6. 读取 AS5600 首帧角度        →  作为 prevDeg 基准
7. multiTurnInit = true       →  标记首帧,不累加 delta

PC 端(knob.py

复制代码
1. 自动检测 ESP32 串口 (COMx)
2. 打开串口 (115200, 8N1)
3. 初始化 VolumeControl (pycaw)
4. 打印音量映射配置
5. (可选) 启动 HTTP 静态服务器 (端口 8080)
6. (可选) 启动 WebSocket 服务器 (端口 8765)
7. 进入主循环,等待串口数据

2.2 主循环逻辑

ESP32 主循环(每帧 ~33ms,即 30Hz)

复制代码
loop() {
    ├─ handleSerialCommands()     // 处理 PC 发来的命令
    ├─ 读取按键 (边沿触发, 30ms 去抖)
    │     └─ 按下 → 切换 knobOn 状态, forceDisplay()
    ├─ 读 AS5600 寄存器 0x0C/0x0D → 12-bit 原始值
    ├─ rawToDeg(raw) → 转换为 0°~360°
    ├─ 计算 delta, 边界修正, clamp ±10°
    ├─ 累加 degTot (首帧跳过)
    ├─ 计算 vel (EMA 滤波, clamp ±180°/s)
    ├─ 每 250ms 发送一次串口: "KNB,<degTot>,<vel>\n"
    ├─ 每 250ms 检查是否需要刷新屏幕 (4Hz, 0.8° 阈值)
    │     └─ displayUpdate(): fillScreen + 绘制角度 + 模式文字
    └─ prevDeg = curDeg
}

PC 主循环(knob.py

复制代码
loop() {
    ├─ serial.readline() → 解析 "KNB,<degTot>,<vel>"
    ├─ handle_angle(deg_tot) → 绝对角度映射到音量
    │     ├─ pct = 5 + (deg_tot + 180) / 360 * 95
    │     ├─ pct = clamp(pct, 5, 100), 取整
    │     ├─ 去重: 相同 pct 不重复设置
    │     └─ pycaw 设置系统音量
    ├─ 控制台打印: "[volume] tot=+123.4 d=+0.0 vol= 52%"
    └─ ws_queue.put(json) → 推送给 WebSocket 线程
}

三、代码拆分讲解

3.1 ESP32 固件:AS5600_Visualizer.ino

关键常量

复制代码
static const uint16_t DISPLAY_HZ      = 4;       // 屏幕刷新频率
static const float    DISPLAY_MIN_DEG  = 0.8f;    // 最小刷新角度阈值
static const uint32_t KNOB_HZ         = 30;      // 传感器采样频率
static const int      BTN_PIN         = 27;      // 按键引脚
static const uint16_t BTN_DEBOUNCE_MS = 30;      // 按键去抖时间
static const uint8_t  AS5600_ADDR     = 0x36;    // I2C 地址

rawToDeg() ------ 原始值转角度

从 AS5600 的 0x0C(高 8 位)和 0x0D(低 4 位)寄存器读取 12-bit 原始值,映射到 0° 360°:

复制代码
float rawToDeg(uint16_t raw) {
    return (raw / 4095.0f) * 360.0f;
}

多圈追踪逻辑

复制代码
float delta = curDeg - prevDeg;
if (delta >  180.0f) delta -= 360.0f;  // 正向跨越 0° 边界
if (delta < -180.0f) delta += 360.0f;  // 反向跨越 0° 边界
delta = constrain(delta, -10.0f, 10.0f); // 单帧限幅,抗噪
if (!multiTurnInit) {
    degTot += delta;
} else {
    multiTurnInit = false;  // 首帧只对齐,不累加
}

按键处理(边沿触发)

复制代码
bool btnNow = digitalRead(BTN_PIN);          // LOW = 按下
if (!btnNow && btnPrev) {                     // 下降沿
    if (millis() - btnTime >= BTN_DEBOUNCE_MS) {
        knobOn = !knobOn;                      // 切换模式
        forceDisplay();                        // 立即刷新屏幕
    }
    btnTime = millis();
}
btnPrev = btnNow;

显示更新(4Hz 策略)

复制代码
void displayUpdate() {
    uint32_t now = millis();
    if (now - lastDisplayTime < (1000 / DISPLAY_HZ)) return;        // 频率控制
    if (fabs(degTot - lastDisplayDeg) < DISPLAY_MIN_DEG) return;    // 变化阈值
    lastDisplayTime = now;
    lastDisplayDeg = degTot;
    tft.fillScreen(TFT_BLACK);
    tft.setTextColor(TFT_GREEN, TFT_BLACK);
    tft.setTextDatum(MC_DATUM);
    tft.drawString("%s", 120, 120, 7);           // 大字号显示角度
    tft.drawString(knobOn ? "KNOB" : "DEBUG", 120, 200, 2);  // 模式
}

3.2 PC 端:knob.py

核心常量

复制代码
VOL_DEG_MIN = -180.0   # 最低音量对应角度
VOL_DEG_MAX =  180.0   # 最高音量对应角度
VOL_PCT_MIN = 5        # 最低音量百分比(不静音)
VOL_PCT_MAX = 100      # 最高音量百分比

VolumeControl 类 ------ 音量映射与设置

复制代码
class VolumeControl:
    def set_from_angle(self, deg_tot, deg_min, deg_max, pct_min, pct_max):
        # 线性映射 + clamp
        ratio = (deg_tot - deg_min) / (deg_max - deg_min)
        ratio = max(0.0, min(1.0, ratio))
        pct = int(round(pct_min + ratio * (pct_max - pct_min)))
        if pct == self._last_pct:
            return  # 去重,避免重复调用 API
        self._last_pct = pct
        self._set_volume(pct)  # 调用 pycaw

Knob 类 ------ 串口通信主循环

复制代码
class Knob:
    def loop(self):
        while self.running:
            line = self.ser.readline().decode('ascii', errors='ignore').strip()
            if not line.startswith('KNB,'):
                continue
            parts = line.split(',')
            deg_tot = float(parts[1])
            vel = float(parts[2]) if len(parts) > 2 else 0.0
            self.vc.set_from_angle(deg_tot, ...)
            self._push_viz(deg_tot, vel)  # 推送到 WebSocket

WSServer 类 ------ 异步 WebSocket 广播

复制代码
class WSServer:
    async def _broadcast(self):
        while True:
            await asyncio.sleep(0.033)  # ~30Hz
            if not self.queue.empty():
                data = self.queue.get_nowait()
                # 只保留最新帧
                while not self.queue.empty():
                    self.queue.get_nowait()
                msg = json.dumps(data)
                dead = []
                for ws in self.clients:
                    try:
                        await ws.send(msg)
                    except:
                        dead.append(ws)
                for ws in dead:
                    self.clients.discard(ws)

3.3 网页端:viz.html

技术栈:Three.js(CDN)+ Canvas 2D + Web Audio API,单文件无构建。

3D 旋钮

使用 MeshPhysicalMaterial 构建高端旋钮外观:

  • 主体:深灰金属材质,metalness=0.9, roughness=0.2
  • 清漆层(clearcoat):模拟汽车烤漆光泽
  • 橡胶握把环:48 个滚花齿
  • 金色装饰带 + 铬合金顶板 + 中心宝石
  • 发光指针:随角度旋转
  • ACES 色调映射 + 雾效 + 动态灯光轨道

波形图

200 点滚动 Canvas 2D 折线图,纵轴为角速度(±180°/s),使用渐变填充,实时滚动。

八音盒

基于 Web Audio API 的合成八音盒:

  • 每个音符由 3 个谐波叠加(正弦 + 正弦 + 三角波)
  • 1.6 秒指数衰减
  • 每 12° 角度变化触发一个音符(步进量化)
  • 内置《小星星》和《樱花》两首曲目
  • 根据旋转方向和速度调节音量

四、操作过程及数据展示

4.1 操作步骤

第一次使用 ------ 环境搭建

复制代码
# 1. 克隆项目到桌面
cd ~/Desktop/AS5600_Visualizer

# 2. 安装 Python 依赖
pip install -r requirements.txt

# 依赖列表:
#   pyserial>=3.5      # 串口通信
#   pycaw>=20220416     # Windows 音量控制
#   comtypes>=1.1.14   # pycaw 的 COM 依赖
#   websockets>=12.0   # WebSocket 服务器

3. 烧录 ESP32 固件

  1. 用 零知派 打开 AS5600_Visualizer.ino
  2. 选择开发板 零知ESP32,选择对应 COM 口
  3. 点击上传

4. 启动系统

复制代码
步骤 1: 将 ESP32 通过 USB 连接到 PC
步骤 2: 双击 run.bat
步骤 3: 看到 "可视化已启动: http://localhost:8080/viz.html"
步骤 4: 复制上述地址去浏览器打开
步骤 5: 旋转磁铁旋钮,观察:
        - ESP32 屏幕显示角度变化
        - PC 控制台打印音量百分比
        - 网页 3D 旋钮同步旋转 + 波形跳动

5. 使用按键

  • 按一下 扩展板上的S3按键:切换 KNOB (旋钮控制音量)↔ DEBUG(仅显示,不控制音量)
  • 屏幕和串口同步更新状态

6. 命令行参数

复制代码
python knob.py                     # 默认启动(含可视化)
python knob.py --no-viz            # 不启动网页服务
python knob.py --ws-port 9000      # 自定义 WebSocket 端口
python knob.py --http-port 3000     # 自定义 HTTP 端口
python knob.py --vol-min 10         # 最低音量改为 10%
python knob.py --vol-max 90         # 最高音量改为 90%

典型控制台输出

复制代码
[map]  音量 5%~100%  ↔  角度 -180° ~ +180°
[port] COM3 @ 115200
可视化已启动: http://localhost:8080/viz.html
[volume] tot=+178.3 d=+0.2 vol=100%
[volume] tot=+175.1 d=-3.2 vol= 99%
[volume] tot=  +0.0 d=-0.0 vol= 52%
[volume] tot= -90.5 d=-1.2 vol= 29%
[volume] tot=-180.0 d=-2.1 vol=  5%

角度-音量映射表

累计角度 系统音量
≤ -180° 5%(最低,不静音)
-135° ≈18%
-90° ≈29%
-45° ≈40%
≈52%(中间值)
+45° ≈65%
+90° ≈76%
+135° ≈88%
≥ +180° 100%(最大)

4.2 演示视频

零知派ESP32--AS5600磁吸旋钮音量控制器


五、技术原理

5.1 工作原理

AS5600 霍尔效应角度传感

AS5600 内部集成了多个霍尔元件阵列。当外部磁铁的磁场穿过芯片表面时,各霍尔元件输出不同强度的电压。芯片内部的 DSP 对这些信号进行反正切计算(ATAN2),输出 12-bit 数字角度值(0 4095 对应 0° 360°)。

关键特性:

  • 非接触:磁铁与芯片之间可以有 0.5--3mm 间隙
  • 绝对位置:上电即可知道角度,无需归零(不同于增量式编码器)
  • 低功耗:典型工作电流 < 10mA

I2C 通信时序

ESP32 作为 I2C Master,以 100kHz(标准模式)或 400kHz(快速模式)与 AS5600 通信:

复制代码
[START] → [ADDR+W(0x36)] → [REG(0x0C)] → [RESTART] → [ADDR+R] → [读高8位] → [ACK] → [读低4位+4位0] → [NACK] → [STOP]

寄存器地址:

  • 0x0C:角度高 8 位(D11:D4)
  • 0x0D:角度低 4 位(D3:D0,高 4 位为 0)

音量-角度映射公式

复制代码
ratio  = (degTot - VOL_DEG_MIN) / (VOL_DEG_MAX - VOL_DEG_MIN)
       = (degTot + 180) / 360
ratio  = clamp(ratio, 0, 1)
vol%   = VOL_PCT_MIN + ratio * (VOL_PCT_MAX - VOL_PCT_MIN)
       = 5 + ratio * 95

采用绝对映射而非增量映射的好处:旋钮的物理位置直接对应一个音量值,用户无需记忆"当前转到哪了",看一眼就知道。

5.2 工作模式配置

系统有两种工作模式,通过物理按键切换:

模式 屏幕显示 串口行为 音量控制
KNOB(旋钮模式) "KNOB" + 绿色角度 发送 KNB 数据 ✅ 实时调节系统音量
DEBUG(调试模式) "DEBUG" + 黄色角度 发送 KNB 数据 ❌ 不调节音量,仅显示

切换方式:

  • 物理按键:按下扩展板上的S3轻触开关
  • 串口命令 :PC 发送 knob onknob off

其他串口命令:

命令 说明
knob on 进入旋钮模式(控制音量)
knob off 进入调试模式(仅显示)
zero 将当前角度重置为 0°
mode xxx 切换显示模式(保留接口)

六、常见问题指引

Q1:AS5600 读不到数据 / 角度始终为 0

排查步骤

  1. 检查 I2C 接线:SDA→GPIO21, SCL→GPIO22,VCC→3.3V, GND→GND
  2. 用 零知派 的 I2C Scanner 扫描地址,确认 0x36 存在
  3. 确认 AS5600 breakout 板上的上拉电阻已焊接
  4. 磁铁是否正确安装:径向磁化方向,N/S 极面向芯片,间隙 1--3mm

Q2:角度跳变 / 不连续

可能原因

  • 磁铁间隙过大(> 3mm),信号弱导致抖动
  • 磁铁未居中对准芯片中心
  • 磁铁不是径向磁化(径向 = N 和 S 在直径两端)

解决 :调整磁铁位置,确保居中且间隙合适。如果仍有轻微抖动,可适当减小 DELTA_CLAMP 值。

Q3:音量不变化

  1. 确认屏幕显示 KNOB 模式(不是 DEBUG)

  2. 确认控制台有 [volume] 输出

  3. 检查 Windows 音量混合器中,是否选中了正确的音频输出设备

  4. 尝试手动调节系统音量滑块,确认 pycaw 能正常工作:

    复制代码
    python -c "from pycaw.pycaw import AudioUtilities; print('OK')"

Q4:网页打不开 / WebSocket 连接失败

  1. 确认控制台输出了 "可视化已启动" 的提示
  2. 浏览器访问 http://localhost:8080/viz.html
  3. 如果端口被占用,使用 --http-port--ws-port 指定其他端口
  4. 确认未使用 --no-viz 参数
  5. WebSocket 状态指示灯:
    • 🟢 绿色 = 已连接
    • 🔴 红色 = 断开(自动 1 秒重连)

Q5:旋钮旋转方向与音量增减相反

修改 knob.py 中的角度映射常量,交换最大/最小值:

复制代码
VOL_DEG_MIN = 180.0   # 原来是 -180.0
VOL_DEG_MAX = -180.0  # 原来是  180.0

或者通过命令行参数 --vol-min 180 --vol-max -180 临时反转。

Q6:八音盒不发声

  1. 确认浏览器未静音(右上角音量图标)
  2. 需要先与页面进行一次交互(点击页面任意位置)以激活 AudioContext
  3. 八音盒在 viz.html?ws= URL 参数开启时才会工作
  4. 需要在旋钮模式下旋转旋钮才能触发音符

Q7:想调整显示参数

参数 位置 默认值 说明
屏幕刷新率 .inoDISPLAY_HZ 4 Hz 越低越稳,越高越流畅
最小刷新角度 .inoDISPLAY_MIN_DEG 0.8° 越大越稳,越小越灵敏
采样频率 .inoKNOB_HZ 30 Hz 不要超过 50 Hz
最小音量 knob.pyVOL_PCT_MIN 5 0 100
角度范围 knob.pyVOL_DEG_MIN/MAX ±180° 旋转多少度覆盖全音量范围
按键去抖 .inoBTN_DEBOUNCE_MS 30 ms 过小会误触,过大会迟钝
相关推荐
小保CPP1 小时前
OpenCV C++车型识别1-图像预处理
c++·人工智能·opencv·计算机视觉
尼喃2 小时前
42V热拔插认证过压保护芯片:70V耐压+响应<1μs+可调OVP+SOT23-6
嵌入式硬件
库克克2 小时前
【C++】C++11 包装器function 与 绑定器 bind
开发语言·c++
0x3F(小茶)2 小时前
STM32 SPI的5种方向模式
c语言·stm32·单片机·嵌入式硬件
小小龙学IT2 小时前
C++ Placement New 与显式析构:手动对象生命周期管理的艺术
c++·windows·mfc
小保CPP2 小时前
OpenCV C++车型识别2-形状匹配
c++·人工智能·opencv·计算机视觉
BSD_CGQ3 小时前
FSR压力传感器信号调理电路设计要点
单片机·嵌入式硬件·压力传感器·源头工厂·薄膜压力传感器
爱就是恒久忍耐4 小时前
CanFestival移植到STM32 F103芯片(基于HAL库)
stm32·单片机·嵌入式硬件
txh05074 小时前
如何使用示波器
嵌入式硬件·硬件工程