UNIHIKER K10 塔台雷达:用一块小屏,实时看见头顶飞过的每一架航班

2026-10-155

基于 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 → 工具 → 管理库,搜索并安装:

  1. ArduinoJson(作者:Benoit Blanchon)— 必装
  2. TFT_eSPI 与 unihiker_k10 — 安装 K10 官方包时自动附带,无需单独装

三、制作过程

步骤 1:环境准备

  1. 安装 Arduino IDE(1.8.x 或 2.x 均可)
  2. 在 Arduino IDE 的开发板管理器里搜索并安装 UNIHIKER K10 的板级支持包
  3. 在库管理器里安装 ArduinoJson
  4. 开发板选择 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,MAX_SHOW 保持 68;
如果在航线稀疏地区,RADAR_KM 可放大到 80~100,否则屏幕上会很空。

步骤 4:烧录

  1. 用 Type-C 线连接 K10 到电脑
  2. 点击上传,等待编译烧录完成
  3. 打开串口监视器(波特率 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 即可脱离电脑独立运行。开机流程:

  1. 屏幕依次显示 Connecting WiFi... → Locating... → Fetching flights...
  2. 随后画面进入雷达界面:扫描线开始匀速转圈
  3. 大约 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);        // 斜距(勾股定理)

再按显示半径归一化到像素:

x_{screen} = \frac{\Delta lon_{km}}{RADAR\_KM} \times R_{MAX}, \qquad y_{screen} = -\frac{\Delta lat_{km}}{RADAR\_KM} \times R_{MAX}

为什么经度要乘 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 个像素,眨个眼就找不回位置了。

解决:

  1. 刷新间隔缩到 5 秒:5 秒飞 1.2km ≈ 1.5 像素,肉眼能看出缓慢移动
  2. 加平滑插值:每架飞机存"目标坐标"和"显示坐标"两套,显示坐标每帧朝目标挪 8%,看到的是缓慢滑过去而非硬跳
p.dx += (p.x - p.dx) * 0.08f;   // 平滑跟随
p.dy += (p.y - p.dy) * 0.08f;
  1. 呼号匹配:两次刷新之间同一架飞机(按呼号匹配)会继承上一次的显示坐标与整条航迹,这样它才是"一路飞过去",而不是每次重新出现

问题 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();                         // 非阻塞取数据,几乎不耗时
  // ... 画这一帧 ...
}

改完后扫描线会一直流畅转动,数据在后台悄悄更新完成,下一帧自动换上新位置。

这一步还连着三个子坑,一并说明:

  1. 栈溢出:一开始把 60 架飞机的结构体(约 8KB)放在任务栈上,必爆。改成 static,任务栈给 8192 字节就稳。
  2. 数组越界:队列只收 8 架,但解析中间过程要暂存 60 架,直接写会踩内存。改成内部用独立的 work[60] 工作区,最后只把前 8 架拷出来。
  3. 跨核数据竞争:呼号匹配用的"上一轮数据表"如果两个核都读写会打架。改成 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 轴向下,取负
}

旋转公式就是标准的二维旋转矩阵:

\begin{pmatrix} x' \\ y' \end{pmatrix} = \begin{pmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix}

然后画 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);
}

七、图片 / 视频展示

效果示意图

UNIHIKER K10 塔台雷达:用一块小屏,实时看见头顶飞过的每一架航班_image_1.webp

图中标注了各个视觉元素的含义:红色飞机剪影(按航向旋转)、黄色呼号与高度标签、渐隐绿色航迹、同心圆标尺、扫描线与尾迹、屏幕中心的自身位置。


八、注意事项

硬件相关

  1. WiFi 必须是 2.4GHz 频段。ESP32-S3 不支持 5GHz,如果连不上,先检查路由器是不是开了 5G 专用 SSID。
  2. 供电要足。K10 屏幕亮度高、WiFi 发射时电流会有尖峰,用劣质数据线可能出现重启。建议用原装线或电脑 USB 口供电。
  3. 注意散热。长期运行时板子会温热,属正常现象,但别放在密闭盒子里。

软件与数据

  1. ArduinoJson 必须手动安装,K10 官方包里没有,不装会编译报错。
  2. adsb.fi 接口限速 1 次/秒。代码里 POLL_MS 默认 5000(5 秒),请不要调低于 1000,否则会被限流甚至封 IP。
  3. 数据覆盖有地域差异。ADS-B 依赖民间接收机,城市/机场周边覆盖好,偏远地区可能飞机很少甚至为零——这不是程序 bug。
  4. IP 定位精度有限,只能定位到城市级别。如果定位偏差大,直接在代码里把 DEFAULT_LAT / DEFAULT_LON 写死成自己的坐标。
  5. WiFiClientSecure 用了 setInsecure() 跳过证书校验。这对公开数据接口是常见做法,简化了流程,但严格来说降低了安全性,请勿照搬到需要传输敏感信息的场景。

调参提醒

  1. RADAR_KM 要根据所在地调整。在机场附近(航班密集)建议 2030km;航线稀疏地区可放大到 80100km。这个参数直接决定屏幕上是挤成一团还是空空荡荡。
  2. MAX_SHOW 不要贪多。虽然代码上限支持 60 架,但 2.8 寸屏上显示 8 架已经是舒适上限,再多标签就会互相遮挡(虽然有避让算法,但太密时会退化成"只显示飞机不显示标签")。
  3. 改 drawAircraft() 里的坐标可以缩放飞机图标。所有部件坐标都在 ±8 这一档,等比例放大到 ±10 或缩到 ±6 都很方便。

版权与合规

  1. 数据来源须注明。adsb.fi 提供的是个人非商业用途免费接口,转载或分享本项目时请一并注明数据来源为 adsb.fi。
  2. 不要用于商业用途。本项目仅作学习交流,请勿将航班数据用于任何商业产品。

九、相关附件

硬件清单

创作许可协议

本项目采用 CC BY(署名) 进行许可。

所需材料/产品

加载全部

    行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

    行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

    行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

相关推荐

评论(0)
- 没有更多了 -

创作许可协议

本项目采用 CC BY(署名) 进行许可。

相关推荐

所需材料/产品

行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算