项目背景:ADMT4000 是一款磁悬浮演示装置,默认仅支持 Type-C 供电。为了摆脱额外电源线束缚,并增加更多交互反馈功能,我基于 Teensy 4.0 设计了一块扩展控制板,实现旋钮状态可视化、震动反馈和菜单交互。
在多圈密码转盘锁中提取其中的想法,扩展了一些相关游戏的设置玩法,增加了一些娱乐性。
项目概述
项目名称 | ADMT4000 扩展控制器 |
核心功能 | 供电改造 + 旋钮状态可视化 + 震动反馈 + OLED 菜单 |
主控芯片 | Teensy 4.0 |
关键模块 | OLED 显示、WS2812B 彩色灯、震动马达、按键输入 |
一、需求分析
1.1 原始问题
• ADMT4000 默认只有 Type-C 供电,额外接线麻烦
• 旋钮使用磁感应,没有任何触觉反馈,或者声音反馈
• 旋钮状态无法直观查看
1.2 需求解决
需求 | 描述 |
供电改造 | 解决额外 Type-C 供电问题 |
震动反馈 | 旋钮转动时提供触觉反馈 |
状态显示 | OLED 屏幕显示当前菜单/状态 |
彩色灯指示 | 用颜色区分不同状态 |
按键交互 | 支持菜单选择和确认操作 |
二、方案设计
2.1 供电方案解决
基于原理图分析,发现其中有多余 Pin 预留,通过万用表的蜂鸣档测量其中导线是连接的,则采取飞线的方式进行供电实现。

ADI ADMT4000 |
根据分析我们可以直接飞线实现供电的操作。

飞线供电实现 |
2.2 原理图设计

原理图实现 |
2.3 关键器件选型
模块 | 选型 | 选型理由 |
主控 | Teensy 4.0 | 支持 USB HID,可直接作为键盘使用 |
显示 | SSD1306 OLED 128x64 | I2C 接口,库支持完善 |
灯珠 | WS2812B x4 | 单线控制,视觉反馈 |
马达 | 震动马达 + 二极管保护 | 防止电流倒灌损坏芯片 |
稳压 | AP2112K-3.3 | 给马达和 WS2812B 提供稳定的输出 |
按键 | BUTTON | 进行按键对应菜单操作 |
三、硬件实现
3.1 主控板设计
PCB 布局思路:
• 1.25mm 排座连接 ADMT4000,减少杜邦线使用
• 背面丝印活动相关信息
• 元器件紧凑排列
• 标注相关丝印注释

PCB 正面图 |

PCB 背面图 |
引脚分配:
功能 | 引脚 |
马达 PWM | GPIO 5 |
彩色灯数据 | GPIO 6 |
左按键 | GPIO 7 |
右按键 | GPIO 8 |
OLED SDA | GPIO 18 |
OLED SCL | GPIO 19 |
3.2 焊接组装
注意:这里的排线连接线需要购买其中反向的,否则其中的引脚则对应不上
马达我直接贴在板子的 typec 口上进行震动反馈。

焊接完成连接实物图 |
四、软件实现
4.1 开发环境
• 核心库:Wire、Adafruit_GFX、Adafruit_SSD1306、FastLED
• 开发板:Teensy 4.0
• 串口速率:115200 baud
4.2 测试软件初始化
void setup() {
Serial.begin(115200);
delay(1000);
// --- 按键初始化 ---
// 硬件已加电容到 GND,开启内部上拉即可
pinMode(SW_LEFT_PIN, INPUT_PULLUP);
pinMode(SW_RIGHT_PIN, INPUT_PULLUP);
// --- 马达初始化 ---
pinMode(MOTOR_PWM_PIN, OUTPUT);
digitalWrite(MOTOR_PWM_PIN, LOW);
// --- WS2812B 初始化 ---
FastLED.addLeds<WS2812B, LED_DATA_PIN, GRB>(leds, NUM_LEDS);
// ⚠️ 重要:限制最大亮度保护 USB 供电
FastLED.setBrightness(30);
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();
// --- OLED 初始化 ---
if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
// 屏幕连接异常,红灯狂闪报警
while(true) {
fill_solid(leds, NUM_LEDS, CRGB::Red); FastLED.show(); delay(100);
fill_solid(leds, NUM_LEDS, CRGB::Black); FastLED.show(); delay(100);
}
}
Serial.println("✅ 所有外设初始化成功!");
}
4.3 测试主循环逻辑
void loop() {
bool leftPressed = (digitalRead(SW_LEFT_PIN) == LOW);
bool rightPressed = (digitalRead(SW_RIGHT_PIN) == LOW);
if (leftPressed) {
// 左键:轻震 + 绿灯 + 屏幕提示
Serial.println("<- 左键被按下!");
display.clearDisplay();
display.setCursor(0, 25);
display.println("LEFT BTN!");
display.display();
fill_solid(leds, NUM_LEDS, CRGB::Green);
FastLED.show();
analogWrite(MOTOR_PWM_PIN, 80); // 轻微震动
while(digitalRead(SW_LEFT_PIN) == LOW) { delay(10); }
analogWrite(MOTOR_PWM_PIN, 0);
}
else if (rightPressed) {
// 右键:狂震 + 蓝灯 + 屏幕提示
Serial.println("-> 右键被按下!");
display.clearDisplay();
display.setCursor(0, 25);
display.println("RIGHT BTN!");
display.display();
fill_solid(leds, NUM_LEDS, CRGB::Blue);
FastLED.show();
analogWrite(MOTOR_PWM_PIN, 200); // 强力震动
while(digitalRead(SW_RIGHT_PIN) == LOW) { delay(10); }
analogWrite(MOTOR_PWM_PIN, 0);
}
else {
// 待机:呼吸灯 + 屏幕显示
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
display.println("CYBER-VAULT OS v1.0");
display.setTextSize(2);
display.setCursor(10, 30);
display.println("WAITING...");
display.display();
// 紫色呼吸灯效果
fill_solid(leds, NUM_LEDS, CHSV(190, 255, quadwave8(breath)));
FastLED.show();
breath += 2;
delay(20);
}
}
如果板子的效果有正确的反馈说明板子焊接没有问题--- 这个是测试我设计的焊接小板的测试软件!
五、芯片分析
https://www.analog.com/media/en/technical-documentation/data-sheets/admt4000.pdf
我们在官网中可以获得其中的相关资料,我们一步步的查看其中的相关信息,通过数据手册来了解其中要如何驱动。
5.1 通讯协议 SPI

我们可以知道其中 SPI 的模式是 0,然后其中的传输数据是 32 位。
5.2 校验 CRC

读写寄存器的 CRC 校验是不同的,需要区分判断
uint8_t calculateCRC5(uint8_t is_read, uint8_t address, uint16_t data, uint8_t status_bits) {
uint32_t data_in = 0;
// is_read: 0代表读,1代表写
data_in |= ((uint32_t)(is_read & 0x01) << 25);
data_in |= ((uint32_t)(address & 0x3F) << 19);
data_in |= ((uint32_t)(data & 0xFFFF) << 3);
data_in |= (status_bits & 0x07);
uint8_t shft[5] = {1, 1, 1, 1, 1};
for (int i = 25; i >= 0; i--) { // 26 次循环
uint8_t bit_val = (data_in >> i) & 0x01;
uint8_t xor_0 = bit_val ^ shft[4];
shft[4] = shft[3];
shft[3] = shft[2];
shft[2] = shft[1] ^ xor_0;
shft[1] = shft[0];
shft[0] = xor_0;
}
uint8_t crc = (shft[4] << 4) | (shft[3] << 3) | (shft[2] << 2) | (shft[1] << 1) | shft[0];
return crc;
}
5.3 读写寄存器


uint16_t readSPIRegister32(uint8_t address) {
digitalWrite(CS_PIN, LOW);
uint8_t cmd_byte = address & 0x3F;
uint8_t rx0 = SPI.transfer(cmd_byte);
uint8_t rx1 = SPI.transfer(0x00);
uint8_t rx2 = SPI.transfer(0x00);
uint8_t rx3 = SPI.transfer(0x00);
digitalWrite(CS_PIN, HIGH);
SPI.endTransaction();
uint16_t data = (rx1 << 8) | rx2;
uint8_t status_bits = (rx3 >> 5) & 0x07;
uint8_t received_crc = rx3 & 0x1F;
uint8_t calculated_crc = calculateCRC5(0, cmd_byte, data, status_bits);
if (calculated_crc == received_crc) {
return data;
} else {
return 0xFFFF; // 返回错误代码
}
}
void writeSPIRegister32(uint8_t address, uint16_t data) {
uint8_t crc = calculateCRC5(1, address, data, 0);
uint8_t byte1 = 0x40 | (address & 0x3F);
uint8_t byte2 = (data >> 8) & 0xFF;
uint8_t byte3 = data & 0xFF;
uint8_t byte4 = crc & 0x1F;
digitalWrite(CS_PIN, LOW);
SPI.transfer(byte1);
SPI.transfer(byte2);
SPI.transfer(byte3);
SPI.transfer(byte4);
digitalWrite(CS_PIN, HIGH); // 通信结束
SPI.endTransaction();
}
5.4 寄存器查看
其中最主要的功能,就是查看其中的相关寄存器地址和是否读写数据位,来实现我们的操作,具体内容可以到对应的寄存器的解释查看。

六、完整模块详解
6.1 系统总体架构方案框图
💡 以下流程图展示了整个软件系统的运行逻辑

6.2 主菜单(STATE_MENU)
case STATE_MENU:
{
u8g2_prep();
// 菜单项列表
const char* ms[] = {
"1. 圈数密码锁",
"2. 街机打砖块",
"3. 电脑调音",
"4. 系统监控"
};
for(int i=0; i<4; i++){
int y_pos = 15 + i*15;
if(i==menuIndex) u8g2.drawUTF8(5, y_pos, "> "); // 选中标记
u8g2.drawUTF8(20, y_pos, ms[i]);
}
u8g2.sendBuffer();
// 💡 旋钮旋转切换菜单(每 15° 切换一项)
float menuDiff = currentAngle - lastMenuAngle;
if (menuDiff > 180) menuDiff -= 360;
if (menuDiff < -180) menuDiff += 360;
if (abs(menuDiff) >= 15.0) {
if (menuDiff > 0) menuIndex = (menuIndex + 1) % 4;
else menuIndex = (menuIndex - 1 + 4) % 4;
hapticTick(); // 菜单切换震动反馈
lastMenuAngle = currentAngle;
}
// 右键进入选中功能
if(right) {
hapticTick();
if(menuIndex==0) {
currentState = STATE_PWD_INPUT;
inputStep = 0;
stepStartTurns = hardwareTurns;
}
if(menuIndex==1) { /* 初始化游戏参数 */ }
if(menuIndex==2) { currentState = STATE_VOLUME_CTRL; lastTickAngle = currentAngle; }
if(menuIndex==3) { currentState = STATE_SYS_INFO; }
while(digitalRead(SW_RIGHT_PIN)==LOW);
}
break;
}

主菜单界面 |
6.3 圈数密码锁(STATE_PWD_INPUT)
🎯 核心功能:通过旋转旋钮输入圈数,模仿机械密码锁的解锁体验
核心机制:悬停确认
case STATE_PWD_INPUT:
{
int currentDiffTurns = hardwareTurns - stepStartTurns;
u8g2_prep();
u8g2.drawUTF8(15, 15, "--- 机械金库锁 ---");
// 显示目标圈数
u8g2.setCursor(5, 35);
u8g2.print("目标: ");
if (pwdTurns[inputStep] > 0) { u8g2.print("右转 "); u8g2.print(pwdTurns[inputStep]); u8g2.print(" 圈"); }
else { u8g2.print("左转 "); u8g2.print(-pwdTurns[inputStep]); u8g2.print(" 圈"); }
// 显示当前圈数
u8g2.setCursor(5, 50);
u8g2.print("当前: ");
if (currentDiffTurns > 0) { u8g2.print("右转 "); u8g2.print(currentDiffTurns); u8g2.print(" 圈"); }
else if (currentDiffTurns < 0) { u8g2.print("左转 "); u8g2.print(-currentDiffTurns); u8g2.print(" 圈"); }
else { u8g2.print("0 圈"); }
// 🔑 悬停确认逻辑:停在目标圈数 1 秒后自动进入下一步
static unsigned long dwellTime = 0;
static bool isDwelling = false;
if (currentDiffTurns == pwdTurns[inputStep]) {
if (!isDwelling) {
isDwelling = true;
dwellTime = millis();
} else {
unsigned long holdingTime = millis() - dwellTime;
// 绘制确认进度条(1 秒读满)
int barWidth = map(holdingTime, 0, 1000, 0, 118);
if (barWidth > 118) barWidth = 118;
u8g2.drawFrame(4, 57, 120, 6);
u8g2.drawBox(5, 58, barWidth, 4);
// 悬停超过 1 秒,自动确认
if (holdingTime >= 1000) {
inputStep++;
stepStartTurns = hardwareTurns;
isDwelling = false;
if (inputStep >= 3) {
// 全部密码输入完成,解锁!
currentState = STATE_UNLOCKED;
analogWrite(MOTOR_PWM_PIN, 255);
delay(500);
analogWrite(MOTOR_PWM_PIN, 0);
resetHardwareTurns();
} else {
hapticConfirm(); // 阶段成功震动
}
}
}
} else {
isDwelling = false; // 偏离目标,打断确认
}
u8g2.sendBuffer();
// 每当物理圈数变化时,震动提示
static int8_t lastVibrationTurn = hardwareTurns;
if (hardwareTurns != lastVibrationTurn) {
analogWrite(MOTOR_PWM_PIN, 255);
delay(60);
analogWrite(MOTOR_PWM_PIN, 0);
lastVibrationTurn = hardwareTurns;
}
// 右键重置当前步骤
if (right) {
inputStep = 0;
stepStartTurns = hardwareTurns;
isDwelling = false;
hapticBoom();
while(digitalRead(SW_RIGHT_PIN) == LOW);
}
if(left) { currentState = STATE_MENU; lastMenuAngle = currentAngle; }
break;
}

密码锁界面 |
6.4 音量控制(STATE_VOLUME_CTRL)
💡 功能:旋钮旋转调节电脑系统音量,右键一键静音
case STATE_VOLUME_CTRL:
{
u8g2_prep();
u8g2.drawUTF8(20, 20, "[[ 桌面调音台 ]]");
u8g2.drawUTF8(10, 45, "旋钮:调音 | 右键:静音");
u8g2.sendBuffer();
float diff = currentAngle - lastTickAngle;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
// 🔑 每旋转 5° 发送一次音量调节信号
if (abs(diff) >= 5.0) {
hapticTick();
if (diff > 0) {
Keyboard.press(KEY_MEDIA_VOLUME_INC);
Keyboard.release(KEY_MEDIA_VOLUME_INC);
}
else {
Keyboard.press(KEY_MEDIA_VOLUME_DEC);
Keyboard.release(KEY_MEDIA_VOLUME_DEC);
}
lastTickAngle = currentAngle;
}
// 右键静音
if (right) {
analogWrite(MOTOR_PWM_PIN, 255);
delay(80);
analogWrite(MOTOR_PWM_PIN, 0);
Keyboard.press(KEY_MEDIA_MUTE);
Keyboard.release(KEY_MEDIA_MUTE);
while(digitalRead(SW_RIGHT_PIN) == LOW);
}
if(left) { currentState = STATE_MENU; lastMenuAngle = currentAngle; }
break;
}

音量控制界面 |
6.5 系统监控(STATE_SYS_INFO)
case STATE_SYS_INFO:
{
u8g2_prep();
u8g2.drawUTF8(30, 15, "[ 硬件监控 ]");
u8g2.setCursor(15, 33);
u8g2.print("核心温度: ");
u8g2.print(currentTemp, 1);
u8g2.print(" C");
u8g2.setCursor(15, 48);
u8g2.print("拨盘角度: ");
u8g2.print(currentAngle, 1);
u8g2.print("°");
u8g2.setCursor(15, 63);
u8g2.print("绝对圈数: ");
u8g2.print(hardwareTurns);
u8g2.print(" 圈");
u8g2.sendBuffer();
if(left) { currentState = STATE_MENU; lastMenuAngle = currentAngle; }
break;
}
系统监控界面 |
6.6 打砖块游戏(STATE_BALL_GAME)
🎮 玩法:旋钮控制底部挡板,让小球消除所有砖块
case STATE_BALL_GAME:
{
u8g2_prep();
if (gameState == 0) {
// ========== 游戏进行中 ==========
// 1. 旋钮控制挡板(带灵敏度调整)
float diff = currentAngle - lastTickAngle;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
if (abs(diff) > 1.0) {
paddleX += diff * 0.6; // 灵敏度系数
if (paddleX < 0) paddleX = 0;
if (paddleX > 128 - paddleWidth) paddleX = 128 - paddleWidth;
lastTickAngle = currentAngle;
}
// 2. 小球运动
ballX += ballVX;
ballY += ballVY;
// 边界反弹
if (ballX <= 0 || ballX >= 126) ballVX = -ballVX;
if (ballY <= 0) ballVY = -ballVY;
// 3. 挡板碰撞检测
if (ballY >= 56 && ballY <= 60
&& ballX >= paddleX - 2
&& ballX <= paddleX + paddleWidth + 2
&& ballVY > 0) {
ballVY = -ballVY;
hapticTick();
}
// 4. 砖块碰撞检测(16 宫格砖块)
for (int i = 0; i < 16; i++) {
if (bricks & (1 << i)) {
int bx = (i % 8) * 16;
int by = (i / 8) * 8 + 10;
if (ballX >= bx && ballX <= bx + 14
&& ballY >= by && ballY <= by + 6) {
bricks &= ~(1 << i); // 消除砖块
ballVY = -ballVY;
hapticTick();
break;
}
}
}
// 5. 游戏结束判断
if (bricks == 0) gameState = 1; // 通关
if (ballY >= 64) { gameState = 2; hapticBoom(); } // 失败
// 6. 绘制画面
u8g2.drawBox(paddleX, 58, paddleWidth, 4);
u8g2.drawDisc(ballX, ballY, 2);
for (int i = 0; i < 16; i++) {
if (bricks & (1 << i)) {
u8g2.drawBox((i % 8) * 16, (i / 8) * 8 + 10, 14, 6);
}
}
}
else if (gameState == 1) {
// ========== 通关画面 ==========
u8g2.drawUTF8(35, 30, "通关成功!");
u8g2.drawUTF8(15, 50, "左:返回 | 右:重试");
if (right) {
ballX = 64.0; ballY = 40.0;
ballVX = 1.5; ballVY = -1.5;
paddleX = 52.0;
bricks = 0xFFFF;
gameState = 0;
lastTickAngle = currentAngle;
while(digitalRead(SW_RIGHT_PIN)==LOW);
}
}
else if (gameState == 2) {
// ========== 失败画面 ==========
u8g2.drawUTF8(35, 30, "Game Over");
u8g2.drawUTF8(15, 50, "左:返回 | 右:重试");
if (right) { /* 同上,重置游戏 */ }
}
u8g2.sendBuffer();
if(left) { currentState = STATE_MENU; lastMenuAngle = currentAngle; }
break;
}

打砖块游戏界面 |
6.7 解锁成功(STATE_UNLOCKED)
case STATE_UNLOCKED:
{
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_wqy16_t_chinese3);
u8g2.drawUTF8(20, 40, "You Win!");
u8g2.sendBuffer();
// 💡 彩虹灯效果庆祝
static uint8_t hue = 0;
fill_rainbow(leds, NUM_LEDS, hue++, 30);
FastLED.show();
// 任意按键返回菜单
if(left || right) {
currentState = STATE_MENU;
lastMenuAngle = currentAngle;
while(digitalRead(SW_LEFT_PIN)==LOW || digitalRead(SW_RIGHT_PIN)==LOW);
}
break;
}
七、调试记录
7.1 问题汇总表
# | 问题现象 | 原因分析 | 解决方案 | 预防措施 |
1 | 稳压芯片发烫 | 彩色灯焊接方向反了,导致电流倒灌短路 | 拆除灯珠重新焊接,注意方向 | 焊接前用万用表确认 PCB 丝印方向 |
2 | 部分彩色灯不亮 | 灯珠虚焊或方向错误 | 检查不亮灯珠的焊接 | 上电前目视检查焊点 |
3 | 马达震动时系统重启 | 瞬时电流过大 | 添加二极管保护电路 | 马达使用独立 PWM 控制 |
7.2 调试心得
关于 WS2812B 灯珠:
• ⚠️ 正负极不能接反,接反会直接烧毁灯珠
• 灯珠串联连接,数据信号从 DIN 流入,DOUT 流出
• 如果只有前半段亮,检查后半段灯珠的焊接
关于 PWM 马达:
• 建议加一个反向二极管,防止关闭时产生反向电动势
• 震动强度不要调太高,避免瞬时电流过大
关于 OLED 屏幕:
• I2C 地址固定为 0x3C
• SDA 接 GPIO 18,SCL 接 GPIO 19(Teensy 4.0 的 Wire 接口)
八、总结与改进
8.1 成果总结
完成项 | 状态 |
供电改造(飞线供电) | ✅ 完成 |
主控板 PCB 设计 | ✅ 完成 |
OLED 状态显示 | ✅ 完成 |
WS2812B 彩色灯控制 | ✅ 完成 |
震动马达反馈 | ✅ 完成 |
按键交互 | ✅ 完成 |
8.2 经验沉淀
1. 供电设计优先:项目开始前先确认供电方案,避免后期被动
2. 灯珠注意方向:WS2812B 焊接前务必确认 PCB 丝印方向
3. 亮度限制:WS2812B 最大亮度会拉垮 USB 供电
