The objective of this project is to replace the existing WeatherMatrix project's MatrixPortal M4 display with a newer version that uses weather data provided by Adafruit IO Plus (AIO+) instead of the openweathermap.org web API. The requirements include:
- Implement with CircuitPython for ease of development and prototyping.
- Duplicate the existing 64x32 LED matrix display layout including label colors and icons.
- Develop an architecture to support multiple autonomous displays and local data feed devices (new).
- Utilize a weather data source that accurately aggregates station information that closely matches local conditions.
- Weather data updated approximately 3 times per hour and is reliably available.
- Service subscription is free or reasonably priced.
- Include data elements for wind gusts and local workshop temperature (new).
- Display a progress bar to indicate data time since last update (new).
- If possible, use the existing MatrixPortal M4 hardware; upgrade to MatrixPortal S3 only if necessary.
Special Acknowledgements
Thank you to John Park for the initial weather display design concept (see Weather Display Matrix) that inspired this project. Also, the work of Trevor Beaton was instrumental in creating the updated display design with a clearer and simpler coding approach (see itsaSNAP Daily Weather Forecast Board).
Choose a Weather Observation Source
Three options for sourcing weather data were considered. Here are some pros and cons of each.
openweathermap.org:
- utilizes the existing or similar web API; minimal code changes will be needed
- weather station aggregation often doesn't always match local conditions
- data retrieval throttling is reasonable with a consistent service level
- free service was phased out; will eventually require paying for the service
weather.gov:
- a moderately well-documented web API; moderate code changes will be needed
- weather station data is not aggregated and closely matches local conditions
- single weather station data is not reliably available, sometime for hours at a stretch
- data retrieval throttling is currently reasonable, but without a guaranteed service level
- a free service
AIO+ Weather (Apple WeatherKit):
- moderately well-documented CircuitPython library approach; major code changes will be needed
- weather station data is aggregated and matches local and iPhone conditions
- data retrieval throttling is reasonable with a consistent service level
- weather data and workshop conditions can be combined and viewed on a web-based IO dashboard
- requires a paid subscription to AIO+ for access to its Weather Service
The choice to use AIO+ Weather was an easy one since my existing weather tracking and corrosion monitoring projects require a subscription to AIO+. We'll need a new system design and a major rewrite of the existing MatrixWeather project code to make this happen, providing ample opportunities to improve performance and reliability. The next step was to redesign the overarching weather system architecture.
See weather.gov: A Truly Free Weather API and AIO+ Weather: A Premium Alternative for Local Observations for a detailed discussion of each alternative.
Update the System Design
The new system architecture initially consists of three local components. The first is a repeater that retrieves AIO+ Weather Service data (Apple WeatherKit) and sends observation information to existing AIO weather feeds. Since it runs separately on an ESP32-S3 TFT Feather, the AIO Weather Repeater can continuously and asynchronously provide data to the feeds that are ultimately shared with other system components.
The Workshop Corrosion Monitor is a device that uploads observations of workshop conditions measured by sensors inside the workshop. The monitor is physically installed in the workshop and displays interior conditions. The monitor detects corrosion events by watching a dew point calculation, a combination of temperature and humidity that creates moisture condensation and causes corrosion. Predicting potential corrosion events is a future capability that will require watching exterior weather trends provided by Weather Repeater feeds.
The final system component is the MatrixWeather Receiver, an updated version of the current MatrixWeather display. The receiver gets its information from the AIO feeds, buffering it from the weather repeater and workshop monitor data sources. With this design, all three components of the system operate autonomously, tied together by services and feeds in the AIO cloud.
The AIO Weather Repeater fetches a subset of AIO Weather data, converts the values, and publishes the revised data to AIO feeds. A single repeater is needed for the system to create the data that other devices may need. For this project, the repeater is powered from an AC adapter and backup battery. It is permanently mounted within range of a WiFi access point.
The following data fields are fetched by the repeater and forwarded to the feeds.
- "conditionCode" text published to "weather-description"
- "temperature" converted to degrees F and published to "weather-temperature"
- "humidity" published to "weather-humidity"
- "windSpeed" converted to MPH and published to "weather-windspeed"
- "windDirection" converted to compass description text and published to "weather-winddirection"
- "windGust" converted to MPH and published to "weather-windgusts"
- "metadata.readTime" a data freshness time stamp that is currently not published
- "daylight" a True/False value published to "weather-daylight"
In addition, a "system-watchdog" value is published. The watchdog value indicates the number of minutes that have elapsed since the last hard boot of the repeater's ESP32-S3 TFT Feather board.
# SPDX-FileCopyrightText: 2024 JG for Cedar Grove Maker Studios
# SPDX-License-Identifier: MIT
#
# AIO_weather_repeater_v00.py
# An AIO+ Weather Repeater for ESP32-S3 Feather
import board
import digitalio
import os
import time
import json
import ssl
import supervisor
import neopixel
import adafruit_datetime
import adafruit_connection_manager
import wifi
import adafruit_requests
import adafruit_minimqtt.adafruit_minimqtt as mqtt
from adafruit_io.adafruit_io import IO_MQTT
from cedargrove_temperaturetools.unit_converters import celsius_to_fahrenheit
from weatherkit_to_weathmap_icon import kit_to_map_icon
# Set default display brightness
board.DISPLAY.brightness = 0.025
# Instantiate the LED
led = digitalio.DigitalInOut(board.LED)
led.direction = digitalio.Direction.OUTPUT
led.value = False
# Initialize NeoPixel
pixel = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.01)
pixel[0] = 0xFFFF00 # Initializing (yellow)
# Get weather observations from the Sea-Tac station (47.45,-122.31)
W_TOPIC_KEY = 2730 # Integer value
W_TOPIC_DESC = "Sea-Tac Airport"
def subscribe(client, userdata, topic, granted_qos):
"""Callback method for when the client subscribes to a new feed."""
print(f" ... subscribed to '{topic}' with QOS level {granted_qos}")
def message(client, feed_id, payload):
"""Callback method for when a subscribed feed has a new value."""
global weather_data
# print(payload)
if feed_id == "weather":
weather_data = json.loads(payload)
def wind_direction(heading):
if heading is None:
return '--'
return ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][int(((heading + 22.5) % 360) / 45)]
def publish_to_aio(value, feed):
"""Publish a value to an AIO feed at a throttled transaction rate. A
blocking method.
:param union(integer, float, string) value: The value to publish.
:param string feed: The name of the AIO feed."""
if value is not None:
time.sleep(2.5) # Until throttle method is re-enabled
try:
aio.publish(feed, value)
pixel[0] = 0x00FF00 # Success (green)
print(f" ... SUCCESS: published '{value}' to AIO '{feed}'")
except Exception as aio_publish_error:
pixel[0] = 0xFF0000 # Error (red)
print(f" FAILED to publish '{value}' to AIO '{feed}'")
print(f" publish error: {str(aio_publish_error)}")
print("attempting to reconnect to AIO...")
try:
aio.reconnect()
pixel[0] = 0x00FF00 # Success (green)
except Exception as pub_reconnect_error:
pixel[0] = 0xFF0000 # Error (red)
print(f" FAILED: publish aio.reconnect {str(pub_reconnect_error)}")
print(" MCU will soft reset in 60 seconds.")
time.sleep(60)
supervisor.reload() # soft reset: keeps the terminal session alive
else:
print(f" FAILED to publish '{value}' to AIO '{feed}'")
def busy(delay):
"""An alternative time.sleep function that blinks the LED once per second.
A blocking method."""
pixel[0] = 0x000000 # Normal (black)
for blinks in range(int(round(delay, 0))):
led.value = True
time.sleep(0.05)
led.value = False
time.sleep(0.95)
try:
# Connect to Wi-Fi access point
print(f"Connecting to {os.getenv('CIRCUITPY_WIFI_SSID')}")
wifi.radio.connect(
os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD")
)
pixel[0] = 0x00FF00 # Success (green)
print(" SUCCESS: connected to WiFi access point")
except Exception as wifi_access_error:
pixel[0] = 0xFF0000 # Error (red)
print(f" FAILED: connect to WiFi. Error: {wifi_access_error}")
print(" MCU will soft reset in 60 seconds.")
time.sleep(60)
supervisor.reload() # soft reset: keeps the terminal session alive
# Initialize a requests session
pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio)
requests = adafruit_requests.Session(pool, ssl.create_default_context())
# Connect to AIO and MQTT
# Initialize the MQTT Client object (keep_alive default is 300 sec)
mqtt_client = mqtt.MQTT(
broker="io.adafruit.com",
username=os.getenv("aio_username"),
password=os.getenv("aio_key"),
keep_alive=180,
socket_pool=pool,
ssl_context=ssl.create_default_context(),
)
# Initialize AIO MQTT helper and connect to AIO
aio = IO_MQTT(mqtt_client)
print(" ... IO MQTT helper initialized")
aio.connect()
print(f" ... IO MQTT connected: {mqtt_client.is_connected()}")
# Connect the callback methods to AIO
aio.on_subscribe = subscribe
aio.on_message = message
# Subscribe to the AIO+ weather topic
aio.subscribe_to_weather(W_TOPIC_KEY, "current")
# Initialize the weather_data and history variables
weather_data = None
weather_data_old = None
while True:
# Fetch and update the weather conditions from AIO
try:
pixel[0] = 0xFFFF00 # AIO in progress (yellow)
time.sleep(2.5) # Until throttle method is re-enabled
aio.loop(10)
pixel[0] = 0x00FF00 # Success (green)
except Exception as loop_error:
pixel[0] = 0xFF0000 # Error (red)
print(f" FAILED: aio.loop {str(loop_error)}")
print(" attempting to reconnect to AIO")
try:
time.sleep(10)
aio.connect()
pixel[0] = 0x00FF00 # Success (green)
except Exception as loop_reconnect_error:
pixel[0] = 0xFF0000 # Error (red)
print(f" FAILED: loop aio.reconnect {str(loop_reconnect_error)}")
print(" MCU will soft reset in 60 seconds.")
time.sleep(60)
supervisor.reload() # soft reset: keeps the terminal session alive
if weather_data:
if weather_data != weather_data_old:
weatherkit_desc = weather_data["conditionCode"]
weatherkit_temp = celsius_to_fahrenheit(weather_data["temperature"])
weatherkit_humid = weather_data["humidity"] * 100
weatherkit_wind_speed = weather_data["windSpeed"] * 0.6214
weatherkit_wind_dir = wind_direction(weather_data["windDirection"])
weatherkit_wind_gusts = weather_data["windGust"] * 0.6214
weatherkit_timestamp = weather_data["metadata"]["readTime"]
weatherkit_daylight = weather_data["daylight"]
# print("daylight", weatherkit_daylight, type(weatherkit_daylight), dir(weatherkit_daylight))
print("-" * 13 + " NEW " + "-" * 13)
print(f"timestamp: {weatherkit_timestamp}")
sample_time = adafruit_datetime.datetime.fromisoformat(weatherkit_timestamp[:-1])
print(f"sample_time: {sample_time}")
local_time = sample_time - adafruit_datetime.timedelta(hours=7)
print(f"local_time: {local_time}")
try:
if weatherkit_daylight:
icon = kit_to_map_icon[weatherkit_desc][1] + "d"
else:
icon = kit_to_map_icon[weatherkit_desc][1] + "n"
long_desc = kit_to_map_icon[weatherkit_desc][0]
except KeyError as desc_error:
print(f" NO Description: {desc_error}")
icon = "99d"
long_desc = f"unknown description: {weatherkit_desc}"
print(f"icon: {icon}")
print(f"long_description: {long_desc}")
publish_to_aio(int(round(time.monotonic() / 60, 0)), "system-watchdog")
publish_to_aio(weatherkit_desc, "weather-description")
publish_to_aio(weatherkit_humid, "weather-humidity")
publish_to_aio(weatherkit_temp, "weather-temperature")
publish_to_aio(weatherkit_wind_dir, "weather-winddirection")
publish_to_aio(weatherkit_wind_gusts, "weather-windgusts")
publish_to_aio(weatherkit_wind_speed, "weather-windspeed")
publish_to_aio(str(weatherkit_daylight), "weather-daylight")
weather_data_old = weather_data # to watch for changes
busy(120) # Delay for two minutes to reduce query rate
else:
print(f" ... waiting for weather observations from {W_TOPIC_DESC}")
busy(10) # Step up query rate when first starting
The Corrosion Monitor lives in the adjacent workshop building. It is a data sender that uploads five feeds to AIO:
- Corrosion Index: a composite value that indicates a corrosion event
- Dew Point: the air temperature when moisture condensation commences
- Humidity: the relative content of moisture suspended in air
- Temperature: the workshop interior temperature
- CPU Temperature: the on-chip temperature of the PyPortal M4 MPU (used to operate the monitor's cooling fan)
Currently, the Corrosion Monitor detects when a corrosion event occurs and sends data to AIO. When upgraded, it will retrieve weather information trends in order to predict near-term future corrosion events.
Placed in the home's living area, the MatrixWeather Receiver's large 64x32 display makes it easy to see the most impactful weather conditions at a glance. At this time, only one display is used, but the system architecture should support others, if the need emerges. For this project the receiver is implemented with a MatrixPortal M4 and is compatible with the MatrixPortal S3 board.
Since only five text fields and one bitmap graphic are used for the receiver's display, the new version uses the simpler, abstracted MatrixPortal and PortalBase CircuitPython libraries that help with AIO feed communications.
The display consists of five text labels:
- Temperature in degrees Fahrenheit
- Wind direction/speed in MPH
- Humidity %
- A scrolling label for the description, wind gust speed, and workshop temperature
- Progress bar
Instead of using a graphic object, the progress bar is a text label object containing a single period character (".") that moves across the bottom row, indicating when the weather data will be updated. Right after the data is refreshed, a green indicator starts at the left edge of the display and gradually moves to the right. As it gets close to the right side, it turns red. New data will be fetched from the AIO feeds when it reaches the edge. The text label positioning property is not exposed in the MatrixPortal class, so the code uses the class' internal variable.
The built-in AIO fetch capability of the PortalBase.Network class retrieves all data in the feed rather than just the last entry. Although it is possible to extract a single entry from the query result with some simple CircuitPython code, the entire table of values would still reside in the MatrixPortal's memory. The MatrixPortal M4 doesn't have the capacity for storing the entire extract; upgrading to the S3 version with more memory would be required. Instead, a hidden function in the network class can be used to fetch only the last feed value, allowing continued use of the existing MatrixPortal M4 board.
To control display brightness and reduce power consumption, the cedargrove_palettefader library was used to alter the graphic icon palette and text label colors. As with the text table position property, the bitmap palette is not exposed in the MatrixPortal class, so the internal palette object was used in the code. An issue to expose the class' hidden properties will be submitted.
# SPDX-FileCopyrightText: Modified 2024 JG for Cedar Grove Maker Studios
# inspired by code developed by Trevor Beaton for Adafruit Industries
#
# SPDX-License-Identifier: MIT
#
# MatrixWeather_AIO_Receiver
import time
import board
import terminalio
from adafruit_matrixportal.matrixportal import MatrixPortal
from cedargrove_palettefader.palettefader import PaletteFader
from weatherkit_to_weathmap_icon import kit_to_map_icon
# AIO Weather Receiver Parameters
UPDATE_DELAY = 1320 # 22 minutes
BRIGHTNESS = 0.1
# Instantiate matrix display
mp = MatrixPortal(width=64, height=32, bit_depth=6, debug=False, rotation=90, status_neopixel=board.NEOPIXEL)
# Load splash graphic and dim per BRIGHTNESS
mp.set_background("images/background_sun_clouds.bmp", (65, 65))
bkg_normal = PaletteFader(
mp.graphics._bg_sprite.pixel_shader,
BRIGHTNESS,
gamma=0.65,
normalize=True,
)
mp.graphics._bg_sprite.pixel_shader = bkg_normal.palette
mp.graphics._bg_sprite.x = 0
mp.graphics._bg_sprite.y = 0
# Define color list for labels and dim per BRIGHTNESS
LABEL_COLORS_REF = [
0xFFFF00, # yellow; temperature
0x0066FF, # blue; description
0x00FFFF, # cyan; humidity
0xFF00FF, # purple; wind
0x800000, # red; progress bar
0x008000, # green; progress bar
]
label_colors = PaletteFader(LABEL_COLORS_REF, BRIGHTNESS, gamma=1.0, normalize=False)
# Define the temperature label
mp.add_text(
text_font=terminalio.FONT,
text_color=label_colors.palette[0],
text_position=(16, 6),
text_anchor_point=(0.5, 0.5),
scrolling=False,
)
# Define the wind speed/direction label
mp.add_text(
text_font=terminalio.FONT,
text_color=label_colors.palette[2],
text_position=(16, 45),
text_anchor_point=(0.5, 0.5),
scrolling=False,
)
# Define the humidity label
mp.add_text(
text_font=terminalio.FONT,
text_color=label_colors.palette[3],
text_position=(16, 34),
text_anchor_point=(0.5, 0.5),
scrolling=False,
)
# Define the scrolling description label
mp.add_text(
text_font=terminalio.FONT,
text_color=label_colors.palette[1],
text_position=(16, 55),
text_anchor_point=(0.5, 0.5),
scrolling=True,
)
# Define the progress bar label
mp.add_text(
text_font=terminalio.FONT,
text_color=label_colors.palette[3],
text_position=(16, 61),
text_anchor_point=(0.5, 0.5),
scrolling=False,
)
mp.set_text(".", 4)
def get_last_value(feed_key):
"""Fetch the latest value of the AIO feed.
:param str feed_key: The AIO feed key."""
try:
last_value = mp.network.io_client.receive_data(feed_key)["value"]
return last_value
except (KeyError, IndexError) as e:
print(f"Error fetching data from feed {feed_key}: {e}")
def update_display():
"""Fetch last values and update the display."""
temperature = get_last_value("weather-temperature")
temperature = f"{int(round(float(temperature), 0))} F"
humidity = get_last_value("weather-humidity")
humidity = f"{int(round(float(humidity), 0))}%"
condition = get_last_value("weather-description")
try:
current_condition = kit_to_map_icon[condition][0]
except:
current_condition = "unknown" # If condition is not in translator
daylight = get_last_value("weather-daylight")
if daylight == "True":
icon_suffix = "d"
else:
icon_suffix = "n"
wind_speed = get_last_value("weather-windspeed")
wind_speed = f"{int(round(float(wind_speed), 0))}"
wind_dir = get_last_value("weather-winddirection")
wind_gusts = get_last_value("weather-windgusts")
wind_gusts = f"{int(round(float(wind_gusts), 0))}"
wind = f"{wind_dir} {wind_speed}"
shop_temperature = get_last_value("shop.int-temperature")
shop_temperature = f"{int(round(float(shop_temperature), 0))}"
# Get the local time and provide hour-of-day for is_daytime method
try:
mp.get_local_time("America/Los_Angeles")
display_time = f"{time.localtime().tm_hour:2d}:{time.localtime().tm_min:2d}"
print(f"Local Time: {display_time}")
except Exception as e:
print(f"Error fetching local time: {e}")
# Create icon file name
if current_condition != "unknown":
icon = f"index_{kit_to_map_icon[condition][2]:02d}{icon_suffix}.bmp"
else:
icon = "index_08d.bmp" # Default icon if condition not in translator
print(f"Icon filename: {icon}")
mp.set_text(temperature, 0)
mp.set_text(humidity, 1)
mp.set_text(wind, 2)
mp.set_text(f"{current_condition} Gusts:{wind_gusts} Shop:{shop_temperature}", 3)
mp.set_background(f"images/{icon}", (65, 65))
mp.graphics._bg_sprite.pixel_shader.make_transparent(0)
icon_normal = PaletteFader(
mp.graphics._bg_sprite.pixel_shader,
BRIGHTNESS,
gamma=1.0,
normalize=True,
)
mp.graphics._bg_sprite.pixel_shader = icon_normal.palette
mp.graphics._bg_sprite.x = 8
mp.graphics._bg_sprite.y = 12
old_prog_bar_x = -1
last_weather_update = time.monotonic()
update_display()
# Main loop
while True:
current_time = time.monotonic()
# Update weather every UPDATE_DELAY seconds
if current_time - last_weather_update > UPDATE_DELAY:
update_display()
last_weather_update = current_time
prog_bar_x = int(round(32 * (current_time - last_weather_update) / UPDATE_DELAY, 0))
if prog_bar_x != old_prog_bar_x:
if prog_bar_x > 25:
mp._text[4]["color"] = label_colors.palette[4]
else:
mp._text[4]["color"] = label_colors.palette[5]
mp._text[4]["position"] = ((prog_bar_x, 61))
mp.set_text(".", 4)
old_prog_bar_x = prog_bar_x
# Sleep for 0.1 second then scroll wind and description text
time.sleep(0.10) # Sleep for 0.1 second
mp.scroll()
Weather Icon Translator Module
This module is used by the receiver to translate Apple WeatherKit conditions to more descriptive narratives and to map a condition to an openweathermap filename or matrix icon index. The receiver code provides the short description and uses the returned index value to build the icon filename. Index icon bitmap files are contained in the images folder.
# Apple WeatherKit to openweathermap icon code translator
# WeatherCondition, description, openweathermap icon code, matrix icon index
kit_to_map_icon = {
"BlowingDust": ["Blowing dust or sand.", "50", 8],
"Clear": ["Clear.", "01", 0],
"Cloudy": ["Cloudy, overcast.", "04", 3],
"Foggy": ["Fog.", "50", 8],
"Haze": ["Haze.", "50", 8],
"MostlyClear": ["Mostly clear.", "02", 1],
"MostlyCloudy": ["Mostly cloudy.", "03", 2],
"PartlyCloudy": ["Partly cloudy.", "02", 1],
"Smoky": ["Smoky.", "50", 8],
"Breezy": ["Breezy, light wind.", "99", 8],
"Windy": ["Windy.", "99", 8],
"Drizzle": ["Drizzle or light rain.", "10", 5],
"HeavyRain": ["Heavy rain.", "09", 4],
"IsolatedThunderstorms": ["Light thunderstorms.", "11", 6],
"Rain": ["Rain.", "09", 4],
"SunShowers": ["Rain with visible sun.", "10", 5],
"ScatteredThunderstorms": ["Scattered thunderstorms.", "11", 6],
"StrongStorms": ["Strong thunderstorms.", "11", 6],
"Thunderstorms": ["Thunderstorms.", "11", 6],
"Frigid": ["Frigid conditions.", "99", 8],
"Hail": ["Hail.", "13", 7],
"Hot": ["High temperatures.", "99", 0],
"Flurries": ["Flurries or light snow.", "13", 7],
"Sleet": ["Sleet.", "13", 7],
"Snow": ["Snow.", "13", 7],
"SunFlurries": ["Snow flurries with visible sun.", "13", 7],
"WintryMix": ["Wintry mix.", "13", 7],
"Blizzard": ["Blizzard.", "13", 7],
"BlowingSnow": ["Blowing or drifting snow.", "13", 7],
"FreezingDrizzle": ["Freezing drizzle or light rain.", "09", 4],
"FreezingRain": ["Freezing rain.", "09", 4],
"HeavySnow": ["Heavy snow.", "13", 7],
"Hurricane": ["Hurricane.", "99", 8],
"TropicalStorm": ["Tropical storm.", "99", 8],
}
Wish List (in progress)
- MatrixPortal and PortalBase CircuitPython Libraries
- Expose the following functions and properties:
-
network.io_client.receive_data( )to reduce size of AIO retrieval data set for M4 processor -
graphics._bg_sprite.pixel_shaderfor software-based display brightness control -
graphics._bg_sprite.xfor software-based display brightness control -
graphics._bg_sprite.yfor software-based display brightness control -
_text[index]["color"]to change color of progress bar label (and any text label) -
_text[index]["position"]to change position of progress bar label (and any text label)
-
- Expose the following functions and properties:
- Adafruit IO
- Collection of selectable server-side feeds for a client's AIO account with the latest weather observations for a particular latitude/longitude or NWS station, perhaps built on the existing AIO+ Weather integration. Would be a welcome feature that could create a storm of new project innovation.
- Use the throttling feature in the
adafruit_ioCircuitPython library to manage to AIO service levels (free = 30 feeds/min, AIO+ = 60 feeds/min). Would be particularly useful for managing feed rates from multiple AIO projects such as this one. - It's been difficult to find examples for how to control AIO feed rates in order to stay under account limits, particularly for projects like this one that share feeds and independently query and publish to the same AIO account's feeds. Some further research is needed -- knowing that there's a user-facing throttle management scheme in AIO that's supported by the CircuitPython AIO libraries.
- Create a CircuitPython library of icon bitmap files to match Apple WeatherKit, openweathermap.org, and/or weather.gov conditions. Consider adding a function to resize the bitmap for a specified resolution or pixel size.
This page (Display AIO+ Local Weather Conditions: MatrixWeather System) was last updated on October 23, 2024.