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:
- 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
- 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:
01d 01n 02d 02n 03d 03n 04d 04n 09d 09n 10d 10n 11d 11n 13d 13n 50d 50n
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!