UNIHIKER K10 塔台雷达:用一块小屏,实时看见头顶飞过的每一架航班
基于 UNIHIKER K10 打造的联网实时航班雷达。开机后自动连 WiFi、定位你所在的城市,实时拉取周围 250 海里内所有正在飞行的航班,用经典塔台雷达的视觉语言——同心圆标尺、旋转扫描线、红色飞机剪影、呼号与高度标签——把它们画在 240×320 的屏幕上。显示的每一架都是真实航班,此刻正载着几百人以 800km/h 的速度飞向某个城市。
一、项目简介
出差坐飞机时,我总爱盯着舷窗外发愣——地面上那么多人在赶路,天上也是。后来想,与其抬头瞎猜,不如做块小屏,把头顶的天空"画"出来。
这个项目做了什么:
- 一块 UNIHIKER K10(自带 2.8 寸屏和完整驱动,不用自己接线焊板)
- 自动连接 WiFi,通过 IP 定位获取当前经纬度
- 实时拉取周围 250 海里内的真实航班数据(ADS-B 公开接口)
- 用塔台雷达的视觉风格渲染:同心圆刻度、扫描线、红色飞机剪影、呼号/高度标签
它不是什么:
- 不是模拟数据——机场附近的飞机、货机、通航小飞机都会真实出现在屏幕上
- 不需要外接显示器——K10 自带屏幕,烧录完就能独立运行
- 不需要自己焊电路——纯软件项目,拿到板子就能做
适合谁看: 有 Arduino 基础、玩过 ESP32、想做一个"能摆在桌上天天看"的联网可视化项目的朋友。全程只需要一块 K10 和一根 Type-C 线。
二、硬件 & 软件清单
硬件
| 名称 | 型号 / 规格 | 数量 | 说明 |
|---|---|---|---|
| 主控开发板 | DFRobot UNIHIKER K10 | 1 | 核心硬件,ESP32-S3 主控,板载 2.8 寸 IPS 屏(240×320,竖屏)、WiFi/BLE、麦克风、扬声器、摄像头、6 轴 IMU、RGB 灯带等 |
| 数据线 | USB Type-C | 1 | 供电 + 烧录,K10 侧为 Type-C 口 |
| 电源 | 3.7V 锂电池(可选) | 1 | 需要脱机独立运行时使用,K10 板载电池接口与充电管理 |
关于主控芯片:K10 用的是乐鑫 ESP32-S3,型号 N16R8——即 16MB Flash + 8MB PSRAM + 512KB SRAM。这块项目对内存有一定要求(离屏画布要占 150KB),8MB PSRAM 让整个方案毫无压力。
软件
| 名称 | 版本 / 说明 | 用途 |
|---|---|---|
| Arduino IDE | 2.x | 开发环境,也可以选 PlatformIO |
| UNIHIKER K10 Arduino 包 | 官方最新版 | 提供 unihiker_k10.h 与 K10 全部外设驱动 |
| TFT_eSPI | K10 官方包自带 | 屏幕驱动,本项目用它的 TFT_eSprite 做离屏双缓冲 |
| ArduinoJson | 作者 Benoit Blanchon,需手动安装 | 解析航班接口返回的 JSON 数据 |
| WiFi / HTTPClient / WiFiClientSecure | ESP32 Arduino 核心自带 | 联网、HTTPS 请求 |
| FreeRTOS(ESP32 内置) | ESP32 Arduino 核心自带 | 双核任务调度,把网络请求放到另一个核 |
数据源
| 名称 | 地址 | 说明 |
|---|---|---|
| adsb.fi 公开接口 | https://opendata.adsb.fi/api/v2/lat/{lat}/lon/{lon}/dist/{nm} |
免费公开,个人非商业用途可用,限速 1 请求/秒 |
依赖库安装清单
Arduino IDE → 工具 → 管理库,搜索并安装:
ArduinoJson(作者:Benoit Blanchon)— 必装TFT_eSPI与unihiker_k10— 安装 K10 官方包时自动附带,无需单独装
三、制作过程
步骤 1:环境准备
- 安装 Arduino IDE(1.8.x 或 2.x 均可)
- 在 Arduino IDE 的开发板管理器里搜索并安装 UNIHIKER K10 的板级支持包
- 在库管理器里安装 ArduinoJson
- 开发板选择 UNIHIKER K10,端口选对应的 COM / ttyUSB
步骤 2:代码配置
打开完整代码(见第六章),修改开头两行 WiFi 配置:
#define WIFI_SSID "你的WiFi名"
#define WIFI_PASS "你的WiFi密码"
注意: WiFi 必须是 2.4GHz 频段,ESP32-S3 不支持 5GHz。
步骤 3:调整显示参数(按自己所在城市的航班密度)
#define SEARCH_NM 250 // 检索半径(海里),250 是接口上限
#define RADAR_KM 40.0f // 显示半径(公里)
#define MAX_SHOW 8 // 最多显示最近多少架
#define POLL_MS 5000UL // 刷新间隔(ms)
如果你在机场附近(航班密集),建议
RADAR_KM调到 2030,8;MAX_SHOW保持 6
如果在航线稀疏地区,RADAR_KM可放大到 80~100,否则屏幕上会很空。
步骤 4:烧录
- 用 Type-C 线连接 K10 到电脑
- 点击上传,等待编译烧录完成
- 打开串口监视器(波特率 115200)
正常启动时串口会依次打印:
WiFi OK, IP=192.168.x.x
定位成功: 34.2635, 108.9246
https://opendata.adsb.fi/api/v2/lat/34.2635/lon/108.9246/dist/250
HTTP 200
解析 60 架,显示最近 8 架
步骤 5:上电运行
烧录完成后 K10 即可脱离电脑独立运行。开机流程:
- 屏幕依次显示
Connecting WiFi...→Locating...→Fetching flights... - 随后画面进入雷达界面:扫描线开始匀速转圈
- 大约 5 秒后,第一架飞机出现在圈内,并带上呼号与高度标签
到此完成,全程约 20 分钟。
四、技术原理
4.1 数据从哪来:ADS-B
现代民航客机每隔一段时间会主动广播自己的状态——位置、高度、速度、航班号、航向。这套技术叫 ADS-B(广播式自动相关监视)。全世界有大量爱好者架设接收机,把这些信号收集起来共享到网络,就形成了公开的航班数据库。
本项目的接口调用形式:
GET https://opendata.adsb.fi/api/v2/lat/34.2635/lon/108.9246/dist/250
传入自己的经纬度和检索半径(海里),返回该圆形范围内的所有航班数组。
我们用到这些字段:
| 字段 | 含义 | 单位 |
|---|---|---|
flight |
航班呼号,如 CES2151 |
— |
lat / lon |
当前位置 | 度 |
alt_baro |
气压高度 | 英尺 |
gs |
地速 | 节(海里/小时) |
track |
航迹角(机头朝向) | 度 |
数据源选型踩坑:一开始我用的是 OpenSky Network,结果中国大陆几乎无覆盖,一直返回空数据;换 adsb.lol 后国内直连返回
HTTP 403(地区限制);最后换成 adsb.fi,稳定可用,西安周边一次能返回 60+ 架飞机。
4.2 经纬度如何映射到屏幕
屏幕上只有一个圆圈,需要把"地球表面的经纬度"换算成"屏幕上的像素坐标"。用等距圆柱投影(小范围内精度足够):
以自己为原点,先算出目标的相对公里数:
float dLatKm = (lat - selfLat) * 111.0f; // 纬度差 → 公里
float dLonKm = (lon - selfLon) * 111.0f * cosf(rad(selfLat)); // 经度差 → 公里(乘 cos 修正)
float dist = sqrtf(dLatKm * dLatKm + dLonKm * dLonKm); // 斜距(勾股定理)
再按显示半径归一化到像素:
为什么经度要乘
cos(lat)? 因为经线越靠近两极越挤。1 度纬度无论在哪都是 111km,但 1 度经度在北纬 34° 只有约 92km。这行不写,飞机位置会横向拉伸变形。
4.3 如何消除闪烁:离屏双缓冲
第一版直接用 K10 的 Canvas API 画图,屏幕闪得没法看。
原因:canvasClear() + updateCanvas() 是边擦边画——先清屏、再逐层绘制,中间那一瞬间的"空屏"被肉眼捕捉到了,就是闪烁。
解决:改用 TFT_eSprite 离屏双缓冲。所有图层先画到内存里的一块"虚拟画布"上,画完整帧后一次性推送到屏幕,屏幕永远只看到完整画面:
TFT_eSprite fb = TFT_eSprite(&tft);
fb.createSprite(240, 320); // 内存中开辟 240×320 的画布
// ... 逐层绘制 ...
fb.pushSprite(0, 0); // 原子推送,无闪烁
代价是多占 150KB 内存(240×320×2 字节),对 K10 的 8MB PSRAM 来说毫无压力。
4.4 如何让飞机"动起来":三大问题
问题 1:飞机钉在原地不动
原因:更新太慢 + 尺度太大。客机巡航约 860km/h,即 240m/s。原来 20 秒刷新一次,飞机飞了 4.8km;而屏幕上 80km 半径映射到 100 像素,1km 只有 1.25 像素——20 秒只挪 6 个像素,眨个眼就找不回位置了。
解决:
- 刷新间隔缩到 5 秒:5 秒飞 1.2km ≈ 1.5 像素,肉眼能看出缓慢移动
- 加平滑插值:每架飞机存"目标坐标"和"显示坐标"两套,显示坐标每帧朝目标挪 8%,看到的是缓慢滑过去而非硬跳
p.dx += (p.x - p.dx) * 0.08f; // 平滑跟随
p.dy += (p.y - p.dy) * 0.08f;
- 呼号匹配:两次刷新之间同一架飞机(按呼号匹配)会继承上一次的显示坐标与整条航迹,这样它才是"一路飞过去",而不是每次重新出现
问题 2:每 5 秒画面卡死 3~5 秒
现象:改完 5 秒刷新后,卡顿反而更明显——每隔 5 秒,画面冻住 3~5 秒。
诊断:卡顿时长正好等于刷新间隔,这个线索指向了同步阻塞。
原因:原代码是"画一帧 → 拉数据 → 画一帧 → 拉数据",而拉数据是同步阻塞的——要跟服务器做 HTTPS/TLS 握手、下载 50KB JSON,这 3~5 秒里 CPU 全在等网络,什么都画不了。
解决:ESP32-S3 是双核的,把网络丢到另一个核去。
| 修复前 | 修复后 | |
|---|---|---|
| 网络请求 | core 1,堵住绘制 | core 0 独立任务 |
| 绘制 | 被网络打断 | core 1 独占,永不中断 |
| 数据传递 | 直接写全局变量 | FreeRTOS 队列,安全交接 |
// 把网络任务钉在 core 0,绘制留在 core 1
xTaskCreatePinnedToCore(netTask, "net", 8192, NULL, 1, NULL, 0);
void netTask(void* param) {
static NetResult r; // static:避免 3KB+ 占满任务栈
for (;;) {
doFetch(r);
xQueueOverwrite(netQueue, &r); // 覆盖式入队,只留最新一包
vTaskDelay(pdMS_TO_TICKS(POLL_MS));
}
}
void loop() { // 跑在 core 1
pullNetResult(); // 非阻塞取数据,几乎不耗时
// ... 画这一帧 ...
}
改完后扫描线会一直流畅转动,数据在后台悄悄更新完成,下一帧自动换上新位置。
这一步还连着三个子坑,一并说明:
- 栈溢出:一开始把 60 架飞机的结构体(约 8KB)放在任务栈上,必爆。改成
static,任务栈给 8192 字节就稳。- 数组越界:队列只收 8 架,但解析中间过程要暂存 60 架,直接写会踩内存。改成内部用独立的
work[60]工作区,最后只把前 8 架拷出来。- 跨核数据竞争:呼号匹配用的"上一轮数据表"如果两个核都读写会打架。改成
static局部变量,只在网络核里访问。
4.5 飞机图标的绘制
屏幕上没有现成的飞机素材,需要用线条拼出来。思路是:先用"机头朝上"的坐标系描述所有部件(写起来直观),再按航向统一旋转。
// 把"机头朝上"的局部坐标 (lx,ly) 按 heading 旋转并平移到 (ox,oy)
void rotPt(int ox, int oy, float heading, float lx, float ly, int& sx, int& sy) {
float s = sinf(rad(heading)), c = cosf(rad(heading));
sx = ox + (int)lroundf(lx * c - ly * s); // 二维旋转矩阵
sy = oy - (int)lroundf(lx * s + ly * c); // 屏幕 y 轴向下,取负
}
旋转公式就是标准的二维旋转矩阵:
然后画 4 个部件:机身主轴、左右后掠主翼、小尾翼、机身中点实心块。
为什么不做实心填充? 试过,放弃了。一是在 2.8 寸屏上 18 像素的实心图标会糊成一坨,轮廓反而不清;二是扫描线填充每帧要算几百次三角函数,8 架一起画会掉帧。线条剪影是小屏的最优解。
4.6 扫描线的渐隐效果
真雷达的扫描线拖着一道渐暗的尾迹。做法是沿着扫描角往后退画 20 条线,越靠后颜色越暗:
uint16_t trailColor(int i) {
int g = 63 - i * 3; // RGB565 里绿色通道最亮是 63
if (g < 0) g = 0;
return (uint16_t)(g << 5); // 绿色占 bit5~bit10
}
void drawSweep() {
for (int i = 0; i < 20; i++)
polarLine(0, R_MAX, sweepDeg - i * 2.0f, trailColor(i)); // 渐隐尾迹
polarLine(0, R_MAX, sweepDeg, C_SWEEP); // 最亮的主扫描线
}
4.7 标签智能避让
8 架飞机凑一起,标签必然打架。做法是给每架标签准备四个候选位(右下 → 左上 → 右上 → 左下),逐个试探,谁不跟已放置的矩形重叠就用谁:
bool overlaps(int x, int y, int w, int h) {
for (int i = 0; i < placedCount; i++) {
LabelBox& b = placed[i];
if (x < b.x + b.w && x + w > b.x && y < b.y + b.h && y + h > b.y)
return true; // 两个矩形相交
}
return false;
}
四个位置全挤,就只画飞机、不画标签——宁可少个呼号,也不糊成一团。
五、代码注释说明
完整代码见第六章。这里说明代码的分层结构和关键注释:
代码结构总览
| 段落 | 函数 | 职责 |
|---|---|---|
| 配置区 | #define 宏 |
WiFi、半径、刷新率等全部可调参数,集中在一处 |
| 数据结构 | struct Plane / struct NetResult |
飞机信息 / 跨核传递的数据包 |
| 联网 | connectWiFi() / fetchLocation() |
连接 WiFi、IP 定位 |
| 数据 | doFetch() / parseFlights() |
拉取并解析航班数据 |
| 绘制 | drawBase() / drawSweep() / drawPlanes() / drawHud() |
分图层绘制 |
| 图标 | rotPt() / drawAircraft() |
按航向旋转的飞机剪影 |
| 双核 | netTask() / pullNetResult() |
网络任务(core 0)与数据交接 |
关键注释点
代码中已用中文详细注释了以下容易踩坑的地方,对照阅读即可:
- 配置区每个参数的作用与调整建议(改这里就能调效果)
parseFlights()里的容错逻辑:adsb.fi 不同端点返回的顶层键可能是aircraft也可能是ac,代码里两个都判断parseFlights()里的呼号匹配:为什么要在解析阶段就做匹配(为了继承平滑坐标与航迹)netTask()/pullNetResult()的static声明:为什么必须用 static(避免爆栈)work[]与out[]的分离:为什么解析工作区和输出数组要分开(避免数组越界)rotPt()里屏幕 y 轴取负:为什么局部坐标 y 轴要反向(屏幕坐标向下为正)
附件说明
完整 .ino 源文件已作为附件上传,可直接在 Arduino IDE 中打开编译。
六、完整代码
/*
* K10 塔台雷达 - 联网实时版
* 数据源:adsb.fi(https://opendata.adsb.fi)
*
* 依赖库:
* - ArduinoJson (Benoit Blanchon) <-- 必须先在库管理器安装
* - TFT_eSPI (K10 官方包自带)
*/
#include "unihiker_k10.h"
#include <TFT_eSPI.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
// ======================== 用户配置 ========================
#define WIFI_SSID "你的WiFi名"
#define WIFI_PASS "你的WiFi密码"
// 检索半径(海里)。250 = 上限,约 460km
#define SEARCH_NM 250
// 显示半径(公里)。屏幕上最外圈代表多少 km
// 调小 => 飞机散得更开、标签更清楚;调大 => 能看到更远的飞机
#define RADAR_KM 40.0f
// 最多显示最近的多少架(按距离由近到远截断,防止标签糊成一团)
#define MAX_SHOW 8
// 轮询间隔(ms)。adsb.fi 限速 1 次/秒,5000 = 5 秒一次很稳
#define POLL_MS 5000UL
// 自身坐标兜底(IP 定位失败时)。默认西安
#define DEFAULT_LAT 34.3267f
#define DEFAULT_LON 109.0065f
// ==========================================================
UNIHIKER_K10 k10;
TFT_eSPI tft = TFT_eSPI();
TFT_eSprite fb = TFT_eSprite(&tft);
#define SCR_W 240
#define SCR_H 320
#define CX 120
#define CY 160
#define R_MAX 100
#define MAX_PLANES 60 // 解析时最多缓存多少架(内部用,再截断到 MAX_SHOW)
#define N_TRAIL 20 // 扫描尾迹的线条数
#define N_HIST 10 // 每架飞机保留多少个历史轨迹点
struct Plane {
float x, y; // 目标坐标(网络数据算出来的真实位置)
float dx, dy; // 显示坐标(平滑跟随 x,y,避免瞬移)
float heading; // 航向角
float km; // 距中心距离 km
char cs[10]; // 呼号
float altFt; // 高度 英尺
float spdKt; // 速度 节
unsigned long seen; // 最后一次在数据里出现的时间
// 航迹:环形缓冲,hx/hy 存屏幕相对坐标
float hx[N_HIST], hy[N_HIST];
int hHead, hCount;
};
Plane planes[MAX_PLANES];
int planeCount = 0;
float sweepDeg = 0;
float selfLat = DEFAULT_LAT, selfLon = DEFAULT_LON;
bool netOK = false;
// ================== 跨核通信 ==================
// 网络任务跑在 core 0,绘制跑在 core 1。
// 网络任务把结果打包成 NetResult 塞进队列,绘制核取出来用。
// 注意:只传要显示的 MAX_SHOW 架,别把 60 架全塞队列(会爆栈/吃内存)
struct NetResult {
Plane planes[MAX_SHOW];
int count;
bool ok; // 这次请求是否成功
float lat, lon; // 更新后的自身坐标
};
QueueHandle_t netQueue = NULL; // 长度 1,只保留最新一包
// ==============================================
#define C_BG 0x0000
#define C_RING 0x07E0
#define C_SWEEP 0x07E0
#define C_TEXT 0x05E0
#define C_PLANE 0xF800
#define C_LABEL 0xFFE0
#define C_WHITE 0xFFFF
#define C_WARN 0xF800
// 扫描尾迹:绿色由亮到暗渐变(RGB565 标准绿色通道 G 占 bit5~10)
uint16_t trailColor(int i) {
int g = 63 - i * 3; // 63 = 最亮绿
if (g < 0) g = 0;
return (uint16_t)(g << 5);
}
float rad(float d) { return d * 3.1415926f / 180.0f; }
void lineSeg(int x1, int y1, int x2, int y2, uint16_t c) { fb.drawLine(x1, y1, x2, y2, c); }
void polarLine(float r1, float r2, float deg, uint16_t c) {
float s = sinf(rad(deg)), co = cosf(rad(deg));
lineSeg(CX + (int)(r1 * s), CY - (int)(r1 * co),
CX + (int)(r2 * s), CY - (int)(r2 * co), c);
}
void label(const char* t, int x, int y, uint16_t c) {
fb.setTextColor(c, C_BG);
fb.setTextSize(1);
fb.drawString(t, x, y);
}
// ---------- 1. 连 WiFi ----------
void connectWiFi() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
int t = 0;
while (WiFi.status() != WL_CONNECTED && t < 40) { delay(500); t++; }
netOK = (WiFi.status() == WL_CONNECTED);
Serial.printf("WiFi %s, IP=%s\n", netOK ? "OK" : "FAIL",
netOK ? WiFi.localIP().toString().c_str() : "-");
}
// ---------- 2. IP 定位(失败则用默认坐标) ----------
void fetchLocation() {
if (!netOK) return;
// ip-api.com 免费版只走 HTTP(不是 HTTPS),直接 HTTP 更稳
WiFiClient c;
HTTPClient http;
http.begin(c, "http://ip-api.com/json/?fields=lat,lon,status");
http.setTimeout(8000);
if (http.GET() == 200) {
StaticJsonDocument<256> doc;
if (!deserializeJson(doc, http.getString()) && doc["status"] == "success") {
selfLat = doc["lat"].as<float>();
selfLon = doc["lon"].as<float>();
Serial.printf("定位成功: %.4f, %.4f\n", selfLat, selfLon);
http.end();
return;
}
}
Serial.println("IP 定位失败,用默认坐标");
http.end();
}
// ---------- 3. 拉取航班(adsb.fi) ----------
int cmpByDist(const void* a, const void* b) {
float da = ((const Plane*)a)->km, db = ((const Plane*)b)->km;
return (da > db) - (da < db);
}
// 解析结果写进 out[](容量至少 MAX_SHOW),返回架数
// 内部用 static 保存工作区与上一轮结果(只在网络任务的核里访问,无竞争)
int parseFlights(const String& payload, Plane* out) {
static Plane work[MAX_PLANES]; // 解析工作区(避栈溢出,故用 static)
static Plane prev[MAX_PLANES]; // 上一轮用于呼号匹配
static int prevCount = 0;
DynamicJsonDocument doc(49152); // 48KB,80 架的裁剪字段足够
DeserializationError err = deserializeJson(doc, payload);
if (err) { Serial.printf("JSON 解析失败: %s\n", err.c_str()); return 0; }
// 注意:adsb.fi 返回顶层是 "aircraft"(部分版本/端点为 "ac"),两个都试
JsonArray ac;
if (doc.containsKey("aircraft")) ac = doc["aircraft"].as<JsonArray>();
else if (doc.containsKey("ac")) ac = doc["ac"].as<JsonArray>();
else { Serial.println("未找到 aircraft/ac 数组"); return 0; }
int n = 0;
for (JsonVariant v : ac) {
if (n >= MAX_PLANES) break;
JsonObject o = v.as<JsonObject>();
if (!o.containsKey("lat") || !o.containsKey("lon")) continue;
if (o.containsKey("alt_baro") && o["alt_baro"].is<const char*>()) continue; // "ground"
float lat = o["lat"].as<float>();
float lon = o["lon"].as<float>();
float dLatKm = (lat - selfLat) * 111.0f;
float dLonKm = (lon - selfLon) * 111.0f * cosf(rad(selfLat));
float dist = sqrtf(dLatKm * dLatKm + dLonKm * dLonKm);
if (dist > RADAR_KM) continue;
Plane& p = work[n];
p.x = dLonKm / RADAR_KM * R_MAX;
p.y = -dLatKm / RADAR_KM * R_MAX;
p.km = dist;
p.heading = o.containsKey("track") ? o["track"].as<float>() : 0;
const char* cs = o.containsKey("flight") ? o["flight"].as<const char*>() : "????";
if (!cs || strlen(cs) == 0) cs = o.containsKey("hex") ? o["hex"].as<const char*>() : "????";
strncpy(p.cs, cs, 9); p.cs[9] = 0;
for (int k = strlen(p.cs) - 1; k >= 0 && p.cs[k] == ' '; k--) p.cs[k] = 0;
p.altFt = o.containsKey("alt_baro") && o["alt_baro"].is<float>()
? o["alt_baro"].as<float>() : 0;
p.spdKt = o.containsKey("gs") ? o["gs"].as<float>() : 0;
p.seen = millis();
// —— 关键:去上一轮结果找同一架(按呼号匹配)——
bool matched = false;
if (strcmp(p.cs, "????") != 0) {
for (int k = 0; k < prevCount; k++) {
if (strcmp(prev[k].cs, p.cs) == 0) {
// 继承显示坐标(从旧位置平滑滑过来,不瞬移)
p.dx = prev[k].dx;
p.dy = prev[k].dy;
// 继承整条航迹
p.hHead = prev[k].hHead;
p.hCount = prev[k].hCount;
memcpy(p.hx, prev[k].hx, sizeof(p.hx));
memcpy(p.hy, prev[k].hy, sizeof(p.hy));
matched = true;
break;
}
}
}
if (!matched) {
// 新目标:显示坐标直接落在真实位置,航迹清空从自己开始
p.dx = p.x; p.dy = p.y;
p.hHead = 0; p.hCount = 0;
}
n++;
}
// 按距离由近到远排序,只保留最近的 MAX_SHOW 架
qsort(work, n, sizeof(Plane), cmpByDist);
int show = (n > MAX_SHOW) ? MAX_SHOW : n;
Serial.printf("解析 %d 架,显示最近 %d 架\n", n, show);
memcpy(out, work, sizeof(Plane) * show);
// 记下这一轮,供下次匹配
memcpy(prev, work, sizeof(Plane) * show);
prevCount = show;
return show;
}
// 供网络任务调用:拉数据 -> 解析 -> 打包塞队列
void doFetch(NetResult& r) {
r.ok = false;
r.count = 0;
r.lat = selfLat; r.lon = selfLon;
if (!netOK) return;
WiFiClientSecure c; c.setInsecure(); // 跳过证书校验,简化流程
HTTPClient http;
char url[160];
snprintf(url, sizeof(url),
"https://opendata.adsb.fi/api/v2/lat/%.4f/lon/%.4f/dist/%d",
selfLat, selfLon, SEARCH_NM);
Serial.println(url);
http.begin(c, url);
http.setTimeout(15000);
int code = http.GET();
Serial.printf("HTTP %d\n", code);
if (code == 200) {
r.count = parseFlights(http.getString(), r.planes);
r.ok = true;
}
http.end();
}
// ---------- 4. 绘制 ----------
void drawBase() {
fb.drawCircle(CX, CY, R_MAX, C_RING);
fb.drawCircle(CX, CY, R_MAX / 2, C_RING);
fb.drawCircle(CX, CY, R_MAX / 4, C_RING);
lineSeg(CX, CY - R_MAX, CX, CY + R_MAX, C_RING);
lineSeg(CX - R_MAX, CY, CX + R_MAX, CY, C_RING);
label("N", CX - 3, CY - R_MAX - 14, C_WHITE);
label("S", CX - 3, CY + R_MAX + 2, C_WHITE);
label("W", CX - R_MAX - 14, CY - 4, C_WHITE);
label("E", CX + R_MAX + 4, CY - 4, C_WHITE);
int kmPerRing = (int)(RADAR_KM / 4);
for (int i = 1; i <= 3; i++) {
int r = R_MAX / 4 * i;
char buf[8]; sprintf(buf, "%d", kmPerRing * i);
label(buf, CX + (int)(r * 0.71f) + 4, CY - (int)(r * 0.71f) - 12, C_TEXT);
}
// 中心点(自己的位置)
fb.fillCircle(CX, CY, 2, C_WHITE);
}
void drawSweep() {
for (int i = 0; i < N_TRAIL; i++)
polarLine(0, R_MAX, sweepDeg - i * 2.0f, trailColor(i));
polarLine(0, R_MAX, sweepDeg, C_SWEEP);
}
// 标签避让:记录已放置标签的矩形,重叠就换位置
struct LabelBox { int x, y, w, h; };
LabelBox placed[16];
int placedCount = 0;
bool overlaps(int x, int y, int w, int h) {
for (int i = 0; i < placedCount; i++) {
LabelBox& b = placed[i];
if (x < b.x + b.w && x + w > b.x && y < b.y + b.h && y + h > b.y)
return true;
}
return false;
}
// 把"机头朝上"的局部坐标 (lx,ly) 按 heading 旋转并平移到 (ox,oy)
// 局部坐标约定:x 向右为正,y 向上为正(跟屏幕相反,这里做转换)
void rotPt(int ox, int oy, float heading, float lx, float ly, int& sx, int& sy) {
float s = sinf(rad(heading)), c = cosf(rad(heading));
sx = ox + (int)lroundf(lx * c - ly * s);
sy = oy - (int)lroundf(lx * s + ly * c);
}
// 飞机剪影:俯视线条轮廓(机身 + 后掠主翼 + 尾翼),机头朝上
// 小屏上线条比实心更清晰,也不吃性能
void drawAircraft(int ox, int oy, float heading, uint16_t c) {
int px, py, fx, fy;
// 1) 机身:机头 -> 尾部,一条主轴
rotPt(ox, oy, heading, 0.0f, 8.0f, px, py);
rotPt(ox, oy, heading, 0.0f, -5.0f, fx, fy);
lineSeg(px, py, fx, fy, c);
// 2) 主翼:左右各一条后掠线(从翼根斜向后外)
rotPt(ox, oy, heading, 1.0f, 1.0f, px, py);
rotPt(ox, oy, heading, 8.0f, -3.0f, fx, fy);
lineSeg(px, py, fx, fy, c);
rotPt(ox, oy, heading, -1.0f, 1.0f, px, py);
rotPt(ox, oy, heading, -8.0f, -3.0f, fx, fy);
lineSeg(px, py, fx, fy, c);
// 3) 尾翼:小一号的后掠线
rotPt(ox, oy, heading, 0.8f, -3.5f, px, py);
rotPt(ox, oy, heading, 3.2f, -5.5f, fx, fy);
lineSeg(px, py, fx, fy, c);
rotPt(ox, oy, heading, -0.8f, -3.5f, px, py);
rotPt(ox, oy, heading, -3.2f, -5.5f, fx, fy);
lineSeg(px, py, fx, fy, c);
// 4) 机身中点:一个小实心方块,让"飞机"更像一个整体
rotPt(ox, oy, heading, 0.0f, 2.0f, px, py);
fb.fillRect(px - 1, py - 1, 2, 2, c);
}
void drawPlanes() {
placedCount = 0;
for (int i = 0; i < planeCount; i++) {
Plane& p = planes[i];
// —— 平滑跟随:显示坐标以 8% 步长逼近真实坐标 ——
p.dx += (p.x - p.dx) * 0.08f;
p.dy += (p.y - p.dy) * 0.08f;
int sx = CX + (int)p.dx, sy = CY + (int)p.dy;
// —— 航迹:每隔几帧记录一个点,画成渐隐的短虚线 ——
static int histTick = 0;
histTick++;
if (histTick % 6 == 0) {
p.hx[p.hHead] = p.dx;
p.hy[p.hHead] = p.dy;
p.hHead = (p.hHead + 1) % N_HIST;
if (p.hCount < N_HIST) p.hCount++;
}
for (int k = 0; k < p.hCount; k++) {
int idx = (p.hHead - 1 - k + N_HIST * 2) % N_HIST;
int px = CX + (int)p.hx[idx], py = CY + (int)p.hy[idx];
// 越旧越暗
int g = 40 - k * 4; if (g < 0) g = 0;
fb.drawPixel(px, py, (uint16_t)(g << 5));
}
// ---- 飞机图标:按航向旋转的俯视剪影 ----
drawAircraft(sx, sy, p.heading, C_PLANE);
// 标签候选位置:右下、左上、右上、左下,挑第一个不重叠的
// (飞机翼展约 ±8px,所以标签起点离中心至少 10px)
const int LW = 46, LH = 24; // 单架标签占位(两行)
int cand[4][2] = {
{ sx + 11, sy - 24 },
{ sx - LW - 8, sy - 24 },
{ sx + 11, sy + 8 },
{ sx - LW - 8, sy + 8 }
};
int tx = -1, ty = -1;
for (int c = 0; c < 4; c++) {
int cx0 = cand[c][0], cy0 = cand[c][1];
if (cx0 < 2) cx0 = 2;
if (cy0 < 2) cy0 = 2;
if (cx0 + LW > SCR_W - 2) cx0 = SCR_W - 2 - LW;
if (cy0 + LH > SCR_H - 2) cy0 = SCR_H - 2 - LH;
if (!overlaps(cx0, cy0, LW, LH)) { tx = cx0; ty = cy0; break; }
}
if (tx < 0) continue; // 四个位置都挤,这架就不标了,保留飞机图标
placed[placedCount++] = { tx, ty, LW, LH };
label(p.cs, tx, ty, C_LABEL);
char buf[16];
sprintf(buf, "FL%03d", (int)(p.altFt / 100));
label(buf, tx, ty + 11, C_LABEL);
}
}
void drawHud() {
label(netOK ? "WIFI" : "NO NET", 6, 6, netOK ? C_WHITE : C_WARN);
label("AIR", 200, 6, C_TEXT);
char buf[6]; sprintf(buf, "%d", planeCount);
label(buf, 225, 6, C_WHITE);
label("RADIO", 6, 284, C_TEXT);
label("ONLINE", 6, 300, C_WHITE);
sprintf(buf, "%.2f,%.2f", selfLat, selfLon);
label(buf, 160, 306, C_TEXT);
}
// ---------- 5. 网络任务(跑在 core 0,不阻塞绘制) ----------
void netTask(void* param) {
static NetResult r; // static:避免 3KB+ 占满任务栈
for (;;) {
doFetch(r);
// 覆盖式入队:队列满就丢掉旧的,只保留最新一包
xQueueOverwrite(netQueue, &r);
vTaskDelay(pdMS_TO_TICKS(POLL_MS));
}
}
// 绘制核取最新数据(非阻塞,取不到就继续用旧的)
void pullNetResult() {
static NetResult r; // static:同上
if (xQueueReceive(netQueue, &r, 0) == pdTRUE) {
if (r.ok) {
memcpy(planes, r.planes, sizeof(Plane) * r.count);
planeCount = r.count;
}
selfLat = r.lat; selfLon = r.lon;
}
}
void setup() {
Serial.begin(115200);
k10.begin();
k10.initScreen(2);
k10.setScreenBackground(0x000000);
fb.setColorDepth(16);
if (!fb.createSprite(SCR_W, SCR_H)) Serial.println("!! Sprite 创建失败");
fb.fillSprite(C_BG); label("Connecting WiFi...", 40, CY, C_TEXT); fb.pushSprite(0, 0);
connectWiFi();
fb.fillSprite(C_BG); label("Locating...", 60, CY, C_TEXT); fb.pushSprite(0, 0);
fetchLocation();
fb.fillSprite(C_BG); label("Fetching flights...", 40, CY, C_TEXT); fb.pushSprite(0, 0);
// 建队列(长度 1,配合 xQueueOverwrite 实现"只留最新")
netQueue = xQueueCreate(1, sizeof(NetResult));
// 先同步拉一次,保证开机就有数据
NetResult r; doFetch(r);
if (r.ok) { memcpy(planes, r.planes, sizeof(Plane) * r.count); planeCount = r.count; }
// 把网络任务钉在 core 0,绘制留在 core 1
xTaskCreatePinnedToCore(netTask, "net", 8192, NULL, 1, NULL, 0);
}
void loop() {
// 1) 取网络任务的最新结果(非阻塞,几乎不耗时)
pullNetResult();
// 2) 画这一帧
fb.fillSprite(C_BG);
drawBase();
drawSweep();
drawPlanes();
drawHud();
fb.pushSprite(0, 0);
sweepDeg += 4;
if (sweepDeg >= 360) sweepDeg -= 360;
delay(20);
}
七、图片 / 视频展示
效果示意图

图中标注了各个视觉元素的含义:红色飞机剪影(按航向旋转)、黄色呼号与高度标签、渐隐绿色航迹、同心圆标尺、扫描线与尾迹、屏幕中心的自身位置。
八、注意事项
硬件相关
- WiFi 必须是 2.4GHz 频段。ESP32-S3 不支持 5GHz,如果连不上,先检查路由器是不是开了 5G 专用 SSID。
- 供电要足。K10 屏幕亮度高、WiFi 发射时电流会有尖峰,用劣质数据线可能出现重启。建议用原装线或电脑 USB 口供电。
- 注意散热。长期运行时板子会温热,属正常现象,但别放在密闭盒子里。
软件与数据
ArduinoJson必须手动安装,K10 官方包里没有,不装会编译报错。- adsb.fi 接口限速 1 次/秒。代码里
POLL_MS默认 5000(5 秒),请不要调低于 1000,否则会被限流甚至封 IP。 - 数据覆盖有地域差异。ADS-B 依赖民间接收机,城市/机场周边覆盖好,偏远地区可能飞机很少甚至为零——这不是程序 bug。
- IP 定位精度有限,只能定位到城市级别。如果定位偏差大,直接在代码里把
DEFAULT_LAT/DEFAULT_LON写死成自己的坐标。 WiFiClientSecure用了setInsecure()跳过证书校验。这对公开数据接口是常见做法,简化了流程,但严格来说降低了安全性,请勿照搬到需要传输敏感信息的场景。
调参提醒
RADAR_KM要根据所在地调整。在机场附近(航班密集)建议 2030km;航线稀疏地区可放大到 80100km。这个参数直接决定屏幕上是挤成一团还是空空荡荡。MAX_SHOW不要贪多。虽然代码上限支持 60 架,但 2.8 寸屏上显示 8 架已经是舒适上限,再多标签就会互相遮挡(虽然有避让算法,但太密时会退化成"只显示飞机不显示标签")。- 改
drawAircraft()里的坐标可以缩放飞机图标。所有部件坐标都在 ±8 这一档,等比例放大到 ±10 或缩到 ±6 都很方便。
版权与合规
- 数据来源须注明。adsb.fi 提供的是个人非商业用途免费接口,转载或分享本项目时请一并注明数据来源为 adsb.fi。
- 不要用于商业用途。本项目仅作学习交流,请勿将航班数据用于任何商业产品。




