<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="../assets/xml/rss.xsl" media="all"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>blog.tedder.dev (Posts about led)</title><link>https://blog.tedder.dev/</link><description></description><atom:link href="https://blog.tedder.dev/categories/led.xml" rel="self" type="application/rss+xml"></atom:link><language>en</language><copyright>Contents © 2026 &lt;a href="mailto:ted@tedder.me"&gt;tedder&lt;/a&gt; </copyright><lastBuildDate>Sun, 16 Aug 2026 21:37:26 GMT</lastBuildDate><generator>Nikola (getnikola.com)</generator><docs>http://blogs.law.harvard.edu/tech/rss</docs><item><title>Building a METAR aviation weather map on an ESP32</title><link>https://blog.tedder.dev/posts/metar-map-esp32/?utm_source=/categories/led.xml&amp;utm_medium=nikola_feed&amp;utm_campaign=rss_feed</link><dc:creator>tedder</dc:creator><description>&lt;p&gt;I fly, which means I look at weather constantly. METARs — aviation routine weather reports — tell you ceiling, visibility, and flight category at any reporting station. The colors VFR/MVFR/IFR/LIFR map directly to green/blue/red/magenta in most flight planning apps. A while ago I decided to make that visual and physical: an LED strip that stays on my desk and shows current flight conditions at airports along my usual routes.&lt;/p&gt;
&lt;p&gt;Here's how it evolved, and what I learned.&lt;/p&gt;
&lt;h3&gt;Hardware&lt;/h3&gt;
&lt;p&gt;The platform is an &lt;a href="https://www.adafruit.com/product/5325"&gt;Adafruit QT Py ESP32-S2&lt;/a&gt;. It's small, has native USB, runs CircuitPython or ESP-IDF, and has enough flash and RAM for a WiFi-connected LED controller. A WS2812B strip plugs into one of the analog pins; an SSD1306 OLED 128×32 hangs on I2C for status readout.&lt;/p&gt;
&lt;p&gt;The strip has one LED per airport. Right now I track a handful of stations — local fields and a couple of en-route ones. The OLED shows the last update time and a count of how many airports had valid METARs.&lt;/p&gt;
&lt;h3&gt;The backend: don't do METAR parsing on the microcontroller&lt;/h3&gt;
&lt;p&gt;Aviation weather APIs return JSON blobs or text, require TLS, and occasionally change schema. Parsing that on a microcontroller is painful — you burn flash on JSON libraries, you fight TLS certificate chains, and every API hiccup means you're debugging on a device with no debugger.&lt;/p&gt;
&lt;p&gt;My approach: offload all of that to a Lambda@Edge function. The microcontroller sends a single HTTP request with a list of airport codes and gets back a simple CSV:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;KPDX,VFR,0-127-0,8,0
KTTD,VFR,0-127-0,12,0
KHIO,MVFR,0-0-127,24,0
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Fields: ICAO code, flight category, RGB color tuple, age in minutes, stale flag. The microcontroller splits the line, applies the color directly. No parsing, no JSON, no TLS from the device side (Lambda handles it outbound). This architecture has been solid — the device just asks "what color are these airports" and gets an answer.&lt;/p&gt;
&lt;h3&gt;Flight categories to LED colors&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Color&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VFR&lt;/td&gt;
&lt;td&gt;&amp;gt; 3000ft ceiling, &amp;gt; 5sm vis&lt;/td&gt;
&lt;td&gt;Green (0, 127, 0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MVFR&lt;/td&gt;
&lt;td&gt;1000–3000ft or 3–5sm&lt;/td&gt;
&lt;td&gt;Blue (0, 0, 127)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IFR&lt;/td&gt;
&lt;td&gt;500–1000ft or 1–3sm&lt;/td&gt;
&lt;td&gt;Red (127, 0, 0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LIFR&lt;/td&gt;
&lt;td&gt;&amp;lt; 500ft or &amp;lt; 1sm&lt;/td&gt;
&lt;td&gt;Magenta (255, 22, 199)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The stale flag comes back set if the METAR is older than a threshold. I dim stale airports slightly to indicate "this data is old."&lt;/p&gt;
&lt;h3&gt;CircuitPython phase&lt;/h3&gt;
&lt;p&gt;The first version ran CircuitPython. CircuitPython is genuinely great for a project like this: the REPL is fast, you edit files over USB mass storage without any flashing ceremony, the Adafruit ecosystem has everything you need, and iteration is just save-file-watch-reload.&lt;/p&gt;
&lt;p&gt;It held up well through about a year and a dozen feature additions. I added a web server for live airport configuration, NTP sync, a debug flag bitmask stored in NVM (persistent across reboots), a watchdog timer, and structured logging.&lt;/p&gt;
&lt;p&gt;Then CircuitPython's networking started biting me.&lt;/p&gt;
&lt;h4&gt;The DNS problem&lt;/h4&gt;
&lt;p&gt;On certain builds of CircuitPython for the ESP32-S2, lwIP's DNS resolver would silently fail. The device appeared connected — it had an IP, it could ping — but &lt;code&gt;getaddrinfo()&lt;/code&gt; would hang or return errors on the first few calls after boot. The workaround I ended up with was a &lt;code&gt;SmartPool&lt;/code&gt; wrapper around &lt;code&gt;socketpool.SocketPool&lt;/code&gt; that intercepts &lt;code&gt;getaddrinfo()&lt;/code&gt; and falls back to raw UDP DNS against Cloudflare and Google if lwIP's resolver fails:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nc"&gt;SmartPool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="fm"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;getaddrinfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getaddrinfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="ne"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Raw UDP DNS fallback to 1.1.1.1, then 8.8.8.8&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_raw_dns_lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;All the Adafruit libraries (&lt;code&gt;adafruit_requests&lt;/code&gt;, &lt;code&gt;adafruit_ntp&lt;/code&gt;, etc.) receive &lt;code&gt;SmartPool&lt;/code&gt; instead of the raw pool, so the fallback is transparent. It worked — but it's the kind of thing that makes you wonder if you're approaching the floor of what CircuitPython's network stack can reliably support.&lt;/p&gt;
&lt;h4&gt;WiFi roaming detection&lt;/h4&gt;
&lt;p&gt;The device is always-on and needs to survive WiFi disconnects. CircuitPython doesn't give you a clean reconnect event — you discover the connection is dead when the next HTTP call throws. My workaround: catch the exception, set &lt;code&gt;requests = None&lt;/code&gt;, detect &lt;code&gt;None&lt;/code&gt; at the top of the fetch loop, reconnect, reinitialize the session. Works, but fragile.&lt;/p&gt;
&lt;h3&gt;Production reliability: debug flags and watchdog&lt;/h3&gt;
&lt;p&gt;One pattern I've carried across all my embedded projects: a 32-bit debug bitmask stored in persistent memory, and a hardware watchdog.&lt;/p&gt;
&lt;p&gt;The bitmask lets me POST the device's health history to a monitoring endpoint. Each bit tracks a category of failure — WiFi connect failures, HTTP exceptions, NTP failures, NVM write failures. Even if the device reboots and the exception is gone, the bit stays set until explicitly cleared. When I look at the debug page later, I can see "WiFi connect failed 3+ times" even if the device is healthy now.&lt;/p&gt;
&lt;p&gt;The watchdog is set to 60 minutes (this is an always-on device, not deep-sleep). It gets fed in the WiFi check, the METAR fetch, and the main loop. If anything hangs — which on CircuitPython occasionally happens with &lt;code&gt;adafruit_requests&lt;/code&gt; on flaky connections — the device reboots rather than sitting stuck forever.&lt;/p&gt;
&lt;h3&gt;ESP-IDF rewrite&lt;/h3&gt;
&lt;p&gt;The CircuitPython workarounds accumulated enough that I started an ESP-IDF C rewrite. The motivating factors:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ESP-IDF WiFi is reliable in ways CircuitPython's isn't on this chip. No SmartPool, no &lt;code&gt;requests = None&lt;/code&gt; dance, no lwIP DNS workarounds.&lt;/li&gt;
&lt;li&gt;OTA updates. CircuitPython devices update by copying a file over USB. ESP-IDF devices can pull firmware from an HTTPS endpoint and self-update. For a device that sits on my desk, this matters less — but it's a pattern I use across all my ESP32 projects now.&lt;/li&gt;
&lt;li&gt;Performance. The OLED rendering in CircuitPython is noticeably slow at higher refresh rates. In C it's instant.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The backend CSV format and airport list format stay identical. The behavior from outside — what the LEDs show, what the debug page returns — is the same. The rewrite is a platform swap, not a feature change.&lt;/p&gt;
&lt;h3&gt;What I'd build differently&lt;/h3&gt;
&lt;p&gt;The Lambda@Edge backend was the best decision I made early. Keeping the wire protocol simple (plain HTTP, CSV responses) meant the firmware stayed simple. Anytime the METAR API changed, I updated the Lambda — the device didn't know anything changed.&lt;/p&gt;
&lt;p&gt;If I were starting over, I'd skip CircuitPython for a project that's always-on and WiFi-dependent. CircuitPython is excellent for sensors, USB gadgets, interactive prototyping. For a device that needs to run unattended for months on a network connection, ESP-IDF's networking stack is just more reliable.&lt;/p&gt;
&lt;p&gt;The debug flag pattern is worth copying to any embedded project. Persistent error history — even across reboots — is invaluable when you're trying to figure out why a device that's been running for a week started acting up.&lt;/p&gt;</description><category>aviation</category><category>circuitpython</category><category>esp32</category><category>hardware</category><category>led</category><category>python</category><guid>https://blog.tedder.dev/posts/metar-map-esp32/</guid><pubDate>Sun, 16 Aug 2026 16:00:00 GMT</pubDate></item></channel></rss>