这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » DIY与开源设计 » 电子DIY » 给28BYJ-48三自由度机械臂加一个网页控制台

共1条 1/1 1 跳转至页

给28BYJ-48三自由度机械臂加一个网页控制台

助工
2026-10-05 11:18:11     打赏

给上一篇的28BYJ-48三自由度机械臂加一个网页控制台,手机或电脑打开浏览器,拖动三个手柄就能指挥机械臂转动,不需要装任何软件。

1、机械臂简介

机械臂有三个关节:J1是底座,负责水平旋转;J2是肩、J3是肘,负责俯仰。网页上拖动蓝色手柄预览姿态,松手机械臂一步到位。角度逆时针为正,三轴行程都是±90°,0°是上电中间位。

1791169960973163.jpg

2、控制原理

控制链路很短:浏览器发出HTTP请求,ESP32上的服务器接收后驱动步进电机。网页下发的是绝对角度,固件自己算出还差多少步再决定正转反转,连续下发也不会累加错乱。

3、软件介绍

软件由三个文件组成,全部上传到ESP32:

  • stepper3.py:三轴驱动,主循环补步输出脉冲,支持同步/独立两种运动模式,越界自动夹到±90°。(详见上一篇)

  • server.py:HTTP服务器,提供5个接口,控制页面在启动时读入内存。

  • index.html:网页控制台,纯HTML加SVG加原生JavaScript,不用任何前端框架。(详见附件)

服务器接口如下:

方法路径作用




GET/返回控制页面
GET/api/status返回三轴角度和运动状态
GET/api/move?j1=&j2=&j3=&hz=&sync=转到指定关节角
POST/api/stop急停
GET/api/home三轴回0°

服务器参考代码如下:

import network
import socket
import time
import stepper3 as st

SSID = "SSID"
PASS = "PASS"
PORT = 80
LOOP_SLEEP_MS = 1             # 主循环空转间隔:1ms → tick 约 500~900Hz

def _wifi():
   w = network.WLAN(network.STA_IF)
   w.active(True)
   time.sleep_ms(1500)   # P4 的 WiFi 跑在板载 C6 协处理器上,start 是异步的,等它就绪
   if not w.isconnected():
       for i in range(3):
           print("连接 WiFi: %s(第 %d 次)" % (SSID, i + 1))
           try:
               w.connect(SSID, PASS)
           except OSError as e:
               print("  connect 报错:", e)
           for _ in range(150):
               if w.isconnected():
                   break
               time.sleep_ms(200)
           if w.isconnected():
               break
           try:
               w.disconnect()   # 复位 STA 状态再重试(diag 实测第 2 次必成)
           except OSError:
               pass
           time.sleep_ms(1000)
   if w.isconnected():
       print("WiFi 已连接,控制页地址: http://%s/" % w.ifconfig()[0])
   else:
       print("WiFi 连接失败:运行 diag_wifi.py 诊断,把输出发回来")
   return w

_PAGE = None
_poll = 0            # /api/status 心跳计数(串口诊断用)
_last_moving = False # 上次轮询时的运动状态(检测"到位"时刻)
_hb_tick = 0         # 上次心跳时的 tick 次数
_hb_dt = 0           # 上次心跳时的累计流逝毫秒

def _page():
   global _PAGE
   if _PAGE is None:
       with open("index.html", "rb") as f:
           _PAGE = f.read()
   return _PAGE

def _send_all(conn, buf):
   """非阻塞发送,期间持续 tick(大页面也不会卡住步进)"""
   mv = memoryview(buf) if not isinstance(buf, memoryview) else buf
   n = 0
   t0 = time.ticks_ms()
   while n < len(mv) and time.ticks_diff(time.ticks_ms(), t0) < 3000:
       st.tick()
       try:
           k = conn.send(mv[n:])
       except OSError:
           k = 0
       if k:
           n += k
       else:
           time.sleep_ms(1)

def _send(conn, code, ctype, body):
   if isinstance(body, str):
       body = body.encode()
   head = ("HTTP/1.1 %d OK\r\nContent-Type: %s\r\nContent-Length: %d"
           "\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n"
           % (code, ctype, len(body))).encode()
   try:
       _send_all(conn, head)
       _send_all(conn, body)
   except OSError:
       pass
   try:
       conn.close()
   except OSError:
       pass

def _query(qs):
   d = {}
   for kv in qs.split("&"):
       if "=" in kv:
           k, v = kv.split("=", 1)
           d[k] = v
   return d

def _handle(conn, req):
   global _poll, _last_moving, _hb_tick, _hb_dt
   line = req.split("\r\n", 1)[0]
   parts = line.split(" ")
   if len(parts) < 2:
       return _send(conn, 400, "text/plain", "bad request")
   route, _, qs = parts[1].partition("?")
   q = _query(qs)

   if route != "/api/status":   # 状态轮询量大,单独用心跳节流
       print("[请求] %s %s" % (parts[0], parts[1]))

   if route == "/":
       try:
           return _send(conn, 200, "text/html; charset=utf-8", _page())
       except OSError:
           return _send(conn, 500, "text/plain", "index.html not found")

   if route == "/api/status":
       _poll += 1
       if _poll % 20 == 0:   # 页面每 0.5s 轮询一次 → 约 10s 打一条心跳
           tk, dts, dtm = st.stats(True)
           print("[心跳] tick %d 次(%d Hz) 累计 %d ms 最长间隔 %d ms(健康:>=200Hz, 最长<=5ms)"
                 % (tk - _hb_tick, (tk - _hb_tick) // 10, dts - _hb_dt, dtm))
           _hb_tick, _hb_dt = tk, dts
       a = st.angles()
       m = st.moving()
       if _last_moving and not m:
           print("[到位] j1=%.1f j2=%.1f j3=%.1f" % (a[0], a[1], a[2]))
       _last_moving = m
       body = '{"j1":%.1f,"j2":%.1f,"j3":%.1f,"moving":%d}' % (
           a[0], a[1], a[2], 1 if m else 0)
       return _send(conn, 200, "application/json", body)

   if route == "/api/move":
       def f(k):
           try:
               return float(q.get(k, "0"))
           except ValueError:
               return 0.0
       try:
           hz = int(float(q.get("hz", "400")))
       except ValueError:
           hz = 400
       sync = q.get("sync", "0") == "1"
       t1, t2, t3 = f("j1"), f("j2"), f("j3")
       st.set_targets(t1, t2, t3, hz, sync)
       print("[move] -> 剩余步数 %s  运动中 %s" % (st.steps_left(), st.moving()))
       a = st.angles()
       body = '{"ok":1,"j1":%.1f,"j2":%.1f,"j3":%.1f}' % (a[0], a[1], a[2])
       return _send(conn, 200, "application/json", body)

   if route == "/api/stop":
       st.stop_all()
       print("[急停] 当前角度 %s" % (tuple("%.1f" % x for x in st.angles()),))
       return _send(conn, 200, "application/json", '{"ok":1}')

   if route == "/api/home":
       print("[回中] 三轴回 0°")
       st.home()
       return _send(conn, 200, "application/json", '{"ok":1}')

   return _send(conn, 404, "text/plain", "not found")

def _recv_req(conn, tmo_ms=1500):
   """非阻塞收请求,期间持续 tick;对端关闭返回已收到的内容"""
   t0 = time.ticks_ms()
   data = b""
   while time.ticks_diff(time.ticks_ms(), t0) < tmo_ms:
       st.tick()
       try:
           chunk = conn.recv(512)
       except OSError:          # EAGAIN:暂时没数据
           time.sleep_ms(1)
           continue
       if not chunk:            # 对端关闭
           break
       data += chunk
       if b"\r\n\r\n" in data or len(data) > 2048:
           break
   return data

def _probe(s):
   """启动自测:量非阻塞 accept 单次耗时(ms),用来决定多久碰一次 socket"""
   try:
       t0 = time.ticks_ms()
       for _ in range(30):
           try:
               s.accept()
           except OSError:
               pass
       return time.ticks_diff(time.ticks_ms(), t0) / 30.0
   except Exception as e:
       print("自测跳过:", e)
       return 0.5

def run():
   _wifi()
   s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
   s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
   s.bind(("0.0.0.0", PORT))
   s.listen(3)
   s.setblocking(False)     # 关键:非阻塞。阻塞超时路径实测要 ~65ms,主循环只有 15Hz
   print("Web 服务已启动,端口 %d(非阻塞 + 主循环 tick 驱动步进)" % PORT)
   cost = _probe(s)
   # socket 调用会卡住 tick,所以不必每轮都碰 socket:
   # 让 socket 开销占总时间 <20%,其余时间留给 tick
   poll_n = 1
   if cost > 2.0:
       poll_n = min(100, int(cost / 2.0) + 1)
   print("自测:accept 单次 %.2fms → 每 %d 轮轮询一次 socket,预计 tick %.0f Hz(步进需 >=200)"
         % (cost, poll_n, 1000.0 / (LOOP_SLEEP_MS + cost / poll_n)))
   st.tick(); st.stats(True)   # 重置计时基准,避免把 WiFi 等待算进"最长间隔"
   i = 0
   while True:
       st.tick()
       i += 1
       if i % poll_n == 0:
           try:
               conn, addr = s.accept()
           except OSError:
               conn = None
           if conn:
               conn.setblocking(False)
               try:
                   req = _recv_req(conn)
                   if req:
                       _handle(conn, req.decode("utf-8", "ignore"))
                   else:
                       conn.close()
               except Exception as e:
                   print("请求异常:", e)
                   try:
                       conn.close()
                   except Exception:
                       pass
       time.sleep_ms(LOOP_SLEEP_MS)

run()

网页端每0.5秒轮询一次/api/status同步真实角度;拖动时只更新动画不发请求,松手才下发一次,无论拖多快机械臂只会收到一条指令。

4、使用方法

把三个文件上传到ESP32,填好WiFi账号密码,运行服务器后串口会打印出控制页地址,同一WiFi下的手机或电脑打开该地址即可。上电前先把机械臂摆在中间位置,那是它的0°。

机械臂没有任何位置传感器,角度完全靠步数推算,断电后位置会丢失,每次上电前需要手动把三个关节摆回中间位。

index.zip





关键词: 机械臂    

共1条 1/1 1 跳转至页

回复

匿名不能发帖!请先 [ 登陆 注册 ]