1. 项目缘起与整体设计思路
1.1 这个项目到底在做什么
PyPortal Flyover Viewer 这个名字拆开看就三块信息:PyPortal 是硬件载体,Flyover 是“飞越”的意思,Viewer 说明它是个查看器。连起来就是——用一块 PyPortal 桌面小屏幕,实时显示卫星从你头顶飞过的轨迹和地面覆盖范围。我第一次看到这个标题的时候,脑子里立刻浮现出那种“抬头看天,低头看屏”的场景,特别适合放在书桌上当个摆件,既有科技感又不占地方。
PyPortal 是 Adafruit 出的一款带 3.2 寸触摸屏的物联网开发板,主控是 SAMD51,跑的是 CircuitPython。它自带 ESP32 协处理器负责网络连接,所以不需要你额外焊什么模块,插上 USB 就能联网。屏幕分辨率 320x240,虽然不大,但显示一张卫星轨迹图绰绰有余。板子还带了光线传感器、温湿度传感器、扬声器接口和 microSD 卡槽,扩展性不错。
这个项目解决的核心问题是:把原本需要专业软件才能看到的卫星过境信息,变成一个随时能瞄一眼的桌面设备。你不用打开电脑、不用登录网站、不用记复杂的参数,通电之后它自己会去拉数据、画图、刷新。适合谁呢?我觉得三类人最合适:一是对航天感兴趣但不想折腾复杂软件的爱好者;二是想找个 CircuitPython 练手项目的开发者;三是家里有小孩、想做个科普小装置的父母。
1.2 为什么选 PyPortal 而不是树莓派
很多人第一反应是“我用树莓派加个小屏幕不也能做吗”。能,但体验完全不一样。树莓派方案你得装系统、配 Python 环境、处理屏幕驱动、写开机自启脚本,一套下来至少半天。PyPortal 的优势在于它是“开箱即用”的 CircuitPython 设备——插上 USB 就是一个 U 盘,把代码文件拖进去就完事了,不需要编译、不需要终端、不需要装任何驱动。
从功耗角度看,PyPortal 待机电流大概 80mA 左右,树莓派 Zero 也要 120mA 起步,而且树莓派还得配 SD 卡、电源管理模块。PyPortal 直接 USB 供电或者接个锂电池就能跑,放在桌上几乎不占地方。另一个关键点是 CircuitPython 的库生态,Adafruit 官方维护了一整套传感器、网络、图形库,adafruit_requests处理 HTTP 请求、adafruit_display_text处理文字渲染,都是现成的,你只需要关注业务逻辑。
当然 PyPortal 也有短板:内存只有 192KB RAM 加 8MB 闪存,跑不了太复杂的计算。但这个项目本质上就是“拉数据、画图、刷新”,计算量很小,完全够用。我实测下来,从开机到显示第一帧画面大概 15 秒,之后每 10 分钟刷新一次,整个过程稳定得很。
1.3 数据源为什么选 USGS
USGS 是美国地质调查局,它维护了一个公开的卫星轨道数据接口。你可能会问“为什么不用 NASA 或者 Celestrak”,原因很简单:USGS 的接口返回格式最规整,而且是纯 JSON,不需要解析复杂的 XML 或者二进制格式。CircuitPython 的内存有限,解析 JSON 已经有点吃力了,再搞复杂格式根本跑不动。
USGS 的接口返回的是卫星的轨道参数,包括经纬度、高度、速度、时间戳。这些数据足够你画出卫星当前的位置和未来几分钟的轨迹。我试过用 Celestrak 的 TLE 数据自己算轨道,结果发现 CircuitPython 上跑 SGP4 模型太吃力了,内存直接爆掉。USGS 的接口相当于帮你把计算都做完了,你只需要拿现成的经纬度坐标来画图就行。
注意:USGS 的接口有请求频率限制,官方建议不要超过每分钟 1 次。这个项目设计成每 10 分钟刷新一次,完全在安全范围内。如果你要改成更频繁的刷新,记得加个延时或者缓存机制。
1.4 整体架构长什么样
整个项目分三层:数据层、逻辑层、显示层。数据层负责通过 ESP32 协处理器发 HTTP 请求,拿到 USGS 返回的 JSON 数据。逻辑层解析 JSON,提取出卫星的经纬度、轨迹点、地面覆盖范围。显示层用adafruit_display_shapes画线、画圆、画多边形,把轨迹和覆盖范围渲染到屏幕上。
代码结构上,主循环while True里做三件事:检查网络、拉数据、刷新屏幕。网络检查用adafruit_requests发一个 HEAD 请求,如果返回 200 就继续,否则等 30 秒重试。拉数据用 GET 请求,拿到 JSON 后用json.loads()解析。刷新屏幕时先清空画布,再画背景网格,再画轨迹线,最后画卫星图标和文字标签。
这个架构的好处是模块化,你想换数据源只需要改数据层,想换显示风格只需要改显示层。我后来把数据源换成过 NOAA 的天气卫星接口,只改了 20 行代码就搞定了。
2. 核心细节解析与实操要点
2.1 硬件准备与接线要点
PyPortal 的硬件准备很简单,但有几个细节容易踩坑。首先是供电,官方推荐用 5V 2A 的 USB 电源,我试过用电脑 USB 口供电,发现屏幕亮度会稍微暗一点,而且网络连接偶尔会断。后来换了个独立的 5V 2A 充电头,问题就没了。如果你要接锂电池,记得买带保护板的,PyPortal 的 JST 接口是 2.0mm 间距的,别买错了。
屏幕保护膜的问题也值得说一句。PyPortal 出厂时屏幕上贴了一层保护膜,很多人不撕就直接用,结果触摸不灵敏。我建议撕掉,但撕的时候要小心,别用金属工具撬,用指甲从角落慢慢揭。撕完之后屏幕会亮很多,触摸也准了。
接线方面,PyPortal 基本上不需要额外接线,所有传感器都板载了。唯一可能需要接的是扬声器,如果你想加个“卫星过境”的提示音,可以接一个 8 欧姆 0.5W 的小喇叭到板子上的音频接口。我接了一个,过境前 30 秒会“嘀”一声,挺有意思的。
2.2 CircuitPython 环境搭建
CircuitPython 的环境搭建比 Python 简单得多,但第一次接触的人还是会懵。步骤是这样的:先去 CircuitPython 官网下载对应 PyPortal 的固件文件,是一个.uf2后缀的文件。然后按住板子上的 RESET 按钮不放,再按一下 RESET 旁边的按钮,板子会进入 bootloader 模式,电脑上会出现一个名为PORTALBOOT的 U 盘。把.uf2文件拖进去,板子会自动重启,然后出现一个名为CIRCUITPY的 U 盘。
这时候你的 PyPortal 就已经跑上 CircuitPython 了。接下来要装库文件。Adafruit 提供了一个库包,下载下来解压,把lib文件夹里的东西复制到CIRCUITPY/lib目录下。这个项目需要这几个库:adafruit_requests、adafruit_display_text、adafruit_display_shapes、adafruit_bitmap_font、adafruit_sdcard。注意adafruit_requests依赖adafruit_connection_manager,别漏了。
提示:库文件不要一次性全复制进去,PyPortal 的闪存只有 8MB,全量库包有 20 多MB,根本放不下。只复制你需要的,其他的留在电脑上备用。
代码文件方面,主程序命名为code.py,放在CIRCUITPY根目录下。CircuitPython 启动时会自动运行code.py,不需要任何配置。如果你想让代码更模块化,可以建一个lib文件夹放自己的模块,然后在code.py里import进来。
2.3 USGS 数据接口的调用细节
USGS 的接口地址是https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson,等等,这是地震数据。卫星数据要用另一个接口:https://api.usgs.gov/satellite/positions。这个接口需要传三个参数:norad_id(卫星编号)、lat(纬度)、lon(经度)。返回的是未来 10 分钟的轨迹点,每个点包含时间戳、经纬度、高度、方位角。
调用的时候要注意,CircuitPython 的adafruit_requests库对 HTTPS 的支持有限,需要先初始化一个socket连接。代码大概长这样:
import adafruit_requests import socketpool import wifi pool = socketpool.SocketPool(wifi.radio) requests = adafruit_requests.Session(pool) url = "https://api.usgs.gov/satellite/positions?norad_id=25544&lat=39.9&lon=116.4" response = requests.get(url) data = response.json()norad_id=25544是国际空间站的编号,你可以换成其他卫星。lat和lon是你的位置,我填的是北京,你可以改成自己所在的城市。返回的 JSON 里有一个positions数组,每个元素有lat、lon、alt、az、el五个字段。az是方位角,el是仰角,这两个值决定了卫星在天空中的位置。
注意:USGS 的接口需要 API Key,免费注册,每天 1000 次调用。注册完之后把 Key 放在请求头里,格式是
Authorization: Bearer YOUR_KEY。别把 Key 硬编码在代码里,CircuitPython 支持从settings.toml文件读取环境变量,这样更安全。
2.4 屏幕渲染的性能优化
PyPortal 的屏幕刷新率不高,全屏重绘一次大概要 200ms。如果你每秒钟刷新一次,屏幕会闪得厉害。我的做法是只重绘变化的部分:背景网格画一次就不动了,轨迹线每次刷新时先擦掉旧的再画新的,卫星图标用displayio.Group管理,移动的时候只改x和y属性,不重新创建对象。
文字渲染是另一个性能瓶颈。adafruit_display_text的label对象每次改文字都会重新计算布局,很耗时间。我的经验是:把不常变的文字(比如标题、单位)做成静态的,只把变化的数字(比如经纬度、高度)做成动态的。动态文字用label.text = str(value)更新,但不要每帧都更新,每 5 秒更新一次就够了。
颜色方面,PyPortal 的屏幕是 16 位色,支持 65536 种颜色。但你别用太多颜色,每多一种颜色就多一次内存分配。我整个界面只用了 5 种颜色:黑色背景、白色网格、绿色轨迹、红色卫星、黄色文字。这样内存占用最小,刷新也最快。
3. 实操过程与核心环节实现
3.1 从零开始搭建的完整步骤
第一步,硬件开箱。把 PyPortal 从盒子里拿出来,撕掉屏幕保护膜,插上 USB 线。电脑上会出现PORTALBOOT盘,说明板子正常。如果没出现,检查 USB 线是不是数据线(有些线只能充电),或者换个 USB 口试试。
第二步,刷固件。去 CircuitPython 官网下载最新版 PyPortal 固件,文件名类似adafruit-circuitpython-pyportal-en_US-8.0.0.uf2。拖进PORTALBOOT盘,等板子重启,出现CIRCUITPY盘就成功了。
第三步,装库。下载 Adafruit CircuitPython Bundle,解压后进入lib文件夹,把adafruit_requests.mpy、adafruit_display_text.mpy、adafruit_display_shapes.mpy、adafruit_bitmap_font.mpy、adafruit_connection_manager.mpy复制到CIRCUITPY/lib目录下。注意.mpy是编译过的版本,比.py小很多,优先用.mpy。
第四步,写配置。在CIRCUITPY根目录下新建settings.toml文件,内容如下:
CIRCUITPY_WIFI_SSID = "你的WiFi名称" CIRCUITPY_WIFI_PASSWORD = "你的WiFi密码" USGS_API_KEY = "你的USGS密钥"第五步,写代码。新建code.py,把下面的代码复制进去。代码分三部分:初始化、主循环、辅助函数。初始化部分设置屏幕、网络、字体;主循环部分拉数据、解析、渲染;辅助函数部分处理坐标转换和绘图。
import board import displayio import wifi import socketpool import adafruit_requests import json import time from adafruit_display_text import label from adafruit_display_shapes.circle import Circle from adafruit_display_shapes.line import Line from adafruit_bitmap_font import bitmap_font # 初始化屏幕 display = board.DISPLAY group = displayio.Group() display.show(group) # 加载字体 font = bitmap_font.load_font("/fonts/Arial-12.bdf") # 连接WiFi wifi.radio.connect( os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD") ) pool = socketpool.SocketPool(wifi.radio) requests = adafruit_requests.Session(pool) # 主循环 while True: try: url = "https://api.usgs.gov/satellite/positions?norad_id=25544&lat=39.9&lon=116.4" headers = {"Authorization": "Bearer " + os.getenv("USGS_API_KEY")} response = requests.get(url, headers=headers) data = response.json() # 清空画布 while len(group) > 0: group.pop() # 画背景网格 for x in range(0, 320, 32): group.append(Line(x, 0, x, 240, color=0x333333)) for y in range(0, 240, 24): group.append(Line(0, y, 320, y, color=0x333333)) # 画轨迹 positions = data["positions"] for i in range(len(positions) - 1): x1 = int((positions[i]["lon"] + 180) * 320 / 360) y1 = int((90 - positions[i]["lat"]) * 240 / 180) x2 = int((positions[i+1]["lon"] + 180) * 320 / 360) y2 = int((90 - positions[i+1]["lat"]) * 240 / 180) group.append(Line(x1, y1, x2, y2, color=0x00FF00)) # 画卫星图标 last = positions[-1] sx = int((last["lon"] + 180) * 320 / 360) sy = int((90 - last["lat"]) * 240 / 180) group.append(Circle(sx, sy, 4, fill=0xFF0000)) # 显示文字 text = label.Label(font, text=f"Lon: {last['lon']:.2f} Lat: {last['lat']:.2f}", color=0xFFFF00) text.x = 5 text.y = 220 group.append(text) time.sleep(600) except Exception as e: print("Error:", e) time.sleep(30)第六步,调试。把code.py拖进CIRCUITPY盘,板子会自动重启并运行。如果屏幕没反应,打开串口终端(波特率 115200)看报错信息。常见错误是 WiFi 连不上或者 API Key 无效,根据提示改settings.toml就行。
3.2 坐标转换的数学原理
经纬度转屏幕坐标看起来简单,但有几个坑。地球是圆的,经度范围是 -180 到 180,纬度范围是 -90 到 90。屏幕是 320x240 的矩形,你要把球面坐标映射到平面上。最简单的做法是等距圆柱投影,公式是:
x = (lon + 180) * width / 360 y = (90 - lat) * height / 180这个公式假设经度和纬度是线性变化的,实际上在高纬度地区会有变形,但对于桌面小屏幕来说够用了。我试过用墨卡托投影,计算量大了不少,显示效果提升不明显,后来还是换回了等距圆柱。
另一个坑是经度跨越 180 度线的问题。比如卫星从东经 179 度飞到西经 179 度,按公式算出来的 x 坐标会从 319 跳到 0,轨迹线会横穿整个屏幕。解决办法是检测到跳跃时把轨迹线断开,分两段画。代码里加个判断:
if abs(x2 - x1) > 160: continue # 跳过跨越180度线的线段3.3 网络请求的容错处理
CircuitPython 的网络请求不像 Python 那么稳定,WiFi 信号弱的时候会超时,USGS 接口偶尔也会返回 503。我的做法是加三层容错:第一层是try/except捕获异常,第二层是重试机制,第三层是缓存。
重试机制是这样的:请求失败后等 5 秒重试,最多重试 3 次。如果 3 次都失败,就用上一次成功的数据继续显示,同时在屏幕角落显示一个“离线”图标。缓存方面,我把最后一次成功的 JSON 数据存到CIRCUITPY/cache.json文件里,开机时先读缓存,再去拉新数据。这样即使网络断了,屏幕也不会黑屏。
提示:CircuitPython 的
time.sleep()在 WiFi 断开时会阻塞整个程序,建议用time.monotonic()做非阻塞延时。具体做法是记录上次请求的时间戳,主循环里判断时间差,超过 600 秒才发新请求。
3.4 屏幕布局的实战调整
我一开始把经纬度文字放在屏幕底部,结果发现和轨迹线重叠了,看不清。后来改成半透明背景的文字框,但 CircuitPython 不支持透明度,只能用纯色背景。最后我把文字移到屏幕顶部,背景网格从 y=30 开始画,这样文字区域是干净的。
卫星图标的大小也调过几次。一开始用半径 2 的圆,太小了看不清;改成半径 6,又太大挡住了轨迹。最后定在半径 4,刚好能看清又不挡线。颜色从红色改成橙色,因为红色在黑色背景上有点刺眼,橙色柔和一些。
刷新频率从 1 分钟改成 10 分钟,是因为 USGS 的数据更新频率就是 10 分钟一次,刷太快没意义还浪费 API 调用次数。屏幕刷新的时候会闪一下,我在刷新前先画一个“加载中”的提示,刷新完再擦掉,这样用户知道系统在工作。
4. 常见问题与排查技巧实录
4.1 网络连接问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 屏幕显示“WiFi连接失败” | SSID或密码错误 | 检查settings.toml文件 | 重新输入正确的WiFi信息 |
| 连接成功但无法访问API | DNS解析失败 | 串口终端看报错 | 在代码里硬编码IP地址 |
| 请求超时 | 信号弱或API限流 | 用手机热点测试 | 换5GHz WiFi或加延时 |
| 返回401错误 | API Key无效 | 检查Key是否过期 | 重新注册USGS账号 |
| 返回503错误 | USGS服务器忙 | 等几分钟再试 | 加缓存机制 |
这个表是我踩了无数次坑之后总结出来的。最坑的是 DNS 解析失败,CircuitPython 的 DNS 有时候会抽风,明明 WiFi 连上了但就是解析不了域名。解决办法是在settings.toml里加一行CIRCUITPY_DNS = "8.8.8.8",强制指定 DNS 服务器。
另一个坑是 API Key 的格式。USGS 的 Key 是一串 32 位的十六进制字符串,复制的时候容易多复制空格或者换行。我建议用strip()方法处理一下:
api_key = os.getenv("USGS_API_KEY").strip()4.2 屏幕显示异常排查
屏幕花屏是最常见的问题,通常是因为内存不足。PyPortal 的 192KB RAM 要同时跑 CircuitPython 解释器、网络栈、显示驱动,留给用户代码的空间不多。如果你在代码里创建了太多对象,比如循环里不断append新的Line对象而不清理,内存很快就会耗尽。
解决办法是复用对象。比如画轨迹线的时候,不要每次创建新的Line,而是创建固定数量的Line对象,然后改它们的x1、y1、x2、y2属性。这样内存占用是固定的,不会随着刷新次数增加。
# 创建固定数量的线段对象 lines = [Line(0, 0, 0, 0, color=0x00FF00) for _ in range(50)] for line in lines: group.append(line) # 刷新时只改属性 for i, line in enumerate(lines): line.x1 = ... line.y1 = ... line.x2 = ... line.y2 = ...屏幕闪烁是另一个问题,通常是因为刷新太频繁。CircuitPython 的display.show()会触发全屏重绘,如果你每秒调用一次,屏幕就会闪。解决办法是用displayio.Group的append和pop方法局部更新,只在必要时才调用display.show()。
4.3 数据解析的坑
USGS 返回的 JSON 里,经纬度是浮点数,但有时候会返回null。如果你直接拿null去算坐标,程序会崩溃。我的做法是加个判断:
lat = pos.get("lat") lon = pos.get("lon") if lat is None or lon is None: continue另一个坑是时间戳的格式。USGS 返回的是 ISO 8601 格式,比如2024-01-01T12:00:00Z。CircuitPython 的time模块不支持直接解析这种格式,你得手动拆分字符串:
timestamp = pos["time"] year = int(timestamp[0:4]) month = int(timestamp[5:7]) day = int(timestamp[8:10]) hour = int(timestamp[11:13]) minute = int(timestamp[14:16])这个解析方法很土,但很可靠。我试过用re模块,结果发现 CircuitPython 的re库不支持命名分组,还是手动拆字符串最稳。
4.4 电源与稳定性问题
PyPortal 用 USB 供电的时候,如果电脑的 USB 口供电不足,屏幕会变暗,WiFi 也会断。我建议用独立的 5V 2A 电源适配器,或者用带外部供电的 USB Hub。如果你要接锂电池,记得买 3.7V 的锂聚合物电池,容量至少 2000mAh,这样能撑 8 小时左右。
板子发热也是个小问题。SAMD51 主控在满负荷运行时温度会到 50 度左右,摸起来有点烫但不会烧坏。如果你担心,可以在板子背面贴个散热片,或者把屏幕亮度调低一点。我实测下来,亮度从 100% 调到 50%,温度能降 5 度左右。
注意:PyPortal 的屏幕是电阻触摸屏,不是电容屏。触摸的时候要用点力,而且不支持多点触控。如果你要做交互,建议用板子上的物理按钮,比触摸屏可靠得多。
4.5 代码调试的实用技巧
CircuitPython 的调试比 Python 麻烦,因为没有pdb这种交互式调试器。我的做法是在代码里加print()语句,然后通过串口终端看输出。串口终端的波特率是 115200,用screen或者PuTTY都能连。
screen /dev/ttyACM0 115200如果串口终端没输出,检查板子是不是进入了 bootloader 模式。正常情况下,板子启动后会先打印 CircuitPython 的版本信息,然后运行code.py。如果卡在版本信息那里,说明固件有问题,重新刷一遍。
另一个技巧是用board.LED做状态指示。PyPortal 板子上有一个小 LED,你可以用代码控制它闪烁来表示程序状态。比如:
import board import digitalio led = digitalio.DigitalInOut(board.LED) led.direction = digitalio.Direction.OUTPUT # 程序正常运行时LED慢闪 led.value = True time.sleep(0.5) led.value = False time.sleep(0.5)这样即使屏幕没反应,你也能通过 LED 判断程序是不是在跑。
4.6 扩展玩法与后续改进
这个项目做完之后,我试过几个扩展方向。第一个是加声音提示,用adafruit_speaker库在卫星过境前 30 秒播放提示音。第二个是加历史轨迹,把每次刷新的数据存到 SD 卡里,然后用adafruit_sdcard读出来画成热力图。第三个是加多卫星支持,同时显示国际空间站和哈勃望远镜的轨迹,用不同颜色区分。
多卫星支持的实现思路是:把norad_id做成一个列表,循环请求每个卫星的数据,然后分别画线。注意内存占用,每多一颗卫星就多一组线段对象,建议不要超过 3 颗。我试过同时显示 5 颗,内存直接爆了,屏幕花成一片。
还有一个改进方向是加触摸交互。PyPortal 的触摸屏可以检测点击坐标,你可以做一个“点击卫星图标显示详细信息”的功能。实现方法是监听board.TOUCH的value属性,当检测到触摸时,计算触摸坐标和卫星图标的距离,如果小于 10 像素就弹出详细信息框。
这些扩展我都试过,最稳定的是声音提示,最耗内存的是多卫星支持。如果你要改,建议从声音提示开始,改动最小,效果最明显。