How many matrix panels can you use with the Matrix Portal S3? Theoretically, around 50, however the more panels you add the greater the travel distance and signal degradation. Degraded signals can have effects of pixel artifacts/glitching and visible scan lines that are very hard on the eyes.
The bit depth (amount of possible colors) is also very important. 12 panels cannot stream more than a bit depth of 4 without significant artifacts. Normally with Circuit Python, images are 8-bit indexed BMP's but matrix panels can only display a maximum of 6-bit color. A TFT can support up to 24-bit so do not make the mistake of treating a matrix display like a TFT. It will still process 8-bit indexed BMP's with some image quality loss due to the nature of RGB LED's in a matrix panel.
For this project I'm using 12x 5mm pitch matrix panels. The pitch denotes the physical distance between pixels. A 3mm pitch panel will be much smaller physically than a 6mm pitch for example.
Panel Arrangement
The serpentine arrangement of the panels matters. If you get it wrong you'll end up re-arranging the entire matrix until you get it right. The RGB Matrix library expects the controller (Matrix Portal S3) to be in the top right of any arrangement regardless of post-processing rotation in the code. The library expects each 2nd row to have the panels flipped 180 degrees as shown below. You can have each row or column with as many panels as you want so long as the rules of the serpentine arrangement are adhered to.
Support Brackets
3D printing is a very convenient way to assemble all of the panels to make 1 large structural display. There are many other ways you could create a bracket system including aluminum sheets or wood. The 3D printed STL files for these 5mm pitch panels are on my printables page. The Quad & End brackets are designed to butt together to provide most of the vertical rigidity.
Power Supplies
Each panel requires 5V 4A from a PSU and 15KB of RAM from your microcontroller. For 12 panels that's 5V 42A! I split the power requirements in 2 sections using 2x 5V 18A power supplies. By running at lower bit depth you also reduce the amperage required. It is possible that I might need to add a 3rd PSU to my arrangement as I'm maxing out both PSU's.
I chose to pipe the mains power through a power safety rocker switch for each PSU. In case of emergency it will be much easier to cut power to sections instead of trying to run a single 48A rated safety switch from a single PSU. You can find 5V power supplies with 60A capability but then you can't use it with a safety switch of this type.
It would be unwise to put a 60A fuse in one of these as the switch itself is only rated for 20A. More than 20A and you could melt the switch! It's not recommended to run a single large high amperage power supply without some type of fuse and safety switch. These panels pull a LOT of power and can produce a good amount of heat. Please plan accordingly when designing your power setup.
The switches come with a 15A cylindrical fuse built-in which I've swapped out for a 20A fuse to meet the power requirements. I have not blown a fuse or PSU yet so the amperage has stayed below the 18A maximum of the PSU.
Circuit Python Code
Code repository including fonts, icons, and images.
The code for this project incorporates a BME688 temp/humidity/pressure sensor. Also included is temperature biasing algorithm because the BME688 temp sensor in particular can be quite far off from the real temperature. I highly recommend using the BME280 instead as it's far more accurate out of the box and doesn't need bias adjustment in my experience. I used the BME688 here as an experiment to test its quality vs the BME280 and it comes up very short in comparison.
The imports require some external libraries for Circuit Python 8.2.x such as display_text, display_shapes, bitmap_font, and cedargrove_palettefader.
By using framebufferio we can translate the display into a displayio object so the total display can be used in the same manner as a TFT display with all the features of displayio.
First you always want to set displayio.release_displays() which will reset the total display and prepare it for new data. We'll set some variables for the pixel dimensions, rotation, and bit depth.
Because I only need the display to update periodically with new weather data I'm setting auto_refresh to false. This helps cut down on display artifacts by not continually streaming new data to the display. In this example its only updating the display once every 60 seconds.
import time import board import displayio import rgbmatrix import framebufferio import adafruit_imageload import ulab.numpy as np import terminalio from adafruit_display_text import label from adafruit_display_shapes.roundrect import RoundRect from adafruit_bitmap_font import bitmap_font from cedargrove_palettefader.palettefader_ulab import PaletteFader import adafruit_bme680 displayio.release_displays() DISPLAY_WIDTH = 192 DISPLAY_HEIGHT = 128 DISPLAY_ROTATION = 0 BIT_DEPTH = 3 AUTO_REFRESH = False
Next we setup the RGBmatrix with physical pinouts of the Matrix Portal S3. This is also where you define how many rows (tile) you have in your array. There are other arrangements that are not serpentine. Please see Jepler's Advanced Matrix Panel Guide for Multiple Panels as a reference. Double Buffer helps with visual quality especially for scrolling projects. If you can use doublebuffer in your project without running out of ram or crashes I highly recommend it.
matrix = rgbmatrix.RGBMatrix( width=DISPLAY_WIDTH, height=DISPLAY_HEIGHT, bit_depth=BIT_DEPTH, rgb_pins=[ board.MTX_R1, board.MTX_G1, board.MTX_B1, board.MTX_R2, board.MTX_G2, board.MTX_B2], addr_pins=[board.MTX_ADDRA, board.MTX_ADDRB, board.MTX_ADDRC, board.MTX_ADDRD], clock_pin=board.MTX_CLK, latch_pin=board.MTX_LAT, output_enable_pin=board.MTX_OE, tile=4, serpentine=True, doublebuffer=True)
Next we'll setup the RGBMatrix to use framebufferio and translate that into a displayio object. We'll also setup the BME688 sensor and our display update rate. Polling time and display refresh are the same thing in this circumstance but they are normally separate things for a TFT project. Because we're using it to cut down on glitches & artifacting they can be set to the same variable later in the code.
I typically include a set of quick hex color variables in most of my projects.
# Associate the RGB matrix with a Display so we can use displayio display = framebufferio.FramebufferDisplay(matrix, auto_refresh=AUTO_REFRESH, rotation=DISPLAY_ROTATION) i2c = board.I2C() sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c) # Time in seconds between updates (polling) # 600 = 10 mins, 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour sleep_time = 60 # Converts seconds in minutes/hours/days def time_calc(input_time): if input_time < 60: sleep_int = input_time time_output = f"{sleep_int:.0f} seconds" elif 60 <= input_time < 3600: sleep_int = input_time / 60 time_output = f"{sleep_int:.0f} minutes" elif 3600 <= input_time < 86400: sleep_int = input_time / 60 / 60 time_output = f"{sleep_int:.0f} hours" else: sleep_int = input_time / 60 / 60 / 24 time_output = f"{sleep_int:.1f} days" return time_output # Quick Colors for Labels TEXT_BLACK = 0x000000 TEXT_BLUE = 0x0000FF TEXT_CYAN = 0x00FFFF TEXT_GRAY = 0x8B8B8B TEXT_GREEN = 0x00FF00 TEXT_LIGHTBLUE = 0x90C7FF TEXT_MAGENTA = 0xFF00FF TEXT_ORANGE = 0xFFA500 TEXT_PURPLE = 0x800080 TEXT_RED = 0xFF0000 TEXT_WHITE = 0xFFFFFF TEXT_YELLOW = 0xFFFF00
Setup custom fonts and a long list of custom labels that are updated each display refresh.
# Fonts are optional tinyfont = bitmap_font.load_font("/fonts/tiny3x5.bdf") medium_font = bitmap_font.load_font("/fonts/Arial-16.bdf") huge_font = bitmap_font.load_font("/fonts/GoodTimesRg-Regular-16.bdf") GoodTimes40 = bitmap_font.load_font("/fonts/GoodTimesRg-Regular-40.bdf") # Individual customizable position labels # https://learn.adafruit.com/circuitpython-display-support-using-displayio/text hello_label = label.Label(tinyfont) hello_label.anchor_point = (0.5, 0.0) hello_label.anchored_position = (DISPLAY_WIDTH/2, 2) hello_label.scale = (1) hello_label.color = TEXT_WHITE hello_label_outline1 = label.Label(tinyfont) hello_label_outline1.anchor_point = (0.5, 0.0) hello_label_outline1.anchored_position = (DISPLAY_WIDTH/2, 2+1) hello_label_outline1.scale = (1) hello_label_outline1.color = TEXT_BLACK hello_label_outline2 = label.Label(tinyfont) hello_label_outline2.anchor_point = (0.5, 0.0) hello_label_outline2.anchored_position = (DISPLAY_WIDTH/2, 2-1) hello_label_outline2.scale = (1) hello_label_outline2.color = TEXT_BLACK hello_label_outline3 = label.Label(tinyfont) hello_label_outline3.anchor_point = (0.5, 0.0) hello_label_outline3.anchored_position = (DISPLAY_WIDTH/2+1, 2) hello_label_outline3.scale = (1) hello_label_outline3.color = TEXT_BLACK hello_label_outline4 = label.Label(tinyfont) hello_label_outline4.anchor_point = (0.5, 0.0) hello_label_outline4.anchored_position = (DISPLAY_WIDTH/2-1, 2) hello_label_outline4.scale = (1) hello_label_outline4.color = TEXT_BLACK warning_text_label = label.Label(tinyfont) warning_text_label.anchor_point = (0.5, 0.5) warning_text_label.anchored_position = (DISPLAY_WIDTH/2, 13) warning_text_label.scale = (1) warning_text_label.color = TEXT_RED temp_label = label.Label(medium_font) temp_label.anchor_point = (1.0, 1.0) temp_label.anchored_position = (DISPLAY_WIDTH-5, DISPLAY_HEIGHT/2+3) temp_label.scale = 1 temp_label.color = TEXT_ORANGE temp_data_label = label.Label(GoodTimes40) temp_data_label.anchor_point = (0.5, 0.5) temp_data_label.anchored_position = (DISPLAY_WIDTH/2, DISPLAY_HEIGHT/2) temp_data_label.scale = 1 temp_data_label.color = TEXT_ORANGE temp_data_shadow = label.Label(GoodTimes40) temp_data_shadow.anchor_point = (0.5, 0.5) temp_data_shadow.anchored_position = (DISPLAY_WIDTH/2+1, DISPLAY_HEIGHT/2+1) temp_data_shadow.scale = 1 temp_data_shadow.color = TEXT_BLACK humidity_label = label.Label(terminalio.FONT) humidity_label.anchor_point = (0.0, 1.0) humidity_label.anchored_position = (2, DISPLAY_HEIGHT - 14) humidity_label.scale = 1 humidity_label.color = TEXT_GRAY humidity_outline1 = label.Label(terminalio.FONT) humidity_outline1.anchor_point = (0.0, 1.0) humidity_outline1.anchored_position = (2, DISPLAY_HEIGHT - 14+1) humidity_outline1.scale = 1 humidity_outline1.color = TEXT_BLACK humidity_outline2 = label.Label(terminalio.FONT) humidity_outline2.anchor_point = (0.0, 1.0) humidity_outline2.anchored_position = (2, DISPLAY_HEIGHT - 14-1) humidity_outline2.scale = 1 humidity_outline2.color = TEXT_BLACK humidity_outline3 = label.Label(terminalio.FONT) humidity_outline3.anchor_point = (0.0, 1.0) humidity_outline3.anchored_position = (2+1, DISPLAY_HEIGHT - 14) humidity_outline3.scale = 1 humidity_outline3.color = TEXT_BLACK humidity_outline4 = label.Label(terminalio.FONT) humidity_outline4.anchor_point = (0.0, 1.0) humidity_outline4.anchored_position = (2-1, DISPLAY_HEIGHT - 14) humidity_outline4.scale = 1 humidity_outline4.color = TEXT_BLACK humidity_data_label = label.Label(huge_font) humidity_data_label.anchor_point = (0.0, 1.0) humidity_data_label.anchored_position = (1, DISPLAY_HEIGHT-2) humidity_data_label.scale = 1 humidity_data_label.color = TEXT_ORANGE humidity_shadow = label.Label(huge_font) humidity_shadow.anchor_point = (0.0, 1.0) humidity_shadow.anchored_position = (2, DISPLAY_HEIGHT-1) humidity_shadow.scale = 1 humidity_shadow.color = TEXT_BLACK barometric_label = label.Label(terminalio.FONT) barometric_label.anchor_point = (1.0, 1.0) barometric_label.anchored_position = (DISPLAY_WIDTH-2, DISPLAY_HEIGHT - 13) barometric_label.scale = 1 barometric_label.color = TEXT_GRAY barometric_label_shadow = label.Label(terminalio.FONT) barometric_label_shadow.anchor_point = (1.0, 1.0) barometric_label_shadow.anchored_position = (DISPLAY_WIDTH-1, DISPLAY_HEIGHT - 12) barometric_label_shadow.scale = 1 barometric_label_shadow.color = TEXT_BLACK barometric_data_label = label.Label(huge_font) barometric_data_label.anchor_point = (1.0, 1.0) barometric_data_label.anchored_position = (DISPLAY_WIDTH-2, DISPLAY_HEIGHT-2) barometric_data_label.scale = 1 barometric_data_label.color = TEXT_ORANGE barometric_shadow = label.Label(huge_font) barometric_shadow.anchor_point = (1.0, 1.0) barometric_shadow.anchored_position = (DISPLAY_WIDTH-1, DISPLAY_HEIGHT-1) barometric_shadow.scale = 1 barometric_shadow.color = TEXT_BLACK
Next we'll use CedarGrove's PaletteFader so we can dynamically change the brightness level of any displayio layer. This part is optional and a nice feature to have. Note that I'm using a large custom background image as the first displayio layer.
# Define background graphic object parameters BKG_BRIGHTNESS = 0.5 # Initial brightness level BKG_GAMMA = 0.8 # Works nicely for brightness = 0.2 BKG_IMAGE_FILE = "images/Astral_Fruit_192x128.bmp" # Load the background image and source color palette bkg_bitmap, bkg_palette_source = adafruit_imageload.load( BKG_IMAGE_FILE, bitmap=displayio.Bitmap, palette=displayio.Palette ) # Instantiate background PaletteFader object and display on-screen faded_object = PaletteFader(bkg_palette_source, brightness=BKG_BRIGHTNESS, gamma=BKG_GAMMA, normalize=True) bkg_tile = displayio.TileGrid(bkg_bitmap, pixel_shader=faded_object.palette)
Next is creating a rounded rectangle as the background layer for severe weather popup warnings.
# Warning label RoundRect roundrect = RoundRect(int(0), # x-position of the top left corner int(8), # y-position of the top left corner DISPLAY_WIDTH, # width of the rounded-corner rectangle 11, # height of the rounded-corner rectangle 5, # corner radius fill=0x0, # fill color outline=0xFFFFFF, # outline color stroke=1) # stroke width
Group & Sub-Group creation. Background layers are ordered from top to bottom in displayio groups.
To layer elements properly you must put them in the correct order you want them displayed.
The function for the warning popup depends on a boolean variable set later in the code. If a weather parameter is over or under a certain threshold then a rounded rectangle popup warning will display alerting you.
# Create subgroups primary_group = displayio.Group() primary_group.append(bkg_tile) text_group = displayio.Group() hello_group = displayio.Group() temp_group = displayio.Group() warning_group = displayio.Group() main_group = displayio.Group() # Add subgroups to main display group main_group.append(primary_group) main_group.append(text_group) main_group.append(hello_group) main_group.append(warning_group) main_group.append(temp_group) # Add warning popup group warning_group.append(roundrect) warning_group.append(warning_text_label) # Label Display Group (foreground layer) hello_group.append(hello_label_outline1) hello_group.append(hello_label_outline2) hello_group.append(hello_label_outline3) hello_group.append(hello_label_outline4) hello_group.append(hello_label) temp_group.append(temp_label) temp_group.append(temp_data_shadow) temp_group.append(temp_data_label) text_group.append(humidity_outline1) text_group.append(humidity_outline2) text_group.append(humidity_outline3) text_group.append(humidity_outline4) text_group.append(humidity_label) text_group.append(humidity_shadow) text_group.append(humidity_data_label) text_group.append(barometric_label_shadow) text_group.append(barometric_label) text_group.append(barometric_shadow) text_group.append(barometric_data_label) display.show(main_group) def show_warning(text): warning_text_label.text = text warning_group.hidden = False def hide_warning(): warning_group.hidden = True
I typically set some final variables right before the main while loop that are less configuration and more like last minute variables that don't need customization.
Once you figure out the offset bias for your temp sensor you can plug values into the algorithm to make it much more accurate. For instance, at 120F I have to subtract 15F for an accurate reading. The algorithm will automatically interpolate between each set of values you give it. Being within 2% of the real temperature is considered acceptable.
How do you know what the real temperature is? Get a NIST traceable thermometer to compare it against. Do not rely on online weather as it can be drastically different from the temperature at your location. Local sensors can be far more useful and more accurate than online temperature data alone. With the right algorithm you can make practically any temp sensor extremely accurate.
display_temperature = 0 # Temperature offset adjustments # For correcting incorrect temp sensor readings input_range = [50, 70, 80, 88, 90, 95, 120] output_range = [50-0.1, 70-2.0, 80-2.0, 88-4.5, 90-7.0, 95-10.0, 120-15] hello_text = "MATRIX PORTAL S3" humidity_text = "HUMIDITY" pressure_text = "PRESSURE"
Finally we're at the main while loop where everything comes together.
Because I live at sea level, I have the luxury of calibrating sea level to whatever my pressure sensor outputs. Normally this is where you would want to tie into an online weather service for that value as it will fluxuate constantly... feeding it a single default value like 1014 will only work for about 15 minutes and might throw off your altitude if you're interested in displaying altitude. I do not display altitude from pressure readings in any manner here but it's there if you want to use it.
In order to make text legible against all background colors we can create an outline using 5 labels. Every label you display slows down the display a little. Every label works the CPU a little harder until you have 50 labels and your project slows to a crawl. A microcontroller CPU has a very limited amount of resources so choose what you want to display wisely. Thankfully the Matrix Portal S3 is a beast of a microcontroller and can more than adequately handle everything I'm throwing at it.
while True: sensor.sea_level_pressure = sensor.pressure hello_label_outline1.text = f"{hello_text}" hello_label_outline2.text = f"{hello_text}" hello_label_outline3.text = f"{hello_text}" hello_label_outline4.text = f"{hello_text}" hello_label.text = f"{hello_text}" print("===============================") debug_OWM = False # Set to True for Serial Print Debugging # Local sensor data display temp_label.text = "°F" # Board Uptime print("Board Uptime: ", time_calc(time.monotonic())) # Account for PCB heating bias, gets slightly hotter as ambient increases temperature = sensor.temperature * 1.8 + 32 temp_round = round(temperature, 2) print("Sensor Temp: ", temperature) # biased reading display_temperature = np.interp(temperature, input_range, output_range) display_temperature = round(display_temperature[0], 2) print(f"Adjusted Temp: {display_temperature:.1f}") #mqtt_pressure = 1009 # Manually set to debug warning message mqtt_humidity = round(sensor.relative_humidity, 1) mqtt_pressure = round(sensor.pressure, 1) mqtt_altitude = round(sensor.altitude, 2) temp_data_shadow.text = f"{display_temperature:.1f}" temp_data_label.text = f"{display_temperature:.1f}" humidity_outline1.text = f"{humidity_text}" humidity_outline2.text = f"{humidity_text}" humidity_outline3.text = f"{humidity_text}" humidity_outline4.text = f"{humidity_text}" humidity_label.text = f"{humidity_text}" humidity_shadow.text = f"{mqtt_humidity:.1f} %" humidity_data_label.text = f"{mqtt_humidity:.1f} %" barometric_label_shadow.text = f"{pressure_text}" barometric_label.text = f"{pressure_text}" barometric_shadow.text = f"{mqtt_pressure:.1f}" barometric_data_label.text = f"{mqtt_pressure:.1f}" # Warnings based on local sensors if mqtt_pressure <= 919: # pray you never see this message show_warning("HOLY COW: SEEK SHELTER!") elif 920 <= mqtt_pressure <= 979: show_warning("DANGER: MAJOR HURRICANE!") elif 980 <= mqtt_pressure <= 989: show_warning("DANGER: MINOR HURRICANE") elif 990 <= mqtt_pressure <= 1001: show_warning("WARNING: TROPICAL STORM") elif 1002 <= mqtt_pressure <= 1009: # sudden gusty downpours show_warning("CAUTION: LOW PRESSURE SYSTEM") elif 1019 <= mqtt_pressure <= 1025: # sudden light cold rain show_warning("CAUTION: HIGH PRESSURE SYSTEM") elif mqtt_pressure >= 1026: show_warning("WARNING: HAIL & TORNADO WATCH") else: hide_warning() # Normal pressures: 1110-1018 (no message) print("Next Update: ", time_calc(sleep_time)) print("===============================") display.refresh(target_frames_per_second=10, minimum_frames_per_second=0) time.sleep(sleep_time)