一、整体分层架构(从上到下)
- 应用层:网页 JS / ESP32 业务逻辑 网页:生成航点 JSON 文本,调用 WebSocket 发送 API; ESP32:接收 JSON、解析坐标、串口打印 / 转发飞控。
- 传输层:WebSocket 协议(基于 TCP) 底层依托 TCP 长连接,是两者直接通信的核心协议。
- 网络层:WiFi 局域网 IP 通信(IPv4) 电脑和 ESP32 接入同一个路由器 WiFi ,处于同一局域网,靠 IP 地址寻址(你的 ESP32:
192.168.1.74)。 - 物理层:2.4G WiFi 无线射频 ESP32 自带 WiFi 模组,电脑靠无线网卡收发路由器信号。
二、核心通信协议:WebSocket(重点)
1. 是什么
WebSocket 是基于 TCP 的全双工长连接协议,专门用于浏览器与设备实时双向通信,弥补普通 HTTP 短连接的缺陷。
- 普通 HTTP:一问一答,发完就断开,每次传数据都要重新握手;
- WebSocket:一次握手建立持久连接,网页和 ESP32 随时互相发数据,延迟极低,适合实时下发航点。
2. 完整通信流程(分 2 大阶段)
阶段 1:HTTP 握手升级连接(仅连接时执行 1 次)
- 浏览器发起 HTTP 请求,请求头携带
Upgrade: websocket,告知服务器(ESP32)要升级协议; - ESP32 校验握手密钥,返回
HTTP/1.1 101 Switching Protocols; - 握手完成,TCP 通道永久保留,切换为 WebSocket 二进制 / 文本帧传输,不再走 HTTP。
阶段 2:WebSocket 帧双向数据传输(下发航点就是这个阶段)
你下发航点时,网页把 JSON 字符串封装成WebSocket 文本帧发给 ESP32:
- JS 调用
ws.send(JSON字符串); - 浏览器自动包装 WebSocket 帧头、掩码;
- WiFi 通过 TCP 数据包发到 ESP32;
- ESP32 原生 socket 代码解析帧、去除掩码,还原原始 JSON 文本;
- ESP32 解析 JSON 得到航点坐标,串口打印。
3. 你的场景传输格式
- 传输载体:WebSocket 文本帧(Text Frame)
- 载荷内容:UTF-8 编码 JSON 字符串(结构化航点数据)
- 端口:固定 81,浏览器通过
ws://192.168.1.74:81定位 ESP32 设备
4. 全双工特性(双向收发)
- 正向:网页 → ESP32:下发航点 JSON;
- 反向:ESP32 → 网页:可回传状态(例如 "航点接收成功"、GPS 状态),你当前代码未启用,但协议支持。
三、底层承载:TCP 协议(WebSocket 的地基)
- WebSocket不能脱离 TCP 单独运行,所有 WebSocket 帧都会被封装成 TCP 数据包;
- TCP 是可靠传输:自带重传、校验、分包重组;
- 优势:航点 JSON 不会丢包、不会乱序,坐标完整送达 ESP32;
- 对比 UDP:UDP 无校验,容易丢失航点,不适合航线下发。
- 端口作用:一台设备可同时开多个服务(80 网页、81WebSocket),靠端口区分。
四、网络环境:局域网内网通信
1. 通信前提硬性要求
电脑(打开 HTML 的设备)和 ESP32 必须连接同一个路由器的 2.4G WiFi:
- ESP32 只支持 2.4G WiFi,不支持 5G;
- 两者网关、子网掩码一致,路由器允许内网设备互相访问。
2. 寻址逻辑
- ESP32 上电连接 WiFi 后,路由器分配内网静态 / 动态 IP:
192.168.1.74; - 浏览器通过这个 IP + 端口 81,在内网找到 ESP32 的 WebSocket 服务;
- 无法跨外网直接访问:如果电脑连手机热点、ESP 连家里 WiFi,不在同一局域网,连接会直接失败。
五、两端数据交互格式详解
1. 网页发送侧(输出)
- JS 处理高德地图坐标,GCJ02 转 WGS84;
- 组装 JS 对象,
{cmd:"upload_mission",waypoints:[坐标数组]}; JSON.stringify()转为纯文本字符串;- WebSocket 文本帧封装,TCP 通过 WiFi 发给 ESP32。
2. ESP32 接收侧(输入)
- Socket 捕获 TCP 数据流,解析 WebSocket 帧结构,去除掩码;
- 得到纯净 JSON 字符串;
json.loads()把文本还原为字典对象,提取 lat/lon/alt 航点;- 业务处理:串口打印 / 本地存储 / 封装 MAVLink 转发飞控。
3. 中间载体 JSON 的定位
JSON 只是应用层数据封装格式,不属于网络通信协议:
- 网络传输只认二进制数据流(TCP 包、WebSocket 帧);
- JSON 是人为约定的文本规范,方便人阅读、两端快速解析航点,换二进制协议也能通信(如直接发 MAVLink 二进制)。
六、对比其他通信方式,为什么选 WebSocket?
表格
| 通信方式 | 适配你的场景优缺点 |
|---|---|
| WebSocket(当前方案) | 浏览器原生支持,无需插件;长连接实时下发;结构化 JSON 易调试;内网 WiFi 稳定,最优解 |
| HTTP 轮询 | 短连接频繁握手,下发航点延迟高,不推荐 |
| UDP Socket | 无可靠校验,航点容易丢包,不适合航线下发 |
| 蓝牙串口 | 传输距离仅 10 米,地图规划距离受限 |
| 串口有线 | 电脑必须插 USB 连 ESP32,无无线便捷性 |
七、完整通信链路总结(一句话串联)
电脑浏览器 JS 生成航点 JSON → 封装为WebSocket 文本帧 → 底层 TCP 协议打包 → 2.4G WiFi 路由器局域网传输 → ESP32 的 WiFi 模组接收 TCP 流 → 解析 WebSocket 帧还原 JSON 字符串 → MicroPython 解析 JSON 提取 WGS84 航点坐标 → 串口打印输出。
网页端代码:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link rel="stylesheet" href="https://a.amap.com/jsapi_demos/static/demo-center/css/demo-center.css"/>
<title>JS直接WiFi发送航点至ESP32</title>
<style>
html,body,#container {margin:0;padding:0;width:100%;height:100%;}
.input-card {z-index:999;display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:8px;}
.item {display:flex;flex-direction:column;gap:4px;}
input {width:340px;padding:4px;}
button {padding:6px 12px;border:none;border-radius:3px;cursor:pointer;}
#clearAllBtn {background:#f44336;color:#fff;}
#sendEsp32Btn {background:#228B22;color:#fff;}
.wp-dot {width:12px;height:12px;background:#ff3300;border-radius:50%;border:2px solid #fff;cursor:pointer;}
</style>
</head>
<body>
<div id="container"></div>
<div class="input-card">
<div class="item"><span>GCJ02高德坐标</span><input readonly id="gcjInput"></div>
<div class="item"><span>WGS84飞控坐标</span><input readonly id="wgsInput"></div>
<button id="clearAllBtn">一键清空全部航点</button>
<button id="sendEsp32Btn">WiFi下发航点到ESP32</button>
</div>
<script src="https://webapi.amap.com/maps?v=2.0\&key=43er461ioff81e37682a97d7fb12"></script>
<script>
const markersArray = \[\];
const pointList = \[\];
let polyline = null;
const DEFAULT_ALT = 10;
// ========== 配置ESP32 WebSocket地址(已改成 192.168.1.74)==========
const ESP32_WS_URL = "ws://192.168.1.74:81";
let ws = null;
const PI = 3.14159265358979324;
const A = 6378245.0;
const EE = 0.006693421622965923;
// GCJ02转WGS84
function gcjToWgs(gcjLng, gcjLat) {
function transLat(x, y) {
let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * PI) + 320 * Math.sin(y / 30.0 * PI)) * 2.0 / 3.0;
return ret;
}
function transLng(x, y) {
let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0;
return ret;
}
let dLat = transLat(gcjLng - 105.0, gcjLat - 35.0);
let dLng = transLng(gcjLng - 105.0, gcjLat - 35.0);
let radLat = gcjLat / 180.0 * PI;
let magic = Math.sin(radLat);
magic = 1 - EE * magic * magic;
let sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI);
dLng = (dLng * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI);
return {lng: gcjLng - dLng, lat: gcjLat - dLat};
}
// 地图初始化
const map = new AMap.Map('container', {
center: 110.038988,21.455353,
layers: new AMap.TileLayer.Satellite(),
zoom: 18
});
polyline = new AMap.Polyline({path:\[\],strokeColor:"#00dd00",strokeWeight:3,strokeOpacity:0.8,map:map});
// 点击红点删除航点
document.getElementById('container').addEventListener('click', e=>{
const dot = e.target.closest('.wp-dot');
if(!dot) return e.stopPropagation();
const idx = Number(dot.dataset.index);
markersArrayidx.remove();
markersArray.splice(idx,1);
pointList.splice(idx,1);
polyline.setPath(pointList);
refreshDotIndex();
});
// 地图新增航点
map.on('click', e=>{
const gcjLng = e.lnglat.getLng();
const gcjLat = e.lnglat.getLat();
const wgs = gcjToWgs(gcjLng, gcjLat);
document.getElementById('gcjInput').value = `{gcjLng.toFixed(6)},{gcjLat.toFixed(6)}`;
document.getElementById('wgsInput').value = `{wgs.lng.toFixed(6)},{wgs.lat.toFixed(6)}`;
const idx = markersArray.length;
const marker = new AMap.Marker({
position:new AMap.LngLat(gcjLng,gcjLat),map:map,content:`<div class="wp-dot" data-index="${idx}"></div>`,anchor:'center'
});
markersArray.push(marker);
pointList.push(new AMap.LngLat(gcjLng,gcjLat));
polyline.setPath(pointList);
});
// 一键清空
document.getElementById('clearAllBtn').onclick = ()=>{
markersArray.forEach(m=>m.remove());
markersArray.length = 0;
pointList.length = 0;
polyline.setPath(\[\]);
document.getElementById("gcjInput").value = "";
document.getElementById("wgsInput").value = "";
};
function refreshDotIndex(){
markersArray.forEach((m,i)=>m.getContent().querySelector('.wp-dot').dataset.index = i);
}
// ========== 核心:WebSocket连接ESP32,发送JSON航点数据 ==========
document.getElementById('sendEsp32Btn').onclick = async ()=>{
if(pointList.length < 1) return alert("请先添加航点!");
// 构建标准航点JSON
const sendData = {
cmd: "upload_mission",
home_alt: DEFAULT_ALT,
waypoints: \[\]
};
pointList.forEach(gcjPoint=>{
const wgs = gcjToWgs(gcjPoint.getLng(), gcjPoint.getLat());
sendData.waypoints.push({
lat: Number(wgs.lat.toFixed(6)),
lon: Number(wgs.lng.toFixed(6)),
alt: DEFAULT_ALT,
stay: 0 // 停留时间0秒
});
});
// 建立WebSocket连接
if(!ws || ws.readyState !== WebSocket.OPEN){
ws = new WebSocket(ESP32_WS_URL);
ws.onopen = ()=>{
console.log("ESP32连接成功,发送航点");
ws.send(JSON.stringify(sendData));
alert("航点已下发至ESP32!");
};
ws.onclose = ()=>alert("ESP32断开,请检查WiFi地址");
ws.onerror = ()=>alert("连接ESP32失败,确认同一局域网");
ws.onmessage = (msg)=>console.log("ESP32返回:",msg.data);
}else{
// 已连接直接发送
ws.send(JSON.stringify(sendData));
alert("航点重新下发完成!");
}
};
</script>
</body>
</html>
micropython端的代码:
import network
import socket
import json
import time
===================== 配置项(改成你自己的WiFi信息) =====================
WIFI_SSID = "360WiFi-4A36D5"
WIFI_PASSWORD = "a12345678"
WS_PORT = 81 # 和前端JS里 ws://xxx:81 端口对应
======================================================================
连接WiFi
def connect_wifi():
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if not wlan.isconnected():
print("正在连接WiFi...")
wlan.connect(WIFI_SSID, WIFI_PASSWORD)
while not wlan.isconnected():
time.sleep(0.5)
ip_info = wlan.ifconfig()
print("WiFi连接成功!ESP32局域网IP:", ip_info0)
return ip_info0
WebSocket简易服务(MicroPython无官方websocket库,用原生socket实现文本传输)
def start_websocket_server(ip):
addr = socket.getaddrinfo(ip, WS_PORT)0-1
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(1)
print(f"WebSocket服务已启动,端口{WS_PORT},等待网页连接...")
while True:
conn, addr = s.accept()
print(f"收到网页客户端连接:{addr}")
try:
握手处理(网页WebSocket标准握手)
data = conn.recv(1024).decode("utf-8")
if "Upgrade: websocket" in data:
计算握手密钥返回
key = data.split("Sec-WebSocket-Key: ")1.split("\r\n")0.strip()
import ubinascii, uhashlib
magic = key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
sha1 = uhashlib.sha1(magic.encode()).digest()
accept_key = ubinascii.b2a_base64(sha1).decode().strip()
response = (
"HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Accept: {accept_key}\r\n\r\n"
)
conn.send(response.encode())
print("WebSocket握手完成,可以接收航点JSON!")
循环接收网页发来的JSON帧
while True:
frame_header = conn.recv(2)
if not frame_header:
break
byte0, byte1 = frame_header0, frame_header1
opcode = byte0 & 0x0F
payload_len = byte1 & 0x7F
读取载荷长度扩展
if payload_len == 126:
payload_len = int.from_bytes(conn.recv(2), "big")
elif payload_len == 127:
payload_len = int.from_bytes(conn.recv(8), "big")
读取掩码 + 真实文本数据
mask = conn.recv(4)
payload = conn.recv(payload_len)
WebSocket掩码解码
data_raw = bytes(payload\[i ^ maski % 4 for i in range(payload_len)])
json_str = data_raw.decode("utf-8")
print("\n==================== 收到网页JSON原始数据 ====================")
print(json_str)
解析JSON航点
try:
json_data = json.loads(json_str)
cmd = json_data.get("cmd", "")
if cmd == "upload_mission":
home_alt = json_data.get("home_alt", 10)
wp_list = json_data.get("waypoints", \[\])
print("\n===== 解析完成 航点任务详情 =====")
print(f"基准起飞高度:{home_alt} m")
print(f"总航点数量:{len(wp_list)}")
逐条打印每个航点
for idx, wp in enumerate(wp_list):
lat = wp"lat"
lon = wp"lon"
alt = wp"alt"
stay = wp"stay"
print(f"航点{idx+1} | 纬度:{lat:.6f} 经度:{lon:.6f} 高度:{alt}m 停留:{stay}s")
print("================================================\n")
else:
print(f"未知指令:{cmd}")
except Exception as e:
print("JSON解析失败,错误:", e)
except Exception as e:
print("客户端连接断开,错误:", e)
finally:
conn.close()
print("客户端已断开,等待新连接...")
程序入口
if name == "main":
esp_ip = connect_wifi()
start_websocket_server(esp_ip)