基于PyPortal与JSON API的嵌入式气象站:实时风暴追踪器开发实战

发布时间:2026/5/18 13:32:10

基于PyPortal与JSON API的嵌入式气象站:实时风暴追踪器开发实战 1. 项目概述与核心价值最近在折腾一个嵌入式气象站项目核心需求是能在一块屏幕上实时显示大西洋上的热带气旋动态。这听起来像是气象局的专业设备但实际上借助像Adafruit PyPortal这样的开源硬件和NOAA美国国家海洋和大气管理局开放的JSON数据接口我们完全可以在桌面上搭建一个属于自己的、逼格满满的实时风暴追踪器。这个项目的核心逻辑并不复杂设备定期从指定的URL抓取一份JSON格式的风暴数据解析出每个风暴的名称、位置经纬度、强度等级和移动方向然后通过一系列计算把这些抽象的数据点变成屏幕上一个个带图标、名称和方向箭头的可视化标记叠加在一张静态的背景地图上。为什么选择JSON和PyPortal这个组合对于物联网和嵌入式设备来说JSON数据格式几乎是现代Web API的“普通话”。它结构清晰、轻量级与文本协议兼容性好非常适合在有限的网络带宽和内存资源下进行传输和解析。而PyPortal本质上是一个集成了Wi-Fi、彩色触摸屏和强大MicroPython运行环境的“一体化开发板”它把网络连接、图形显示和用户交互的底层复杂性都封装好了并提供了高级别的PyPortal库。这使得开发者可以像在PC上写Python脚本一样专注于业务逻辑——比如“获取数据、解析数据、画出来”而不必深陷于配置Wi-Fi驱动、管理显示缓冲区或处理触摸中断这些底层细节中。这个项目完美地展示了如何将云端数据NOAA的JSON API、嵌入式硬件PyPortal和图形化软件技术坐标转换、sprite sheet三者无缝衔接构建一个功能完整、响应及时的专用信息显示终端。2. 硬件平台与软件环境搭建2.1 PyPortal硬件特性解析PyPortal的成功很大程度上得益于其高度集成和“开箱即用”的设计理念。我们不需要自己焊接Wi-Fi模块、连接TFT屏幕排线或者为MicroSD卡设计电平转换电路。这一切都被集成在了一块比手掌还小的板子上。其核心是一颗Microchip原Atmel的ATSAMD51系列ARM Cortex-M4微控制器主频高达120MHz并配备了2MB的Flash和256KB的RAM。这个配置对于运行MicroPython和处理JSON数据流来说是相当充裕的。更关键的是其外围集成一块3.5英寸、320x240分辨率的IPS电容触摸屏提供了优秀的显示和交互基础ESP32协处理器专门负责Wi-Fi连接实现了稳定的网络功能并且与主控芯片通过高速串口通信板载的MicroSD卡槽用于存放地图、字体、图标等资源文件一个3W的D类音频放大器和小扬声器甚至能让你为风暴警报配上音效。从开发角度看这种集成度将我们从繁琐的硬件调试中解放出来让我们能立刻开始编写应用层的代码。在开始编码前你需要准备以下环境一块Adafruit PyPortal或功能类似的PyPortal Titano等型号。一根Micro-USB数据线用于供电和编程。一个稳定的2.4GHz Wi-Fi网络环境。一台安装了代码编辑器的电脑推荐使用Visual Studio Code并安装PlatformIO插件或者直接使用Mu Editor它们对MicroPython开发支持友好。2.2 软件库依赖与固件烧录PyPortal的魔力很大程度上封装在CircuitPythonAdafruit基于MicroPython的分支及其丰富的“库”Library生态中。我们需要几个核心库来支撑这个项目adafruit_pyportal 这是项目的核心库它提供了一个高级抽象对象封装了网络请求、图形显示、触摸事件处理等复杂操作。displayio CircuitPython的现代图形库用于管理屏幕上的所有图形元素位图、标签、组等。我们创建风暴图标组、背景地图都离不开它。adafruit_bitmap_font与adafruit_display_text 用于在屏幕上渲染美观的字体显示风暴名称。adafruit_imageload 用于从存储设备如SD卡加载图片资源例如我们的背景地图和精灵图。注意 在CircuitPython中库文件通常以.mpy预编译的字节码或.py形式存在需要手动放置到板子的/lib目录下。最可靠的方式是使用Adafruit的库捆绑包Bundle根据你的CircuitPython版本下载对应的包然后将其中的必要库复制进去。不要试图用pip install那是在电脑上用的。固件方面你需要为PyPortal烧录最新版本的CircuitPython固件。具体步骤是访问CircuitPython官网找到PyPortal对应的.uf2文件将PyPortal通过USB连接到电脑并快速双击板载的复位按钮使其进入名为PORTALBOOT的U盘模式最后将下载的.uf2文件拖入该U盘板子会自动重启并运行新固件。完成后电脑上会出现一个名为CIRCUITPY的U盘这就是我们的代码存储和运行盘。2.3 项目文件结构与Secrets配置一个清晰的项目结构有助于管理代码和资源。在CIRCUITPY根目录下我通常会这样组织CIRCUITPY/ ├── code.py # 主程序入口CircuitPython会自动执行此文件 ├── secrets.py # 保存Wi-Fi密码等敏感信息切勿上传至Git ├── lib/ # 存放所有依赖的库文件 │ ├── adafruit_pyportal.mpy │ ├── adafruit_display_text/ │ └── ... └── assets/ # 存放图片、字体等资源文件 ├── map.bmp # 背景地图 └── icons.bmp # 风暴图标精灵图其中secrets.py文件至关重要它包含了连接Wi-Fi所需的凭证。其格式如下secrets { ssid: 你的Wi-Fi名称, password: 你的Wi-Fi密码, }请务必用你自己的网络信息替换掉引号内的内容。CircuitPython主程序code.py会导入这个文件来获取密码。永远不要将包含真实密码的secrets.py提交到任何公开的代码仓库。一个安全的做法是提交一个secrets.py.example模板文件里面用占位符代替真实信息。3. 数据获取与JSON解析实战3.1 NOAA JSON数据源接口分析我们的数据来源于NOAA的“CurrentStorms.json”接口。这是一个公开的、无需认证的API返回当前活跃热带气旋飓风、台风、热带风暴等的信息。通过浏览器或curl命令访问这个URL我们可以看到返回的JSON结构大致如下为简洁起见已简化{ activeStorms: [ { name: HURRICANE_FIONA, classification: HU, latitude: 23.5, longitude: -65.2, movement: NW, windSpeed: 105, ...: ... }, { name: TROPICAL_STORM_GASTON, ...: ... } ] }这个结构非常清晰顶层是一个对象其中包含一个名为activeStorms的键其值是一个数组列表。列表中的每个元素都是一个对象代表一场风暴包含了我们需要的所有属性。理解这个结构是正确解析数据的第一步。classification字段的编码如“HU”代表飓风“TS”代表热带风暴“TD”代表热带低压决定了我们后续显示哪个图标。3.2 PyPortal网络请求与数据抓取在CircuitPython中直接使用底层的socket或requests库处理HTTPS请求和JSON解析既繁琐又容易出错。adafruit_pyportal库的价值就在这里体现。初始化PyPortal对象时我们通过参数直接告知它数据源的URL和JSON数据的路径。import board from adafruit_pyportal import PyPortal # 初始化PyPortal对象 pyportal PyPortal( urlhttps://www.nhc.noaa.gov/CurrentStorms.json, json_path[activeStorms], # 关键指定数据在JSON中的路径 status_neopixelboard.NEOPIXEL, # 使用板载NeoPixel作为状态指示灯 default_bg/assets/map.bmp, # 设置默认背景图片路径 )json_path参数是一个列表它描述了从JSON根节点到目标数据数组的路径。这里[activeStorms]意味着取回JSON数据解析它然后找到activeStorms这个键将其对应的值即风暴列表作为fetch()方法的返回值。这个设计非常直观省去了我们手动解析JSON的步骤。当需要获取数据时只需调用storm_data pyportal.fetch()这一行代码背后PyPortal库完成了以下工作1) 通过ESP32协处理器建立Wi-Fi连接2) 发起一个HTTPS GET请求到指定URL3) 接收响应数据4) 使用内置的JSON解析器按照json_path的指示提取出目标数据5) 将提取出的Python列表或字典返回。如果网络超时或JSON解析失败该方法会抛出异常因此在实际项目中最好用try-except块包裹起来进行错误处理。3.3 数据结构处理与关键信息提取fetch()调用成功后storm_data变量就是一个标准的Python列表。我们可以用熟悉的Python语法来遍历和访问数据。if storm_data: # 确保列表不为空 for storm in storm_data: name storm[name] lat storm[latitude] lon storm[longitude] classification storm[classification] movement storm[movement] # ... 处理其他信息 print(fStorm: {name}, Lat: {lat}, Lon: {lon}, Class: {classification})这里有几个实操要点键名准确性访问字典的键时必须确保键名字符串与JSON中的完全一致包括大小写。NOAA的接口键名如latitude是全小写。数据类型JSON中的数字会被解析为Python的int或float字符串就是str。经纬度通常是浮点数。错误处理不是每场风暴的数据都包含所有字段。稳健的代码应该使用storm.get(someKey, default_value)方法来访问避免因键不存在而引发KeyError导致程序崩溃。内存考虑虽然一次抓取的数据量不大但在嵌入式环境中仍应避免在内存中保存不必要的中间数据。提取出所需字段后原始的大字典就可以被垃圾回收了。4. 核心图形化显示技术剖析4.1 经纬度到屏幕坐标的转换算法这是本项目在图形显示上的第一个技术难点。JSON数据提供的是地理坐标经纬度而我们的屏幕是像素坐标x, y。我们需要一个函数能将地图所覆盖的地理区域例如大西洋海域西经100度到东经20度南纬10度到北纬60度线性映射到屏幕区域0到319像素0到239像素。这个过程称为线性映射。其核心公式如下screen_x map_west (longitude - geo_west) * (screen_width / (geo_east - geo_west)) screen_y map_north (latitude - geo_north) * (screen_height / (geo_south - geo_north))注意屏幕坐标系通常以左上角为原点(0,0)y轴向下为正。而地理坐标系北纬为正南纬为负东经为正西经为负。在映射时需要特别注意符号关系确保“北”对应屏幕上方“西”对应屏幕左方。在我的实现中我定义了地图的四个地理边界和屏幕的四个像素边界# 地理边界 (大西洋区域示例) GEO_WEST -100.0 GEO_EAST 20.0 GEO_NORTH 60.0 GEO_SOUTH 10.0 # 屏幕边界 (PyPortal Titano 分辨率) SCREEN_WIDTH 320 SCREEN_HEIGHT 240 MAP_LEFT 0 # 地图在屏幕上的起始x坐标 MAP_TOP 0 # 地图在屏幕上的起始y坐标 def geo_to_screen(lat, lon): # 计算经度到x的映射 x MAP_LEFT (lon - GEO_WEST) * (SCREEN_WIDTH / (GEO_EAST - GEO_WEST)) # 计算纬度到y的映射注意纬度越高北屏幕y值越小上 y MAP_TOP (GEO_NORTH - lat) * (SCREEN_HEIGHT / (GEO_NORTH - GEO_SOUTH)) # 确保坐标在屏幕范围内 x max(MAP_LEFT, min(MAP_LEFT SCREEN_WIDTH - 1, int(x))) y max(MAP_TOP, min(MAP_TOP SCREEN_HEIGHT - 1, int(y))) return x, y这个函数接收纬度和经度返回对应的屏幕像素坐标。最后的max/min钳制操作是为了防止计算出的坐标超出屏幕范围这是一种重要的防御性编程。4.2 Sprite Sheet技术与图标高效管理在嵌入式设备上频繁地从存储设备如SD卡加载大量小图片每个风暴一个图标是低效的会严重影响刷新速度。标准的优化方法是使用精灵图Sprite Sheet——将多个小图标打包到一张大图中。我们的icons.bmp就是一张精灵图。假设我们有3种风暴图标热带低压、热带风暴、飓风每个图标16x16像素。那么我们可以将这三个图标垂直排列生成一张16像素宽、48像素高16*3的BMP图片。在CircuitPython的displayio库中我们使用OnDiskBitmap加载整张精灵图然后通过TileGrid对象来“窗口化”地显示其中的某一部分。import displayio from adafruit_imageload import load # 加载精灵图 icon_sheet, icon_palette load(/assets/icons.bmp) # 创建一个TileGrid指定从精灵图的哪个位置开始取图 # 参数: bitmap, pixel_shader, width, height, tile_width, tile_height, x, y icon_tilegrid displayio.TileGrid(icon_sheet, pixel_shadericon_palette, width1, height1, # 我们一次只显示一个“瓦片” tile_width16, tile_height16, default_tile0) # 默认显示第一个图标索引0关键在于default_tile参数和后续通过icon_tilegrid[0] tile_index来切换显示的图标。tile_index就是图标在精灵图中的序号从上到下0, 1, 2...。透明色处理为了让图标背景透明只显示风暴图形我们在制作精灵图时使用了一种在图标中不会出现的颜色比如亮黄色0xFFFF00作为背景。在代码中我们需要将这个颜色设置为透明色for i, color in enumerate(icon_palette): if color 0xFFFF00: # 找到亮黄色 icon_palette.make_transparent(i) break这样在显示时所有亮黄色的像素都会变成透明从而露出底层的地图。4.3 使用Displayio Group进行图形元素层级管理displayio库采用一种层级Group系统来管理屏幕上的所有对象。主display有一个根Group我们可以向其中添加多个子Group或直接添加TileGrid、Label等对象。Group的好处在于可以对一组相关的图形元素进行整体操作例如移动、隐藏或删除。在我们的风暴追踪器中每个风暴的视觉元素图标、名称标签、方向箭头在逻辑上是一个整体。因此为每个风暴创建一个独立的Group是明智的。# 为一场风暴创建图形组 storm_gfx displayio.Group(max_size3) # 预计容纳3个子元素图标、标签、箭头 storm_gfx.x screen_x # 设置组的坐标其所有子元素将相对此坐标定位 storm_gfx.y screen_y # 创建并添加图标 icon_tilegrid ... # 如上文创建并设置正确的tile_index storm_gfx.append(icon_tilegrid) # 创建并添加名称标签 from adafruit_display_text import label import terminalio name_label label.Label(terminalio.FONT, textstorm_name, color0xFFFFFF) name_label.x 10 # 相对于storm_gfx的偏移量 name_label.y -5 storm_gfx.append(name_label) # 创建并添加方向箭头假设是一个简单的线段或三角形TileGrid # ... 创建arrow_tilegrid ... # storm_gfx.append(arrow_tilegrid) # 最后将这个风暴组添加到主显示组中 main_group.append(storm_gfx)通过这种嵌套Group的方式当我们需要更新风暴位置时只需修改storm_gfx.x和storm_gfx.y其下的所有子元素图标、标签、箭头都会自动跟随移动管理起来非常清晰高效。在刷新数据时我们可以清空旧的storm_gfx组然后为新的风暴数据创建新的组从而实现动态更新。5. 系统集成与主循环逻辑实现5.1 主程序架构与初始化流程一个健壮的嵌入式应用需要有清晰的初始化阶段和稳定的主循环。我们的code.py结构大致如下import time import board import displayio from adafruit_pyportal import PyPortal # --- 1. 释放任何现有显示资源热重启时很重要--- displayio.release_displays() # --- 2. 初始化PyPortal对象 --- pyportal PyPortal( urlhttps://www.nhc.noaa.gov/CurrentStorms.json, json_path[activeStorms], status_neopixelboard.NEOPIXEL, default_bg/assets/map.bmp, ) # --- 3. 加载资源并创建图形对象 --- # 加载精灵图并设置透明色 icon_sheet, icon_palette load(/assets/icons.bmp) # ... 透明色处理代码 ... # 创建主显示组 main_group displayio.Group() # 将PyPortal的背景地图设置为根组的内容 # PyPortal内部已经将背景图加入了一个组我们可以直接获取 main_group.append(pyportal.splash) # pyportal.splash 是其根图形组 # 创建一个用于存放所有风暴图形组的列表 storm_graphics_list [] # --- 4. 辅助函数定义 --- def geo_to_screen(lat, lon): # ... 坐标转换函数实现 ... pass def create_storm_graphics(storm_info): # ... 根据一场风暴的信息创建其图形组 ... pass def update_display(storm_data): # ... 核心更新函数清除旧图形根据新数据创建新图形 ... pass # --- 5. 主循环 --- last_update_time 0 UPDATE_INTERVAL 300 # 每5分钟更新一次300秒 while True: now time.monotonic() if now - last_update_time UPDATE_INTERVAL: try: print(Fetching new storm data...) new_storm_data pyportal.fetch() update_display(new_storm_data) last_update_time now print(Update successful.) except Exception as e: print(Error during update:, e) # 短暂的延时防止代码空跑耗电 time.sleep(0.1)初始化流程的关键是顺序先释放显示再初始化硬件对象然后加载静态资源最后进入主循环。pyportal.splash是PyPortal库内部管理的根组我们把自己的main_group附加上去或者直接将风暴组添加到splash中都是可行的。5.2 数据更新与图形刷新策略主循环的核心是定时触发数据更新。我们使用time.monotonic()来获取一个稳定递增的时间戳避免系统时间调整带来的问题。UPDATE_INTERVAL定义了更新频率对于风暴追踪5到10分钟一次是合理的既不会错过重要动向又不会对NOAA服务器和自身电池如果是电池供电造成不必要的负担。update_display函数是连接数据和视图的桥梁def update_display(storm_data): # 1. 清除上一轮的所有风暴图形 for old_storm_gfx in storm_graphics_list: main_group.remove(old_storm_gfx) storm_graphics_list.clear() # 清空列表 if not storm_data: return # 如果没有数据直接返回 # 2. 遍历新数据为每场风暴创建图形 for storm in storm_data: lat storm.get(latitude) lon storm.get(longitude) name storm.get(name, UNNAMED) classification storm.get(classification, UNKNOWN) # 检查风暴是否在我们地图的显示范围内 if GEO_WEST lon GEO_EAST and GEO_SOUTH lat GEO_NORTH: screen_x, screen_y geo_to_screen(lat, lon) storm_gfx create_storm_graphics(name, classification, screen_x, screen_y) if storm_gfx: main_group.append(storm_gfx) storm_graphics_list.append(storm_gfx)这个策略是“全量更新”每次获取新数据后完全移除旧的所有风暴标记然后根据新数据全部重新绘制。这对于数据量不大的场景是简单有效的。更复杂的策略可以是“增量更新”只更新位置变化的风暴但这需要维护状态和进行比对代码更复杂。5.3 低功耗与稳定性考量虽然PyPortal连接了USB电源但考虑其作为物联网设备的通用性功耗和稳定性依然重要。网络连接管理PyPortal库在初始化时会尝试连接Wi-Fi。如果连接失败它可能会阻塞或重试。在生产代码中应该用try-except包裹初始化过程并实现重试逻辑甚至进入一个配置模式如通过触摸屏输入新密码。错误处理与恢复网络请求pyportal.fetch()可能因各种原因失败服务器错误、DNS解析失败、超时。必须用try-except捕获异常并记录或显示错误信息而不是让程序崩溃。可以在屏幕上显示“更新失败”的提示并在下一次循环中重试。内存管理每次更新创建新的Group和Label对象并移除旧对象。在CircuitPython中移除对象并从其父Group中pop()出来通常会使它被垃圾回收。但如果有内存泄漏的迹象可用内存持续下降可能需要更彻底地del对象并将其设为None。主循环延时while True循环末尾的time.sleep(0.1)非常关键。它让CPU在每次循环后有片刻空闲降低了整体功耗同时也避免了可能因代码执行过快而导致的某些硬件状态检测问题。对于电池供电项目在长时间无操作后甚至可以调用微控制器的深度睡眠模式。6. 调试技巧、优化与扩展方向6.1 串口输出调试与常见问题排查当项目不按预期工作时串口输出Print Debugging是最直接的调试手段。通过USB线连接PyPortal和电脑使用Mu Editor、VS Code的串口监视器或screen/putty等工具可以查看print()语句输出的信息。常见问题排查清单问题现象可能原因排查步骤屏幕白屏或花屏1. 显示初始化失败。2. 内存不足图形缓冲区错误。1. 检查displayio.release_displays()是否在程序开始执行。2. 简化初始图形确认是否是资源过大。Wi-Fi连接失败1.secrets.py配置错误。2. 网络信号弱或加密方式不支持。3. 路由器设置了MAC过滤等。1. 确认ssid和password字符串正确。2. 尝试连接其他简单网络。3. 查看串口输出的具体错误信息。fetch()返回None或报错1. 网络连接已断开。2. JSON数据源URL不可达或格式改变。3.json_path设置错误。1. 先尝试用pyportal.network._wifi.radio.connect测试连接。2. 用电脑浏览器访问数据URL确认数据格式。3. 打印pyportal.fetch()的原始响应需修改库或使用adafruit_requests直接请求。图标显示为纯色块1. 精灵图路径错误未成功加载。2. 透明色索引设置错误。3.TileGrid的tile_width/height与图片尺寸不匹配。1. 确认/assets/icons.bmp文件存在。2. 打印icon_palette的颜色列表确认亮黄色的索引号。3. 检查精灵图实际尺寸与代码中参数是否一致。风暴位置显示严重偏移1. 地理边界GEO_WEST等定义错误。2. 坐标转换公式中经纬度符号处理错误。3. 地图图片与实际地理区域不匹配。1. 用已知位置如某个城市的坐标测试转换函数。2. 在转换函数中打印中间计算结果。3. 确认背景地图map.bmp覆盖的区域与代码中定义的区域一致。程序运行一段时间后死机1. 内存泄漏如图形对象未正确移除。2. 网络异常导致未捕获的异常。3. 主循环无休眠CPU过热或看门狗触发。1. 定期打印gc.mem_free()查看内存变化。2. 用更广泛的try-except包裹主循环和更新函数。3. 确保主循环中有time.sleep()。6.2 性能优化与用户体验提升在基础功能实现后可以从以下几个方面优化局部刷新当前是全屏刷新每次更新都重建所有图形。displayio支持局部刷新但实现较复杂。一个折中方案是只更新位置和文本变化的风暴而不是全部删除重绘。这需要为每个风暴图形对象维护一个ID如风暴名称更新时进行比对。数据缓存与离线显示在无法获取网络数据时如Wi-Fi断开可以显示上一次成功获取的数据并加上“数据可能过时”的水印。这需要将解析后的数据用json模块序列化后写入到板载的文件系统中。触摸交互PyPortal的屏幕支持触摸。可以增加交互功能例如点击某个风暴图标在屏幕一侧显示更详细的信息最大风速、气压、预测路径等。这需要处理触摸事件并管理一个“详情显示”层。视觉增强动画效果让风暴图标轻微脉动或让移动方向箭头旋转能增加可视性。路径预测线如果JSON数据中包含预测路径点可以用线段将这些点连接起来绘制出预测路径。颜色编码根据风暴强度如风速使用不同颜色的图标或文字提供更直观的威胁等级提示。6.3 项目扩展与更多应用场景这个项目的框架具有很强的通用性远不止于风暴追踪。其核心模式是“从云端JSON API获取数据 - 解析关键信息 - 映射到屏幕坐标 - 用图形化元素展示”。你可以轻松地替换数据源和背景图打造不同的信息显示器。航班追踪器使用ADS-B或FlightAware的API获取特定空域内的飞机实时位置在地图上显示飞机图标和航班号。全球地震监测仪利用USGS美国地质调查局的JSON地震数据源在地球地图上实时显示震中位置和震级用圆圈大小表示。物联网仪表盘从你自己的服务器或IoT平台如Adafruit IO、ThingsBoard获取传感器数据温度、湿度、设备状态用自定义的仪表、图表或图标在屏幕上展示。公共交通到站显示器查询本地公交或地铁的实时到站信息如果提供JSON API在简约的线路图上显示车辆位置和预计到达时间。要实现这些扩展你需要寻找一个提供JSON格式数据的稳定API。分析其数据结构调整json_path和字段提取代码。准备一张对应的背景地图可以是世界地图、城市地图、地铁线路图等。设计一套代表不同数据状态的图标精灵图。调整坐标转换函数使其匹配新地图的地理或逻辑坐标范围。这个从数据到像素的管道一旦打通创造各种实时信息可视化应用就只剩下想象力和寻找数据源的功夫了。PyPortal这样的硬件正是将云端数据流转化为物理世界可见信息的一个绝佳桥梁。

相关新闻