Skip to content
Go back
Building a Weather Display on the PyPortal Titano

Building a Weather Display on the PyPortal Titano

By Michael Earls

Edit page

I recently built a weather display for my Adafruit PyPortal Titano using CircuitPython.
This project fetches live weather data from OpenWeatherMap and displays the current temperature, condition, and details like “feels like” temperature and wind.

In this blog entry, I’ll walk through the CircuitPython code, how I structured the display, and how I added a simple degree symbol workaround.


1. Wi-Fi and OpenWeatherMap Setup

We start with the secrets.py file that stores your Wi-Fi credentials and API key for OpenWeatherMap.

secrets = {
    "ssid": "YOUR_SSID",
    "password": "YOUR_WIFI_PASSWORD",
    "owm_api_key": "YOUR_OWM_API_KEY",
    # Pick one of these pairs (lat/lon OR city name)
    "lat": 32.3792,
    "lon": -86.3077,
    # Or:
    # "city": "Montgomery,US"
}
try:
    from secrets import secrets
except Exception:
    raise RuntimeError("Create secrets.py with ssid/password/owm_api_key + city or lat/lon")

This ensures that your Wi-Fi SSID, password, and OWM API key are provided before the script runs.

To get started with OpenWeatherMap, create a free account at openweathermap.org.
Once logged in, go to your API keys page and copy the default key (or generate a new one).
Paste that value into the owm_api_key field in your secrets.py file. It may take up to an hour for a new API key to activate.


2. Connecting to Wi-Fi

Using the onboard ESP32 co-processor, we connect to Wi-Fi:

def wifi_connect():
    if getattr(esp, "is_connected", False) and esp.is_connected:
        return True
    ssid = secrets["ssid"]
    pwd = secrets["password"]
    for attempt in range(1, WIFI_RETRIES+1):
        try:
            hud_status(f"WiFi… ({attempt}/{WIFI_RETRIES})")
            esp.connect_AP(ssid.encode(), pwd.encode())
            if esp.is_connected:
                hud_status("WiFi OK")
                return True
        except Exception:
            time.sleep(1)
    hud_status("WiFi failed")
    return False

This function attempts to connect multiple times and shows a status message on the display.


3. Fetching Weather Data

We query the OpenWeatherMap API every 30 minutes, with fallbacks in case HTTPS fails.

def fetch_weather():
    url = owm_url()
    try:
        r = http.get(url, timeout=REQUEST_TIMEOUT_S)
        j = r.json()
        return {
            "_ok": True,
            "main": j["weather"][0]["main"],
            "description": j["weather"][0]["description"],
            "icon": j["weather"][0]["icon"],
            "temp": float(j["main"]["temp"]),
            "feels_like": float(j["main"]["feels_like"]),
            "city": j.get("name", "")
        }
    except Exception:
        return {"_ok": False}

The result is a dictionary that we later use to update the UI.


4. Drawing Weather Icons

If OpenWeatherMap icons aren’t available locally, we generate simple weather icons directly in code. For example, here’s the drawing logic for “Clear” weather (a sun or moon).

def draw_condition_icon(main_condition, is_night=False):
    _clear_icon()
    midx = ICON_W // 2
    midy = ICON_H // 2
    radius = min(ICON_W, ICON_H) // 5

    mc = (main_condition or "").lower()

    if "cloud" in mc:
        _draw_filled_circle(midx - radius//2, midy, radius + 6)
        _draw_filled_circle(midx + radius//3, midy + 4, radius)
    else:
        if is_night:
            # Crescent moon: big circle minus offset circle
            r = radius + 4
            _draw_filled_circle(midx, midy, r)
            _erase_filled_circle(midx + int(r*0.6), midy - int(r*0.1), r)
        else:
            # Sun with rays
            _draw_filled_circle(midx, midy, radius)
            for a in range(0, 360, 30):
                rad = math.radians(a)
                x0 = int(midx + math.cos(rad) * (radius + 10))
                y0 = int(midy + math.sin(rad) * (radius + 10))
                x1 = int(midx + math.cos(rad) * (radius + 35))
                y1 = int(midy + math.sin(rad) * (radius + 35))
                _draw_line(x0, y0, x1, y1)

This ensures the display always has a visual indicator, even without downloaded icons.


4.1 Downloading Official OpenWeatherMap Icons (Desktop)

The PyPortal can load the official OWM icon PNGs from your SD card or internal flash. The simplest workflow is:

  1. Create a folder named icons in the root of your SD card (or at the root of CIRCUITPY if you are not using SD):
/sd/icons

or

/icons
  1. On your Mac (or any desktop with Python 3.10+), run the helper script below to download the complete icon set to a local folder, then copy that folder onto the SD card.

fetch_owm_icons.py

This script downloads both 1× and 2× PNGs (the @2x files are higher resolution) for the standard OpenWeatherMap codes:

Tip: If you only plan to use 1× icons, set DOWNLOAD_2X = False inside the script.

#!/usr/bin/env python3
"""
fetch_owm_icons.py
Downloads OpenWeatherMap icon PNGs to a local folder.
Usage:
  python3 fetch_owm_icons.py --out ./icons --two-x
"""
import argparse
import os
import sys
import time
import urllib.request

ICON_CODES = [
    "01d","01n","02d","02n","03d","03n","04d","04n",
    "09d","09n","10d","10n","11d","11n","13d","13n","50d","50n",
]

BASE = "https://openweathermap.org/img/wn"

def fetch(code: str, out_dir: str, two_x: bool) -> None:
    names = [f"{code}.png"]
    if two_x:
        names.append(f"{code}@2x.png")
    os.makedirs(out_dir, exist_ok=True)
    for name in names:
        url = f"{BASE}/{name}"
        dst = os.path.join(out_dir, name.replace("@2x",""))
        # Store @2x as normal name if desired, else keep a -2x suffix
        if two_x and name.endswith("@2x.png"):
            dst = os.path.join(out_dir, f"{code}.png")  # overwrite with the higher-res file
        print(f"GET {url} -> {dst}")
        with urllib.request.urlopen(url, timeout=20) as resp, open(dst, "wb") as f:
            f.write(resp.read())
        time.sleep(0.2)

def main(argv=None):
    p = argparse.ArgumentParser()
    p.add_argument("--out", default="./icons", help="Output directory")
    p.add_argument("--two-x", action="store_true", help="Prefer @2x (overwrite base file)")
    args = p.parse_args(argv)
    for code in ICON_CODES:
        fetch(code, args.out, args.two_x)
    print("Done. Icons in:", os.path.abspath(args.out))

if __name__ == "__main__":
    sys.exit(main())

Run it:

python3 fetch_owm_icons.py --out ./icons --two-x

This puts *.png files into ./icons. Copy that folder onto your SD card so the device can read them from /sd/icons (the code falls back to /icons if no SD).


4.2 Flattening Icons for PyPortal (Desktop)

Many PNGs contain an alpha channel (transparency). If you prefer a solid background (to avoid edge artifacts on some palettes), flatten the icons to a chosen color.

flatten_icons.py

The script below flattens every PNG in a folder to a solid background color (default: black #000000) and optionally lightens the mid-grays in “night” icons to make them more visible.

#!/usr/bin/env python3
"""
flatten_icons.py
Flattens PNGs by removing alpha and placing them on a solid background.
Usage:
  python3 flatten_icons.py --src ./icons --dst ./icons_flat --bg 000000 --lighten-night
"""
import argparse
import os
from PIL import Image, ImageEnhance

NIGHT_CODES = {"01n","02n","03n","04n","09n","10n","11n","13n","50n"}

def flatten_png(src_path: str, dst_path: str, bg_rgb=(0,0,0), lighten=False):
    im = Image.open(src_path).convert("RGBA")
    bg = Image.new("RGBA", im.size, (*bg_rgb, 255))
    out = Image.alpha_composite(bg, im).convert("RGB")  # drop alpha
    # Optionally lighten midtones to make moon/clouds pop on dark UIs
    if lighten:
        enh = ImageEnhance.Brightness(out)
        out = enh.enhance(1.15)
    os.makedirs(os.path.dirname(dst_path), exist_ok=True)
    out.save(dst_path, format="PNG", optimize=True)

def hex_to_rgb(s: str):
    s = s.strip().lstrip("#")
    if len(s) == 3:
        s = "".join(ch*2 for ch in s)
    if len(s) != 6:
        raise ValueError("bg must be RRGGBB")
    return tuple(int(s[i:i+2], 16) for i in (0,2,4))

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--src", required=True, help="Source folder with PNGs")
    ap.add_argument("--dst", required=True, help="Destination folder for flattened PNGs")
    ap.add_argument("--bg", default="000000", help="Background color hex, e.g., 000000 or 120022")
    ap.add_argument("--lighten-night", action="store_true", help="Lighten night icons a bit")
    args = ap.parse_args()

    bg = hex_to_rgb(args.bg)
    for name in os.listdir(args.src):
        if not name.lower().endswith(".png"):
            continue
        code = name.split(".")[0]
        src = os.path.join(args.src, name)
        dst = os.path.join(args.dst, name)
        lighten = args.lighten_night and (code in NIGHT_CODES)
        flatten_png(src, dst, bg_rgb=bg, lighten=lighten)
        print("flattened:", name)
    print("Done. Output in:", os.path.abspath(args.dst))

if __name__ == "__main__":
    main()

Run it:

# Example: flatten to near-black and lighten night icons
python3 flatten_icons.py --src ./icons --dst ./icons_flat --bg 000000 --lighten-night

Copy the resulting ./icons_flat to the SD card as /sd/icons (or to the device root as /icons). The firmware will try /sd/icons first and fall back to /icons.

Note: If you’re tight on storage, you can keep just the 1× set (about ~20 small PNGs). The code scales them to fit the icon pane at runtime.


5. Displaying Weather Data with a Degree Symbol

One challenge is that the built-in font doesn’t include the Unicode degree symbol. I solved this by adding a raised lower-case “o” as the degree mark.

def _update_temp_display(t):
    if t is None:
        temp_label.text = "--"
        degree_label.text = ""
        unit_label.text = ""
        return
    num = "{:.0f}".format(t)
    temp_label.text = num
    degree_label.text = "o"  # raised small 'o'
    degree_label.scale = 2
    degree_label.x = temp_label.x + len(num) * 30
    degree_label.y = temp_label.y - 12
    unit_label.text = "F"
    unit_label.scale = 3
    unit_label.x = degree_label.x + 20
    unit_label.y = temp_label.y

The temperature now displays as 72 oF with the “o” nicely raised above the baseline.


6. Wrapping Up

The end result is a working weather station that fetches live weather data, displays it clearly on the PyPortal Titano, and works offline with cached icons.

I plan to add more detail lines (like pressure and humidity) in the future.


Full Source Code

import time, math, gc
import board, displayio, busio
from digitalio import DigitalInOut
import terminalio
from adafruit_display_text import label
from adafruit_display_text import bitmap_label
from adafruit_esp32spi import adafruit_esp32spi
# ESP32SPI SocketPool (CP 10.x bundle)
try:
    from adafruit_esp32spi import adafruit_esp32spi_socketpool as socketpool
except ImportError:
    import adafruit_esp32spi_socketpool as socketpool  # fallback if it's top-level in /lib
import adafruit_requests
import os
import io
import sdcardio
import storage
import adafruit_imageload

try:
    from secrets import secrets
except Exception:
    raise RuntimeError("Create secrets.py with ssid/password/owm_api_key + city or lat/lon")

WIFI_RETRIES = 8
WIFI_CONNECT_TIMEOUT_S = 20
REQUEST_TIMEOUT_S = 25
UNITS = "imperial"                 # use "metric" or "imperial"
# terminalio.FONT does not include the Unicode degree symbol; use ASCII-safe unit text
TEMP_SYMBOL = "F" if UNITS == "imperial" else "C"
WIND_SYMBOL = "mph" if UNITS == "imperial" else "m/s"

WEATHER_REFRESH_S = 1800  # 30 minutes
LAST_HTTP_STATUS = ""  # debug: last fetch status for HUD
OWM_SCHEME = "https"  # use "http" to avoid TLS issues on embedded; change to "https" if your SSL works
ICON_SCHEME = "https"   # force HTTPS for icon CDN to avoid truncated/redirected PNGs
LAST_HTTP_MSG = ""   # brief error message from API when available
DEBUG_SERIAL = False  # set True to re-enable serial debug prints

ICON_CACHE_DIR = "/icons"   # local cache for OWM icons
OWM_ICON_SCALE = 2          # default scale factor for 100x100 icons
ICONS_OFFLINE_ONLY = True   # do not download icons; use SD card /icons only

ICON_FS_ROOT = "/"  # will switch to "/sd" if SD mounts

def mount_sd():
    """Try to mount the onboard microSD to /sd and switch icon cache there.
    Attempts several SPI/CS combinations used across PyPortal variants.
    """
    global ICON_FS_ROOT, ICON_CACHE_DIR

    def _try(sd_bus, cs_pin):
        try:
            # sdcardio.SDCard expects a Pin for CS (not a DigitalInOut) on newer bundles
            sd = sdcardio.SDCard(sd_bus, cs_pin)
            vfs = storage.VfsFat(sd)
            storage.mount(vfs, "/sd")
            return True
        except Exception as e:
            _dbg("SD attempt failed:", sd_bus, cs_pin, e)
            try:
                storage.umount("/sd")
            except Exception:
                pass
            return False

    # Preferred: dedicated SD SPI bus if exposed by the board
    buses = []
    try:
        buses.append(board.SD_SPI())
    except Exception:
        pass
    # Fallback: share main SPI
    buses.append(spi)

    cs_candidates = []
    for name in ("SD_CS", "SD_CARD_CS"):
        try:
            cs_candidates.append(getattr(board, name))
        except Exception:
            pass

    for sd_bus in buses:
        for cs_pin in cs_candidates:
            if _try(sd_bus, cs_pin):
                ICON_FS_ROOT = "/sd"
                ICON_CACHE_DIR = "/sd/icons"
                _dbg("SD mounted at /sd; using /sd/icons for cache")
                return True

    ICON_FS_ROOT = "/"
    ICON_CACHE_DIR = "/icons"
    _dbg("SD mount failed; using internal /icons")
    return False

# Minimum free space (in bytes) required before caching icons
ICON_MIN_FREE = 50 * 1024    # require at least 50KB free before caching
ICON_TRY_SMALL_FIRST = False # set True to prefer 1x icons if space is tight

def _fs_free_bytes():
    root = ICON_FS_ROOT or "/"
    try:
        v = os.statvfs(root)
        return v[0] * v[3]  # f_bsize * f_bavail
    except Exception:
        return 0

def hud_status(msg):
    try:
        hud.text = msg
    except NameError:
        pass

def _dbg(*a):
    try:
        if DEBUG_SERIAL:
            print(*a)
    except Exception:
        pass

# ---- Filesystem helpers and icon download/load utilities ----
def _ensure_dir(path):
    # Create nested directories as needed (best-effort)
    parts = path.strip("/").split("/")
    cur = ""
    for p in parts:
        if not p:
            continue
        cur += "/" + p
        try:
            os.listdir(cur)
        except OSError:
            try:
                os.mkdir(cur)
            except Exception:
                pass

def _icon_url(code, two_x=True):
    suffix = "@2x" if two_x else ""
    return f"{ICON_SCHEME}://openweathermap.org/img/wn/{code}{suffix}.png"

# PNG signature validator for icon fetches
def _is_png_bytes(data):
    try:
        return isinstance(data, (bytes, bytearray)) and data[:8] == b"\x89PNG\r\n\x1a\n"
    except Exception:
        return False

def _download_icon(code):
    """Disabled: offline-only mode."""
    return None


# Helper to get file size for logging
def _file_size(path):
    try:
        return os.stat(path)[6]
    except Exception:
        return -1

def _load_icon_tilegrid(path):
    try:
        sz = _file_size(path)
        if sz >= 0 and sz < 512:
            _dbg("Icon cache tiny, deleting:", path, "size:", sz)
            try:
                os.remove(path)
            except Exception:
                pass
            return (None, 0, 0)
    except Exception:
        pass
    """Load PNG into a TileGrid using imageload; return (group, width, height) or (None, 0, 0) on failure."""
    # Try path-based load first (more reliable across imageload versions)
    try:
        bmp, pal = adafruit_imageload.load(path, bitmap=displayio.Bitmap, palette=displayio.Palette)
        tg = displayio.TileGrid(bmp, pixel_shader=pal)
    except Exception as e1:
        _dbg("Icon load err (path)", path, "size:", _file_size(path))
        # Fallback: open file and pass file object
        try:
            with open(path, "rb") as f:
                bmp, pal = adafruit_imageload.load(f, bitmap=displayio.Bitmap, palette=displayio.Palette)
            tg = displayio.TileGrid(bmp, pixel_shader=pal)
        except Exception as e2:
            _dbg("Icon load err (fileobj)")
            return (None, 0, 0)
    # Honor PNG transparency if imageload provided a transparent index on the palette
    try:
        if hasattr(pal, "transparent_color") and pal.transparent_color is not None:
            try:
                pal.make_transparent(pal.transparent_color)
                _dbg("Icon palette transparency set to index:", pal.transparent_color)
            except Exception:
                pass
    except Exception:
        pass
    # If no transparent index was provided, try to infer it from border pixels
    try:
        has_declared = (hasattr(pal, "transparent_color") and (pal.transparent_color is not None))
        if not has_declared:
            bw, bh = bmp.width, bmp.height
            samples = [
                (0, 0), (bw - 1, 0), (0, bh - 1), (bw - 1, bh - 1),
                (bw // 2, 0), (0, bh // 2), (bw - 1, bh // 2), (bw // 2, bh - 1)
            ]
            idxs = []
            for x, y in samples:
                try:
                    idxs.append(bmp[x, y])
                except Exception:
                    pass
            if idxs:
                counts = {}
                for i in idxs:
                    counts[i] = counts.get(i, 0) + 1
                cand = None
                # choose most frequent index from samples as background candidate
                for k in counts:
                    if (cand is None) or (counts[k] > counts[cand]):
                        cand = k
                try:
                    pal.make_transparent(cand)
                    _dbg("Icon transparency inferred index:", cand)
                except Exception:
                    pass
    except Exception:
        pass
    # Debug loaded size and reject tiny/bogus images (e.g., placeholders)
    try:
        _dbg("Icon size:", bmp.width, "x", bmp.height)
    except Exception:
        pass
    if bmp.width < 20 or bmp.height < 20:
        _dbg("Icon too small; ignoring")
        return (None, 0, 0)
    # Compute scale to fit within left pane
    bw, bh = bmp.width, bmp.height
    if bw <= 0 or bh <= 0:
        return (None, 0, 0)
    scale = max(1, min(ICON_W // bw, ICON_H // bh))
    x = max(0, (ICON_W - bw*scale)//2)
    y = max(0, (ICON_H - bh*scale)//2)
    g = displayio.Group(x=0, y=0)
    sg = displayio.Group(scale=scale, x=x, y=y)
    sg.append(tg)
    g.append(sg)
    return (g, bw*scale, bh*scale)

def show_official_icon(icon_code):
    """Show OWM icon only from SD/internal cache. No network downloads."""
    if not icon_code:
        return False
    # Ensure SD is preferred, but use whatever ICON_CACHE_DIR points to
    path = f"{ICON_CACHE_DIR}/{icon_code}.png"
    # Try to load from file system only
    g, w, h = _load_icon_tilegrid(path)
    if g:
        try:
            while len(icon_group):
                icon_group.pop()
            icon_group.append(g)
            gc.collect()
            _dbg("Icon loaded offline:", path)
            return True
        except Exception:
            return False
    _dbg("Icon missing/invalid or too small:", path)
    return False

# ---------- WiFi ----------
spi = busio.SPI(board.SCK, board.MOSI, board.MISO)
try:
    # Prefer the board-defined SD SPI bus (PyPortal Titano exposes this)
    sd_spi = board.SD_SPI()
except AttributeError:
    # Fallback: share the main SPI bus if SD-specific bus is not defined
    sd_spi = spi
esp32_cs    = DigitalInOut(board.ESP_CS)
esp32_ready = DigitalInOut(board.ESP_BUSY)
esp32_reset = DigitalInOut(board.ESP_RESET)
esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset)
# Build SocketPool + (optional) SSL context for Requests Session
try:
    import ssl
    _ssl = ssl.create_default_context()
except Exception:
    _ssl = None
pool = socketpool.SocketPool(esp)
http = adafruit_requests.Session(pool, _ssl)

def wifi_connect():
    # Quick exit if already connected
    if getattr(esp, "is_connected", False) and esp.is_connected:
        return True
    ssid = secrets["ssid"]
    pwd = secrets["password"]
    # Some stacks are happier with bytes
    try:
        ssid_b = ssid if isinstance(ssid, (bytes, bytearray)) else ssid.encode()
        pwd_b  = pwd  if isinstance(pwd,  (bytes, bytearray)) else pwd.encode()
    except Exception:
        ssid_b, pwd_b = ssid, pwd

    for attempt in range(1, WIFI_RETRIES+1):
        try:
            hud_status(f"WiFi… ({attempt}/{WIFI_RETRIES})")
            esp.connect_AP(ssid_b, pwd_b)
            # Wait for DHCP or timeout
            t0 = time.monotonic()
            while not esp.is_connected and (time.monotonic() - t0) < WIFI_CONNECT_TIMEOUT_S:
                time.sleep(0.25)
            if esp.is_connected:
                # Optional: quick ping to prime DNS/route
                try:
                    esp.ping("1.1.1.1")
                except Exception:
                    pass
                hud_status("WiFi OK")
                return True
        except Exception:
            time.sleep(1)
    hud_status("WiFi failed")
    return False

def owm_url():
    api = secrets["owm_api_key"]
    base = f"{OWM_SCHEME}://api.openweathermap.org/data/2.5/weather"
    if "city" in secrets:
        url = f"{base}?q={secrets['city']}&units={UNITS}&appid={api}"
    else:
        url = f"{base}?lat={secrets['lat']}&lon={secrets['lon']}&units={UNITS}&appid={api}"
    try:
        safe = url.split("appid=")[0] + "appid=***"
        _dbg("OWM:", safe)
    except Exception:
        pass
    return url

def fetch_weather():
    global LAST_HTTP_STATUS, LAST_HTTP_MSG
    # Ensure Wi-Fi connection
    if not (getattr(esp, "is_connected", False) and esp.is_connected):
        if not wifi_connect():
            return {"_ok": False, "main":"", "description":"", "icon":"", "temp": None, "feels_like": None,
                    "temp_min": None, "temp_max": None, "humidity": None, "pressure": None,
                    "wind_speed": None, "wind_deg": None, "city":""}

    url_https = owm_url()  # builds with OWM_SCHEME
    url_http  = url_https.replace("https://", "http://")
    urls_to_try = [url_https, url_http] if url_https.startswith("https://") else [url_http]

    backoff = 0.5
    for _ in range(3):
        for _url in urls_to_try:
            try:
                hud_status("Fetching weather…")
                r = http.get(_url, timeout=REQUEST_TIMEOUT_S)
                _dbg("GET sent")
                LAST_HTTP_STATUS = "REQ"
                try:
                    status = getattr(r, "status_code", 200)
                except Exception:
                    status = 200
                LAST_HTTP_STATUS = f"HTTP {status}"
                if status != 200:
                    try:
                        body = r.text
                    except Exception:
                        body = "(no body)"
                    try:
                        r.close()
                    except Exception:
                        pass
                    LAST_HTTP_MSG = body[:60] if isinstance(body, str) else str(body)
                    _dbg("HTTP", status, LAST_HTTP_MSG)
                    raise RuntimeError(f"HTTP {status}")

                j = r.json()
                try:
                    r.close()
                except Exception:
                    pass

                w0 = j["weather"][0] if j.get("weather") else {}
                main = w0.get("main", "")
                desc = w0.get("description", "")
                icon = w0.get("icon", "")
                main_block = j.get("main", {})
                wind_block = j.get("wind", {})
                out = {
                    "_ok": True,
                    "id": w0.get("id"),
                    "main": main,
                    "description": desc,
                    "icon": icon,
                    "temp": float(main_block.get("temp")) if "temp" in main_block else None,
                    "feels_like": float(main_block.get("feels_like")) if "feels_like" in main_block else None,
                    "temp_min": float(main_block.get("temp_min")) if "temp_min" in main_block else None,
                    "temp_max": float(main_block.get("temp_max")) if "temp_max" in main_block else None,
                    "pressure": int(main_block.get("pressure")) if "pressure" in main_block else None,
                    "humidity": int(main_block.get("humidity")) if "humidity" in main_block else None,
                    "wind_speed": float(wind_block.get("speed")) if "speed" in wind_block else None,
                    "wind_deg": int(wind_block.get("deg")) if "deg" in wind_block else None,
                    "city": j.get("name", "")
                }
                LAST_HTTP_STATUS = "OK"
                refresh_status_dot()
                LAST_HTTP_MSG = "OK"
                _dbg("OK:", out.get("city",""), out.get("temp"), out.get("main"))
                hud_status("")
                return out
            except Exception:
                LAST_HTTP_STATUS = "ERR"
                refresh_status_dot()
                _dbg("ERR during fetch")
                # Try the next URL (fallback from https→http)
                pass
        time.sleep(backoff)
        backoff = min(4.0, backoff * 2)

    # Final fallback (API failed). Do not provide fake temp; mark as not OK.
    LAST_HTTP_STATUS = "FAIL"
    refresh_status_dot()
    LAST_HTTP_MSG = "no response"
    return {"_ok": False, "main":"", "description":"", "icon":"", "temp": None, "feels_like": None,
            "temp_min": None, "temp_max": None, "humidity": None, "pressure": None,
            "wind_speed": None, "wind_deg": None, "city": ""}

# ---------- Display ----------
display = board.DISPLAY
W, H = display.width, display.height  # 480x320 on Titano
display.auto_refresh = True  # ensure updates are pushed automatically

root = displayio.Group()
display.root_group = root

# Solid background (bottom-most) so screen is never pure black
bg_bm = displayio.Bitmap(W, H, 1)
bg_pal = displayio.Palette(1)
bg_pal[0] = 0x120022  # deep purple
bg_tg = displayio.TileGrid(bg_bm, pixel_shader=bg_pal)
root.append(bg_tg)

hud = label.Label(terminalio.FONT, text="", color=0xFFFFFF, x=6, y=14)
root.append(hud)

# ---------- Status Dot (API state) ----------
STATUS_SIZE = 10
status_bm = displayio.Bitmap(STATUS_SIZE, STATUS_SIZE, 2)
status_pal = displayio.Palette(2)
status_pal.make_transparent(0)  # index 0 = transparent
status_pal[1] = 0x777777        # default: gray
status_tg = displayio.TileGrid(status_bm, pixel_shader=status_pal, x=W-STATUS_SIZE-4, y=2)
root.append(status_tg)

def _fill_status():
    for y in range(STATUS_SIZE):
        for x in range(STATUS_SIZE):
            status_bm[x, y] = 1  # solid square

_fill_status()

# Map LAST_HTTP_STATUS → color
STATUS_COLORS = {
    "OK": 0x00D16F,     # green
    "REQ": 0xFFD21F,    # yellow (in-flight)
    "ERR": 0xFF3B30,    # red
    "FAIL": 0xFF3B30,   # red
}

def refresh_status_dot():
    s = LAST_HTTP_STATUS or ""
    color = STATUS_COLORS.get(s, 0x777777)  # default gray
    try:
        status_pal[1] = color
    except Exception:
        pass

# ---------- Icon + Temperature UI ----------
# Left side: condition icon drawn into a bitmap. Right side: big temperature + condition text.
ICON_W = int(W * 0.48)
ICON_H = H
ICON_PAL = displayio.Palette(2)  # 0 transparent, 1 foreground
ICON_PAL.make_transparent(0)
ICON_PAL[1] = 0xFFFFFF  # white foreground for procedural fallback; official PNG icons keep original colors

icon_bitmap = displayio.Bitmap(ICON_W, ICON_H, 2)
icon_tg = displayio.TileGrid(icon_bitmap, pixel_shader=ICON_PAL, x=0, y=0)
icon_group = displayio.Group()
# icon_group.append(icon_tg)
root.append(icon_group)

temp_label = bitmap_label.Label(terminalio.FONT, text="", color=0xFFFFFF, x=int(W*0.52), y=int(H*0.55), scale=5)
cond_label = bitmap_label.Label(terminalio.FONT, text="", color=0xBBBBFF, x=int(W*0.52), y=int(H*0.80), scale=2)
root.append(temp_label)
root.append(cond_label)
# Raised degree 'o' and unit label (rendered separately so we can offset/scale)
degree_label = bitmap_label.Label(terminalio.FONT, text="", color=0xFFFFFF, x=0, y=0, scale=2)
unit_label   = bitmap_label.Label(terminalio.FONT, text="", color=0xFFFFFF, x=0, y=0, scale=3)
root.append(degree_label)
root.append(unit_label)

# Right-side line 2: feels-like + wind
sub_label = bitmap_label.Label(terminalio.FONT, text="", color=0xCCCCFF, x=int(W*0.52), y=int(H*0.92), scale=1)
root.append(sub_label)

# --- Icon drawing helpers ---

def _plot(x, y):
    if 0 <= x < ICON_W and 0 <= y < ICON_H:
        icon_bitmap[x, y] = 1

def _draw_filled_circle(cx, cy, r):
    r2 = r * r
    y0 = max(0, cy - r)
    y1 = min(ICON_H - 1, cy + r)
    for y in range(y0, y1 + 1):
        dy = y - cy
        dx_max_sq = r2 - dy*dy
        if dx_max_sq < 0:
            continue
        dx = int((dx_max_sq) ** 0.5)
        x0 = max(0, cx - dx)
        x1 = min(ICON_W - 1, cx + dx)
        for x in range(x0, x1 + 1):
            icon_bitmap[x, y] = 1

def _erase_filled_circle(cx, cy, r):
    r2 = r * r
    y0 = max(0, cy - r)
    y1 = min(ICON_H - 1, cy + r)
    for y in range(y0, y1 + 1):
        dy = y - cy
        dx_max_sq = r2 - dy*dy
        if dx_max_sq < 0:
            continue
        dx = int((dx_max_sq) ** 0.5)
        x0 = max(0, cx - dx)
        x1 = min(ICON_W - 1, cx + dx)
        for x in range(x0, x1 + 1):
            icon_bitmap[x, y] = 0

def _draw_line(x0, y0, x1, y1):
    dx = abs(x1 - x0)
    dy = -abs(y1 - y0)
    sx = 1 if x0 < x1 else -1
    sy = 1 if y0 < y1 else -1
    err = dx + dy
    while True:
        _plot(x0, y0)
        if x0 == x1 and y0 == y1:
            break
        e2 = 2 * err
        if e2 >= dy:
            err += dy
            x0 += sx
        if e2 <= dx:
            err += dx
            y0 += sy

def _clear_icon():
    for y in range(ICON_H):
        for x in range(ICON_W):
            icon_bitmap[x, y] = 0

def draw_condition_icon(main_condition, is_night=False):
    """
    Draw a simple icon for the given condition into icon_bitmap.
    Supported: Clear, Clouds, Rain/Drizzle, Snow, Thunderstorm, Mist/Fog/Haze/Smoke/Dust.
    """
    _clear_icon()
    midx = ICON_W // 2
    midy = ICON_H // 2
    radius = min(ICON_W, ICON_H) // 5

    mc = (main_condition or "").lower()

    if "thunder" in mc:
        # Cloud + bolt
        _draw_filled_circle(midx - radius//2, midy, radius + 6)
        _draw_filled_circle(midx + radius//3, midy + 4, radius)
        # Lightning bolt
        x0, y0 = midx, midy - 10
        _draw_line(x0, y0, x0 - 12, y0 + 20)
        _draw_line(x0 - 12, y0 + 20, x0 + 2, y0 + 20)
        _draw_line(x0 + 2, y0 + 20, x0 - 18, y0 + 50)
    elif "rain" in mc or "drizzle" in mc:
        # Cloud + rain streaks
        _draw_filled_circle(midx - radius//2, midy, radius + 6)
        _draw_filled_circle(midx + radius//3, midy + 4, radius)
        for i in range(-2, 3):
            x = midx - 20 + i * 12
            _draw_line(x, midy + radius + 10, x - 6, midy + radius + 35)
    elif "snow" in mc:
        # Cloud + snow asterisks
        _draw_filled_circle(midx - radius//2, midy, radius + 6)
        _draw_filled_circle(midx + radius//3, midy + 4, radius)
        for i in range(-2, 3):
            cx = midx - 16 + i * 12
            cy = midy + radius + 16
            _draw_line(cx - 4, cy, cx + 4, cy)
            _draw_line(cx, cy - 4, cx, cy + 4)
            _draw_line(cx - 3, cy - 3, cx + 3, cy + 3)
            _draw_line(cx - 3, cy + 3, cx + 3, cy - 3)
    elif any(k in mc for k in ("cloud", "mist", "fog", "haze", "smoke", "dust")):
        # Cloud-only
        _draw_filled_circle(midx - radius//2, midy, radius + 6)
        _draw_filled_circle(midx + radius//3, midy + 4, radius)
    else:
        # Clear: sun (day) or crescent moon (night)
        if is_night:
            # Crescent moon: draw big circle, then erase offset circle to form crescent
            r = radius + 4
            _draw_filled_circle(midx, midy, r)
            _erase_filled_circle(midx + int(r*0.6), midy - int(r*0.1), r)
            # Optional: a couple tiny stars
            for sx, sy in ((midx - r - 10, midy - r - 8), (midx + r - 30, midy - r - 18), (midx - r + 5, midy + r - 25)):
                for dx in (-1, 0, 1):
                    _plot(sx + dx, sy)
                    _plot(sx, sy + dx)
        else:
            # Sun with rays
            _draw_filled_circle(midx, midy, radius)
            for a in range(0, 360, 30):
                rad = math.radians(a)
                x0 = int(midx + math.cos(rad) * (radius + 10))
                y0 = int(midy + math.sin(rad) * (radius + 10))
                x1 = int(midx + math.cos(rad) * (radius + 35))
                y1 = int(midy + math.sin(rad) * (radius + 35))
                _draw_line(x0, y0, x1, y1)

# Title-casing helper (CircuitPython's str may not have .title())
def _titlecase(s):
    try:
        s = "" if s is None else str(s)
        parts = s.replace("_", " ").split()
        return " ".join(p[:1].upper() + p[1:] for p in parts)
    except Exception:
        try:
            return str(s)
        except Exception:
            return ""

# Render temperature with a raised lower-case 'o' as the degree symbol
def _update_temp_display(t):
    try:
        # number part
        if t is None:
            num = "--"
        else:
            num = "{:.0f}".format(t)
        temp_label.text = num
        # measure monospace width (terminalio is 6px per char at scale=1)
        char_w = 6
        width_px = len(num) * char_w * getattr(temp_label, "scale", 1)
        # degree 'o' is smaller and raised
        if t is None:
            degree_label.text = ""
            unit_label.text = ""
            return
        degree_label.text = "o"
        deg_scale = max(1, getattr(temp_label, "scale", 1) // 2)  # half-height 'o'
        degree_label.scale = deg_scale
        degree_label.x = temp_label.x + width_px + 2
        # raise above the baseline a bit
        degree_label.y = temp_label.y - int(getattr(temp_label, "scale", 1) * 1.5)
        # unit just to the right of the degree 'o'
        unit_label.text = TEMP_SYMBOL
        unit_label.scale = deg_scale + 1  # a bit larger than the 'o'
        unit_label.x = degree_label.x + char_w * deg_scale + 2
        unit_label.y = temp_label.y
    except Exception:
        # fail-safe: fall back to showing everything in temp_label
        try:
            temp_label.text = ("--" + TEMP_SYMBOL) if (t is None) else ("{:.0f}".format(t) + TEMP_SYMBOL)
            degree_label.text = ""
            unit_label.text = ""
        except Exception:
            pass

def update_main_ui(w):
    icon_code = str(w.get("icon", "") or "")
    used_official = show_official_icon(icon_code)
    if not used_official:
        # Fallback: draw our procedural icon into icon_bitmap and show it
        try:
            if len(icon_group) == 0:
                icon_group.append(icon_tg)
        except Exception:
            pass
        is_night = icon_code.endswith("n")
        draw_condition_icon(w.get("main", "Clear"), is_night=is_night)
    # Update text regardless of icon source
    # Text on the right — line 1: big temp + condition
    t = w.get("temp", None)
    _update_temp_display(t)
    desc = w.get("description") or w.get("main", "")
    cond_label.text = _titlecase(desc)

    # Line 2: feels-like + wind
    feels = w.get("feels_like", None)
    wind = w.get("wind_speed", None)
    feels_str = ("Feels --" + TEMP_SYMBOL) if (feels is None) else ("Feels {:.0f}".format(feels) + TEMP_SYMBOL)
    wind_str = ("") if (wind is None) else (" \u00B7 Wind {:.1f} {}".format(wind, WIND_SYMBOL))
    try:
        sub_label.text = feels_str + wind_str
    except Exception:
        sub_label.text = feels_str

# ---------- HUD ----------

def set_hud(w):
    city = w.get("city", "")
    if not city and (getattr(esp, "is_connected", False) and esp.is_connected):
        city = ""  # stay quiet; status is shown via the dot
    hud.text = city

# ---------- Main ----------

def main():
    # Basic key sanity
    try:
        if not secrets.get("owm_api_key") or len(str(secrets.get("owm_api_key"))) < 10:
            hud_status("OWM key?")
            _dbg("OWM key looks short or missing")
    except Exception:
        pass
    hud_status("WiFi…")
    wifi_ok = wifi_connect()
    gc.collect()

    # Mount SD for icon cache if available
    mount_sd()
    # Force cache dir to SD when mounted; we are offline-only
    global ICON_CACHE_DIR
    if ICON_FS_ROOT == "/sd":
        ICON_CACHE_DIR = "/sd/icons"
    _ensure_dir(ICON_CACHE_DIR)

    # First fetch & render (keep UI blank if API not ready)
    first = fetch_weather()
    if first.get("_ok"):
        weather = first
    else:
        weather = {"main":"", "description":"", "icon":"", "temp": None, "wind_speed": None, "city": ""}
    set_hud(first)
    update_main_ui(weather)
    gc.collect()

    last_fetch = time.monotonic()  # mark now to start interval
    prev = time.monotonic()
    while True:
        now = time.monotonic()
        dt = now - prev
        if dt > 0.05:
            dt = 0.05
        prev = now

        # Auto-reconnect if WiFi dropped
        if not (getattr(esp, "is_connected", False) and esp.is_connected):
            wifi_connect()

        # Refresh every 30 minutes (configurable)
        if now - last_fetch > WEATHER_REFRESH_S:
            try:
                nw = fetch_weather()
                last_fetch = now
                # Only overwrite UI state if fetch succeeded; otherwise keep last good reading
                if nw.get("_ok"):
                    weather = nw
                    update_main_ui(weather)
                # Always update HUD so status is visible
                set_hud(nw)
            except Exception:
                pass

        # Keep heap tidy
        if gc.mem_free() < 20_000:
            gc.collect()

        time.sleep(0.01)

main()

Resources


Happy Hacking!


Edit page
Share this post on:

Next Post
FPGA Sprite with VGA, Thruster Sound, and Flame