title: 从 0 开始做一个互联网主机秘钥 (基于 ESP32 ) description: ESP32, 秘钥, 互联网主机 slug: esp32-diy date: 2025-07-30 23:36:00+0000 image: logo.png categories:
前阵子刷“什么值得买”的时候看到,
[流浪地球 2 ] 联名款互联网主机秘钥充电宝大甩卖了,
https://post.smzdm.com/p/aev3zw74/
原价两三百的玩意,有故障的大概四五十块块,能用的大概 80-90 。
200 块买个上不了飞机的充电宝是个大聪明,
90 块买个联名手办还要什么自行车啊。
于是,下单搞了一个。到手了如下:
一个数码管的显示屏,下面三个按钮可以控制显示功能。
甚至还能看到充电功率和电量~
能用,很重,不适合出门。
确实就是个手办玩具。
过了两天又想了下,要不拆了这玩意,‘
把显示屏接到自己的设备上,
在设备上写个支持 Authenticator 2FA 的程序,
让它做个真正的“互联网主机秘钥”设备?
说干就干。
首先就是选个硬件板子,需要小,同时能驱动显示屏。
看了一下,ESP32 很合适,IO 口够用,
能驱动小显示器,能连接 WIFI ,
价格也便宜~
先看下最终效果:
上了淘宝看了一圈,最终找了一个 ESP32C3 的板子。
支持 C 口,电脑直连就完事了。
什么驱动都不需要,甚至 macOS 也是直接能用的~
看了一圈开发框架之后,发现还是 micropython 比较简单。
boot.py 是框架自带的,自己的逻辑写在 main.py ,
IO 口和网络都内部自带,需要的驱动基本都能找到第三方库。
不过,需要自己刷个固件 -> 在 ESP32 上开始使用 MicroPython 。
# 安装 esptool 工具 pip install esptool # 擦除设备 ## macOS or linux esptool.py --port /dev/ttyUSB0 erase_flash ## windows 上 python esptool --port COM6 erase_flash # 按住 boot 键,执行刷入刷入固件 # 下载链接: https://micropython.org/download/ESP32_GENERIC_C3/ # 其他的版本自己看 esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 460800 write_flash -z 0x0000 micropython.bin ## Windows 版本 python esptool --port COM6 --baud 460800 write_flash -z 0x0000 micropython.bin ## 刷入成功之后,断电重启 刷入成功之后,断电重启。
验证 micropython 环境
最简单的方案,VS Code Pymark 插件:Pymakr - Visual Studio Marketplace
装上去之后,在左侧栏目,点击一下“
”图标,选择“List Device”。
如果 ListDevice 没有出现或者报错了,
可能需要再安装一下 Nodejs 运行环境。( Windows 上碰到过:choco install nodejs 完事。)
再点击“设备 COM”,选择“连接”图标之后,在选择“终端”图标。
选择终端图标之后,能看到这个命令行界面,说明 micropython 环境好了。
如果看到的是报错,估计是上面的固件没有刷好。
重新尝试输入固件即可。
PS:可能需要切换--baud 460800 输入,具体的看 micropython 文档或者问下 AI
esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 460800 write_flash -z 0x0000 micropython.bin
选屏幕是个大难题了。找了一圈很难找到这个规格的屏幕。
这玩意显示区域:1.8CM 高,15CM 宽,长条形。
在淘宝翻了一圈都没找到类似的规格。
最后退而求次:2.25 寸 TFT 液晶屏幕。
支持了 ST7789 ( micropython 有 st7789.py 驱动)。
显示屏的 8P 接口分表是:GND 、VCC 、SCL 、SDA 、RST 、DC 、CS 、BL 。
对着 ESP32 的引脚,就是在右侧一个个接进去。
ESP32 的引脚分别是:GND 、3.3V 、GPIO02 、GPIO03 、GPIO02 、GPIO10 、GPIO06 、GPIO07
对应代码:
SCK_PIN = 2 # SCL 引脚 (时钟) -> GPI002 MOSI_PIN = 3 # SDA 引脚 (数据) -> GPI003
RST_PIN = 10 # RST 引脚 (复位) -> GPI010 DC_PIN = 6 # DC 引脚 (数据/命令) -> GPI006 CS_PIN = 7 # CS 引脚 (片选) -> GPI007
最后屏幕的 BE 口接到另一个 GND 。(这里我也没弄懂,测试出来的
然后屏幕就点亮了。
"开始表演" :项目代码 -rw-r--r-- 1 liguobao staff 6.3K 7 30 11:05 http_server.py -rw-r--r-- 1 liguobao staff 4.3K 7 30 11:05 main.py -rw-r--r-- 1 liguobao staff 265B 7 20 13:53 pymakr.conf -rw-r--r-- 1 liguobao staff 1.6K 7 30 11:05 show_text.py -rw-r--r--@ 1 liguobao staff 30K 7 22 15:19 st7789.py -rw-r--r--@ 1 liguobao staff 3.6K 7 22 15:19 vga1_8x8.py
st7789 和 vga1 都来源于: https://github.com/russhughes/st7789py_mpy/
连接 WIFI + 实现 HTTP 服务 http_server.py
import network import time import socket #from show_text import display_text_on_tft # --- WiFi 网络配置列表 --- WIFI_NETWORKS = [ {"ssid": "xiaomi_505", "password": "密码"}, {"ssid": "miaowuwu_505", "password": "密码"} ] def connect_wifi(ssid, password): """连接到指定的 WiFi 网络""" wlan = network.WLAN(network.STA_IF) wlan.active(True) if not wlan.isconnected(): print(f"正在连接 WiFi: {ssid}") wlan.connect(ssid, password) for _ in range(15): # 最多等待 15 秒 if wlan.isconnected(): ip_address = wlan.ifconfig()[0] print(f"连接成功,IP 地址: {ip_address}") return ip_address time.sleep(1) print(f"WiFi 连接失败: {ssid}") return None else: ip_address = wlan.ifconfig()[0] print(f"WiFi 已连接,IP 地址: {ip_address}") return ip_address def connect_to_available_wifi(): """尝试连接到可用的 WiFi 网络""" print("开始扫描并连接可用的 WiFi 网络...") wlan = network.WLAN(network.STA_IF) wlan.active(True) # 如果已经连接,直接返回 IP 地址 if wlan.isconnected(): ip_address = wlan.ifconfig()[0] print(f"WiFi 已连接,IP 地址: {ip_address}") return ip_address # 扫描可用的 WiFi 网络 print("正在扫描 WiFi 网络...") networks = wlan.scan() available_ssids = [net[0].decode('utf-8') for net in networks] print(f"扫描到的网络: {available_ssids}") # 尝试连接配置中的 WiFi 网络 for wifi_config in WIFI_NETWORKS: ssid = wifi_config["ssid"] password = wifi_config["password"] if ssid in available_ssids: print(f"找到配置的网络: {ssid},尝试连接...") ip_address = connect_wifi(ssid, password) if ip_address: return ip_address else: print(f"未找到网络: {ssid}") print("无法连接到任何配置的 WiFi 网络") return None def url_decode(text): """简单的 URL 解码""" text = text.replace('+', ' ') text = text.replace('%20', ' ') text = text.replace('%21', '!') text = text.replace('%22', '"') text = text.replace('%23', '#') text = text.replace('%24', '$') text = text.replace('%25', '%') text = text.replace('%26', '&') text = text.replace('%27', "'") text = text.replace('%28', '(') text = text.replace('%29', ')') text = text.replace('%2A', '*') text = text.replace('%2B', '+') text = text.replace('%2C', ',') text = text.replace('%2D', '-') text = text.replace('%2E', '.') text = text.replace('%2F', '/') return text def handle_request(tft, conn): """处理 HTTP 请求""" try: request = conn.recv(1024).decode('utf-8') print(f"收到请求:\n{request}") # 解析请求行 lines = request.split('\n') if lines: request_line = lines[0] parts = request_line.split(' ') if len(parts) >= 2: method = parts[0] path = parts[1] print(f"方法: {method}, 路径: {path}") if method == 'GET': # 解析查询参数 if '?' in path: path_part, query_part = path.split('?', 1) params = {} for param in query_part.splt('&'): if '=' in param: key, value = param.split('=', 1) params[key] = url_decode(value) # 获取 text 参数 received_text = params.get('text', None) else: received_text = None if not received_text: respOnse= """HTTP/1.1 400 Bad Request\r Content-Type: text/plain\r Connection: close\r \r Bad Request: 'text' parameter is required""" print("请求中缺少'text'参数") conn.send(response.encode('utf-8')) conn.close() return print(f"从 GET 请求中提取的文本: '{received_text}'") show_text = f"Received Text:\n{received_text}" # 显示器代码 # display_text_on_tft(tft, show_text) # 发送成功响应 respOnse= """HTTP/1.1 200 OK\r Content-Type: text/plain\r Connection: close\r \r Text received and displayed!""" else: # 方法不允许 respOnse= """HTTP/1.1 405 Method Not Allowed\r Content-Type: text/plain\r Connection: close\r \r Method Not Allowed. Only GET is supported.""" else: respOnse= """HTTP/1.1 400 Bad Request\r Content-Type: text/plain\r Connection: close\r \r Bad Request""" else: respOnse= """HTTP/1.1 400 Bad Request\r Content-Type: text/plain\r Connection: close\r \r Bad Request""" conn.send(response.encode('utf-8')) except Exception as e: print(f"处理请求时出错: {e}") try: error_respOnse= """HTTP/1.1 500 Internal Server Error\r Content-Type: text/plain\r Connection: close\r \r Internal Server Error""" conn.send(error_response.encode('utf-8')) except: pass finally: conn.close() def start_http_server(tft, ip_address, port=80): """启动 HTTP 服务器""" addr = socket.getaddrinfo(ip_address, port)[0][-1] s = socket.socket() s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(addr) s.listen(1) print(f'HTTP 服务器已启动,监听地址: http://{ip_address}:{port}') display_text_on_tft(tft, f"HTTP Server\nListening on {ip_address}:{port}") while True: try: conn, addr = s.accept() print(f'客户端连接来自: {addr}') handle_request(tft, conn) except KeyboardInterrupt: print("服务器停止") break except Exception as e: print(f"服务器错误: {e}") s.close()
import network import time import socket from machine import Pin, SPI import vga1_8x8 as font import st7789 from show_text import display_text_on_tft from http_server import connect_to_available_wifi, handle_request, start_http_server # -------------------------- # --- ST7789 显示屏引脚定义 --- # 你的接线: SCL 、SDA 、RST 、DC 、CS -> GPI002 、GPI003 、GPI010 、GPI006 、GPI007 SCK_PIN = 2 # SCL 引脚 (时钟) -> GPI002 MOSI_PIN = 3 # SDA 引脚 (数据) -> GPI003 RST_PIN = 10 # RST 引脚 (复位) -> GPI010 DC_PIN = 6 # DC 引脚 (数据/命令) -> GPI006 CS_PIN = 7 # CS 引脚 (片选) -> GPI007 # -------------`-------------- # 初始化 TFT 对象 spi = SPI(1, baudrate=10000000, polarity=0, phase=0, sck=Pin(SCK_PIN), mosi=Pin(MOSI_PIN)) dc = Pin(DC_PIN, Pin.OUT) rst = Pin(RST_PIN, Pin.OUT) cs = Pin(CS_PIN, Pin.OUT) # 确保 CS 引脚初始状态 cs.value(1) time.sleep(0.01) # 硬件复位序列 rst.value(1) time.sleep(0.01) rst.value(0) time.sleep(0.01) rst.value(1) time.sleep(0.12) # 等待复位完成 def test_display_basic(): """基础显示测试函数""" print("开始基础显示测试...") # 手动发送基础命令测试 def send_command(cmd, data=None): cs.value(0) dc.value(0) # 命令模式 spi.write(cmd) if data: dc.value(1) # 数据模式 spi.write(data) cs.value(1) time.sleep(0.01) print("发送基础初始化命令...") send_command(b'\x01') # 软件复位 time.sleep(0.15) send_command(b'\x11') # 退出睡眠 time.sleep(0.12) send_command(b'\x29') # 显示开启 time.sleep(0.1) print("基础测试完成") def init_st7789_display(st_width, st_height, rotation=1): """初始化 ST7789 显示屏""" print(f"初始化 ST7789 显示屏: {st_width}x{st_height}, 旋转: {rotation}") tft = st7789.ST7789(spi, st_width, st_height, reset=rst, cs=cs, dc=dc, rotation=rotation) # rotation 对应 MADCTL 的 x36 寄存器值 rotation_to_x36 = { 0: 0x00, # 默认竖屏 1: 0x60, # 顺时针 90 度,横屏 2: 0xC0, # 180 度旋转 3: 0xA0, # 顺时针 270 度 } x36_value = rotation_to_x36.get(rotation, 0x00) print(f"内存访问控制值: {x36_value:#04x}") # 根据 rotation 选择列地址和行地址范围 if rotation % 2 == 0: col_start, col_end = 0, 75 # width - 1 row_start, row_end = 0, 283 # height - 1 else: col_start, col_end = 0, 283 row_start, row_end = 0, 75 init_commands = [ (b'\x01', None, 150), # 软件复位 (b'\x11', None, 120), # 退出睡眠模式 (b'\x3A', b'\x05', 10), # 设置像素格式 RGB565 (b'\x36', bytes([x36_value]), 10), # 内存访问控制 (b'\x2A', col_start.to_bytes(2, 'big') + col_end.to_bytes(2, 'big'), 10), # 列地址设置 (b'\x2B', row_start.to_bytes(2, 'big') + row_end.to_bytes(2, 'big'), 10), # 行地址设置 (b'\x21', None, 10), # 显示反转 (b'\x13', None, 10), # 正常显示 (b'\x29', None, 100), # 显示开启 ] tft.init(init_commands) print("init_commands finished") test_display_basic() # 测试像素显示 try: tft.pixel(0, 0, 0xFFFF) # 左上角 tft.pixel(st_width // 2, st_height // 2, 0xF800) # 中心点红色 tft.pixel(st_width - 1, st_height - 1, 0xFFFF) # 右下角 print(f"{st_width}x{st_height} 显示区域设置成功") return tft except Exception as e: print(f"显示测试失败: {e}") return None # 135 x 240 显示屏的初始化 # 这里假设你使用的是 ST7789 显示屏,分辨率为 135x240 # 如果你使用的是其他型号,请根据实际情况调整参数 ST_WIDTH = 240 # 驱动内部理解为 width ,但实际在横屏时变成了 height ST_HEIGHT = 320 # 初始化 TFT 对象 tft = init_st7789_display(ST_WIDTH, ST_HEIGHT, 3) # 旋转 3 表示顺时针 270 度 def main(): """主程序入口""" print("--- ESP32-C3 ---") tft.fill(st7789.BLACK) # 黑色背景 display_text_on_tft(tft, "ESP32-C3 TFT Display\nReady to receive text...") ip_address = connect_to_available_wifi() start_http_server(tft, ip_address) if __name__ == "__main__": main()
from machine import Pin, SPI import vga1_8x8 as font import st7789 def display_text_on_tft(tft, text_content): """在 TFT 显示屏上显示文本内容( 5 行,35 字符限制,行起点 x=20 )""" if not tft: print("错误: TFT 显示屏未初始化。") return tft.fill(st7789.BLACK) # 黑底 # 标题栏 tft.text(font, "Received Text:", 20, 2, 0x07FF) tft.text(font, "-" * 35, 20, 12, 0x07FF) x_offset = 20 # 每行起点 x 坐标修改为 20 max_chars_per_line = 35 y_start = 85 # 每行起点 y 坐标修改为 85 line_height = font.HEIGHT + 1 max_lines = 5 # 只显示 5 行 lines = text_content.split('\n') line_count = 0 for line in lines: while len(line) > max_chars_per_line: if line_count < max_lines: y_offset = y_start + line_count * line_height line_text = line[:max_chars_per_line] print(f"显示行: {line_text} at y={y_offset}") tft.text(font, line_text, x_offset, y_offset, st7789.WHITE) line = line[max_chars_per_line:] line_count += 1 else: # 行数用完,显示提示 tft.text(font, "... (more)", x_offset, y_start + (max_lines - 1) * line_height, 0xF800) return if line_count < max_lines: tft.text(font, line, x_offset, y_start + line_count * line_height, st7789.WHITE) line_count += 1 else: tft.text(font, "... (more)", x_offset, y_start + (max_lines - 1) * line_height, 0xF800) return
最终效果
装回去之后发现显示反了,安装位置没法改了,直接改下代码算了。
内部走线
塞了个 USB 线进去,供电+数据传输
因为屏幕尺寸和驱动支持的尺寸不那么匹配,最后采用的方案是代码逻辑上实现裁剪。
又因为屏幕尺寸翻转的时候需要更改内存地址,折腾这个搞了很久。
其他的代码倒都是不断试错验证,没太大的问题。
该设备组装是靠着固体胶水和卡扣,
不存在后期拆装的可能。
一切的拆装都基于暴力和破坏。
所以:
这玩意的拆卸是“有损方案”,不存在无损拆装改造。
这玩意的拆卸是“有损方案”,不存在无损拆装改造。
这玩意的拆卸是“有损方案”,不存在无损拆装改造。
内部屏幕没找到适配的 IC 驱动板,
左右两侧一共四个电源线焊接不那么牢固,
拆装过程很容易断开,
正常情况下很难复用这玩意
(有朋友有方案可以滴滴我。
电池拆装有起火风险,请谨慎操作。
电池拆装有起火风险,请谨慎操作。
电池拆装有起火风险,请谨慎操作。
最后。
祝玩得开心~
资料连接:
![]() | 1 lairdnote 71 天前 哈哈 牛 学习了 |
![]() | 2 codelover2016 OP @lairdnote 折腾不止。 |
![]() | 3 lairdnote 70 天前 @codelover2016 当年的 stm32f103 翻出来玩点东西 技术就是折腾 |
4 TrackBack 69 天前 怎么买到 90 块的?链接进去没翻到 |
![]() | 5 codelover2016 OP @TrackBack 咸鱼找就可以,坏的估计不到 50 ,好的就是 90 左右(当然那群人可能涨价了。 |