Overview
Cell towers go down. Power goes out. LoRa mesh radio keeps working!
This is an off-grid message board. It listens for messages sent across a LoRa mesh network and shows them on a 4.2" tri-color e-ink display. There is no internet, no cell service, and no subscription required!
Messages arrive over 915MHz radio from any node running Meshtastic. The newest message is shown in bold red so it reads across a room, with older messages dropping to black underneath it.
E-ink holds its image with no power at all. The display only draws current when it redraws, so the board runs for over a day on a 2000mAh battery.
A button on the top cycles through three views. The weather view pulls a three day forecast, sunrise + sunset times, and the current moon phase. The message view lists what has come in over the mesh. The node view shows every radio the board has heard, sorted by signal strength.
This fully featured message board can also read CO2, temperature, and humidity readings from a STCC4 and SHT41 sensor connected to STEMMA QT port on the Feather ESP32-S3. These readings run along the top of the screen next to the battery level with an estimated runtime.
The board decodes messages using Adafruit CircuitPython MeshFruit, a library written for this project. It is receive only. The board listens to the mesh but never transmits, so it will not appear in anyone's node list.
Page last edited September 27, 2026
Text editor powered by tinymce.
Circuit Diagram
Circuit Diagram
This provides a visual reference for wiring of the components. This diagram was created using Fritzing software.
- RFM95W VIN to 3V on the Feather
- RFM95W GND to GND on the Feather
- RFM95W SCK to SCK on the Feather
- RFM95W MOSI to MOSI on the Feather
- RFM95W MISO to MISO on the Feather
- RFM95W CS to 11 on the Feather
- RFM95W RST to 12 on the Feather
- RFM95W G0 to A5 on the Feather
Button goes between Feather A0 and GND — internal pull-up, so no external resistor needed. Sensor on the STEMMA QT port with a JST SH cable, so no soldering.
SCK, MOSI and MISO are shared with the eInk Feather Friend, so D5, D6, D9 and D10 are unavailable.
Page last edited September 27, 2026
Text editor powered by tinymce.
Code
The code for this project listens for LoRa mesh packets, decodes them, and draws them to the e-ink display. It also reads the air quality sensor, checks the battery, and fetches a weather forecast over WiFi.
Click on the Download Project Bundle button below to get all the files. Save the zip. Plug your Feather into your computer via a known-good data+power USB cable. The CIRCUITPY drive shows up in your computer's File Explorer or Finder (depending on your operating system). Copy the files to CIRCUITPY.
# SPDX-FileCopyrightText: 2026 Pedro Ruiz for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
MeshFruit e-ink message board.
Listens for LoRa mesh broadcast text messages and renders the most
recent ones to a 4.2 inch tri-color e-ink panel, alongside a weather
forecast, air quality and battery runtime.
A button on A0 cycles views. Switching does not reboot, so the message
buffer survives a view change.
Receive only. This board does not transmit and will not appear in the
mesh node list.
"""
import os
import time
import adafruit_max1704x
import adafruit_meshfruit
import adafruit_rfm9x
import adafruit_ssd1683
import adafruit_stcc4
import alarm
import board
import digitalio
import displayio
import microcontroller
import neopixel
import terminalio
from adafruit_debouncer import Debouncer
from adafruit_bitmap_font import bitmap_font
from adafruit_display_text import label, wrap_text_to_pixels
from fourwire import FourWire
import mesh_icons
import mesh_weather
displayio.release_displays()
# --- settings ---------------------------------------------------------
LATITUDE = os.getenv("LATITUDE")
LONGITUDE = os.getenv("LONGITUDE")
WIFI_SSID = os.getenv("CIRCUITPY_WIFI_SSID")
WIFI_PASSWORD = os.getenv("CIRCUITPY_WIFI_PASSWORD")
USE_FAHRENHEIT = True
# --- radio ------------------------------------------------------------
# US 915MHz band, 250kHz slots. Slot N sits at 902.125 + (N - 1) * 0.25
# MHz, and slot 20 is the default for the public channel. Outside the
# US this has to match whatever the nodes around you are using: run
# "meshtastic --info" on a node to read its operating frequency.
FREQUENCY = 906.875
CHANNEL_HASH = 0x08
SYNC_WORD_REG = 0x39
MESH_SYNC_WORD = 0x2B
RADIO_IRQ_PIN = board.A5
# --- layout -----------------------------------------------------------
WIDTH = 400
HEIGHT = 300
MARGIN = 10
TEXT_WIDTH = WIDTH - MARGIN * 2
BLACK = 0x000000
RED = 0xFF0000
WHITE = 0xFFFFFF
HEADER_BASELINE = 16
RULE_Y = 30
CONTENT_TOP = 52
CONTENT_BOTTOM = 262
STATUS_RULE_Y = 272
NODES_TOP = 64
MESSAGE_GAP = 14
FOOTER_TEXT_OFFSET = 9
WEATHER_SPLIT_Y = 168
WEATHER_MSG_TOP = 182
WEATHER_MSG_LIMIT = 2
SKY_ICON_X = WIDTH - 100
SKY_TEXT_X = SKY_ICON_X + 18
SUN_ROW_Y = 6
MOON_ROW_Y = 62
# Weight carries recency, size carries fit. All faces are from the
# public domain Misc-Fixed family, trimmed to printable ASCII.
FONT_PATHS = {
"large_bold": "/fonts/9x18B.pcf",
"medium_bold": "/fonts/7x14B.pcf",
"medium": "/fonts/7x14.pcf",
"name": "/fonts/6x13B.pcf",
"small": "/fonts/6x10.pcf",
}
MAX_LARGE_LINES = 2
# --- behaviour --------------------------------------------------------
MAX_MESSAGES = 12
SEEN_HISTORY = 20
REFRESH_SECONDS = 20
BUTTON_PIN = board.A0
VIEWS = ("weather", "messages", "nodes")
VIEW_TITLES = {"weather": "Weather", "messages": "MeshFruit", "nodes": "Nodes"}
# The panel cannot redraw more than once every 180 seconds, so the
# pixel carries the fast feedback the display cannot.
BLINK_SECONDS = 0.35
PIXEL_PRESS = (0, 0, 60)
PIXEL_MESSAGE = (0, 50, 0)
PIXEL_QUEUED = (60, 25, 0)
PIXEL_SOON = (50, 40, 0)
PIXEL_DRAWING = (40, 0, 40)
REFRESH_SOON_SECONDS = 10
# With no mesh traffic nothing would trigger a redraw, so the battery,
# air and weather readings would sit stale indefinitely. Redraw on this
# interval regardless. Each refresh costs about 20 seconds of panel
# drive, so this trades a little runtime for readings you can trust.
IDLE_REFRESH_SECONDS = 1800
CPU_FREQUENCY = 80_000_000
SLEEP_SECONDS = 0.2
AIR_SETTLE_SECONDS = 1.0
BATTERY_RATE_FLOOR = 0.5
# Learned short names are cached in NVM so they survive a reboot.
NAMES_MAGIC = b"MF1"
NAMES_MAX = 24
NAME_LEN = 4
ENTRY_LEN = 4 + NAME_LEN
# Nothing here is compute bound, so run the CPU slower.
microcontroller.cpu.frequency = CPU_FREQUENCY
# --- hardware ---------------------------------------------------------
# The STEMMA sensors come up first. board.STEMMA_I2C() claims and
# manages I2C_POWER itself, and if another peripheral gets there first
# the bus comes up with no pull ups, which surfaces as a wiring error
# rather than a power one.
i2c = board.STEMMA_I2C()
air_sensor = adafruit_stcc4.STCC4(i2c)
air_sensor.continuous_measurement = True
time.sleep(AIR_SETTLE_SECONDS)
battery = adafruit_max1704x.MAX17048(i2c)
spi = board.SPI()
# The eInk Feather Friend owns D5, D6, D9 and D10. Park the SRAM and SD
# chip selects so they do not drive the shared SPI bus.
for pin in (board.D6, board.D5):
parked = digitalio.DigitalInOut(pin)
parked.direction = digitalio.Direction.OUTPUT
parked.value = True
# Leave reset and busy as None. The Feather Friend does not bring those
# out to D8 and D7, and claiming those pins corrupts the I2C bus.
display_bus = FourWire(
spi, command=board.D10, chip_select=board.D9, reset=None, baudrate=1000000
)
time.sleep(1)
display = adafruit_ssd1683.SSD1683(
display_bus, width=WIDTH, height=HEIGHT, highlight_color=RED, busy_pin=None
)
rfm9x = adafruit_rfm9x.RFM9x(
spi,
digitalio.DigitalInOut(board.D11),
digitalio.DigitalInOut(board.D12),
FREQUENCY,
)
rfm9x.signal_bandwidth = 250000
rfm9x.spreading_factor = 11
rfm9x.coding_rate = 5
rfm9x.preamble_length = 16
rfm9x.enable_crc = True
rfm9x._write_u8(SYNC_WORD_REG, MESH_SYNC_WORD) # pylint: disable=protected-access
# Defaults to the public channel key. Pass psk= for a private channel.
mesh = adafruit_meshfruit.Meshtastic_Compatible()
pixel = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2, auto_write=True)
button_pin = digitalio.DigitalInOut(BUTTON_PIN)
button_pin.direction = digitalio.Direction.INPUT
button_pin.pull = digitalio.Pull.UP
button = Debouncer(button_pin)
FONTS = {name: bitmap_font.load_font(path) for name, path in FONT_PATHS.items()}
rule_palette = displayio.Palette(1)
rule_palette[0] = BLACK
# --- state ------------------------------------------------------------
messages = []
seen = []
node_names = {}
node_stats = {}
view_index = 0
pending = False
warned_soon = False
# Held in dicts so the functions below can update them without a
# global statement.
radio_state = {"rssi": None}
forecast = {"data": None, "at": 0.0}
# --- sensors ----------------------------------------------------------
def read_air():
"""Read the air sensor. Returns a display string, or None.
CO2 is read first: the driver fetches the whole measurement on that
property, so asking for temperature first reports "not ready".
"""
try:
co2 = air_sensor.CO2
humidity = air_sensor.relative_humidity
celsius = air_sensor.temperature
except (RuntimeError, OSError) as error:
print("air read failed:", error)
return None
degrees = celsius * 9 / 5 + 32 if USE_FAHRENHEIT else celsius
unit = "F" if USE_FAHRENHEIT else "C"
return f"{co2} ppm {round(degrees)}{unit} {round(humidity)}%"
def format_hours(hours):
"""Render a duration compactly: 45m, 6h, 2d."""
if hours < 1:
return f"{max(round(hours * 60), 1)}m"
if hours < 48:
return f"{round(hours)}h"
return f"{round(hours / 24)}d"
def read_battery():
"""Charge state and estimated time remaining.
charge_rate is percent per hour, signed. Dividing the remaining
capacity by it gives a runtime estimate, which matters more than a
percentage on a board meant to outlast a power cut.
"""
try:
percent = min(max(battery.cell_percent, 0), 100)
rate = battery.charge_rate
except (RuntimeError, OSError) as error:
print("battery read failed:", error)
return None
text = f"{round(percent)}%"
if rate > BATTERY_RATE_FLOOR:
return f"{text} +{format_hours((100.0 - percent) / rate)}"
if rate < -BATTERY_RATE_FLOOR:
return f"{text} {format_hours(percent / abs(rate))}"
return text
# --- fonts and text ---------------------------------------------------
def face(size):
"""Return the font and its line height for a named face."""
font = FONTS[size]
return font, font.get_bounding_box()[1] + 4
def wrap_for(text, size):
"""Wrap text to the panel width for a named face."""
font, line_height = face(size)
return wrap_text_to_pixels(text, TEXT_WIDTH, font), font, line_height
def size_for(index, text):
"""Pick a face for a message at this position in the list.
The newest message is always bold and red. It uses the large face
unless it runs long, in which case it steps down so it does not
crowd older messages off the panel.
"""
if index != 0:
return "medium"
lines, _, _ = wrap_for(text, "large_bold")
if len(lines) > MAX_LARGE_LINES:
return "medium_bold"
return "large_bold"
# --- node names -------------------------------------------------------
def load_names():
"""Restore cached short names from NVM."""
blob = bytes(microcontroller.nvm[: 4 + NAMES_MAX * ENTRY_LEN])
if blob[:3] != NAMES_MAGIC:
return
for index in range(min(blob[3], NAMES_MAX)):
start = 4 + index * ENTRY_LEN
number = int.from_bytes(blob[start : start + 4], "little")
short = blob[start + 4 : start + ENTRY_LEN].rstrip(b"\x00")
if short:
node_names[f"!{number:08x}"] = short.decode("utf-8")
print("restored", len(node_names), "node names")
def save_names():
"""Persist known short names to NVM.
Only called when a name changes, since NVM sits in flash and has a
finite write endurance.
"""
items = list(node_names.items())[:NAMES_MAX]
blob = bytearray(NAMES_MAGIC + bytes([len(items)]))
for node, short in items:
blob += int(node[1:], 16).to_bytes(4, "little")
# CircuitPython's bytes has no ljust, so pad by hand.
raw = short.encode("utf-8")[:NAME_LEN]
blob += raw + b"\x00" * (NAME_LEN - len(raw))
microcontroller.nvm[0 : len(blob)] = blob
def decode_name(raw):
"""Decode a NodeInfo name field, or None if it is not valid UTF-8."""
if not raw:
return None
try:
return raw.decode("utf-8")
except UnicodeError:
return None
def display_name(node):
"""Short name for a node if NodeInfo has been heard, else its ID."""
return node_names.get(node, node)
# --- drawing ----------------------------------------------------------
def add_rule(group, y_pos, thickness=2):
"""Draw a horizontal rule across the content width."""
bitmap = displayio.Bitmap(WIDTH - MARGIN * 2, thickness, 1)
group.append(
displayio.TileGrid(bitmap, pixel_shader=rule_palette, x=MARGIN, y=y_pos)
)
def draw_header(group, air, charge):
"""Title, air readings and battery, above the rule."""
group.append(
label.Label(
terminalio.FONT,
text=VIEW_TITLES[VIEWS[view_index]],
color=BLACK,
scale=2,
x=MARGIN,
y=HEADER_BASELINE,
)
)
name_font, _ = face("name")
if air:
group.append(
label.Label(
name_font,
text=air,
color=BLACK,
anchor_point=(0.5, 0.5),
anchored_position=(WIDTH // 2, HEADER_BASELINE),
)
)
if charge:
group.append(
label.Label(
name_font,
text=charge,
color=BLACK,
anchor_point=(1.0, 0.5),
anchored_position=(WIDTH - MARGIN, HEADER_BASELINE),
)
)
add_rule(group, RULE_Y)
def draw_empty(group, text, y_pos):
"""Placeholder for a view with nothing to show yet."""
group.append(
label.Label(
terminalio.FONT, text=text, color=BLACK, scale=2, x=MARGIN, y=y_pos
)
)
def draw_messages( # pylint: disable=too-many-locals
group, top=CONTENT_TOP, bottom=CONTENT_BOTTOM, limit=None
):
"""Message list. Returns the number of messages shown."""
if not messages:
draw_empty(group, "Listening for messages...", top + 10)
return 0
meta_font, meta_height = face("name")
y_pos = top
shown = 0
for index, (node, text) in enumerate(messages):
if limit is not None and shown >= limit:
break
size = size_for(index, text)
lines, font, line_height = wrap_for(text, size)
needed = meta_height + len(lines) * line_height + MESSAGE_GAP
# Always show the newest message even if it runs long, but never
# start a later block that cannot fit.
if shown > 0 and y_pos + needed > bottom:
break
color = RED if index == 0 else BLACK
group.append(
label.Label(
meta_font, text=display_name(node), color=color, x=MARGIN, y=y_pos
)
)
line_y = y_pos + meta_height + 8
for line in lines:
if line_y > bottom:
break
group.append(
label.Label(font, text=line, color=color, x=MARGIN, y=line_y)
)
line_y += line_height
y_pos += needed
shown += 1
return shown
def draw_nodes(group):
"""Node list: who has been heard, how strongly, how often."""
if not node_stats:
draw_empty(group, "No nodes heard yet...", CONTENT_TOP + 10)
return 0
name_font, name_height = face("name")
small_font, small_height = face("small")
# Strongest signal first, so the nearest neighbours lead.
nodes = sorted(node_stats.items(), key=lambda item: -item[1]["rssi"])
y_pos = NODES_TOP
shown = 0
for node, stats in nodes:
name = display_name(node)
has_name = name != node
block = name_height + (small_height if has_name else 0) + 12
if y_pos + block > CONTENT_BOTTOM:
break
color = RED if shown == 0 else BLACK
group.append(
label.Label(name_font, text=name, color=color, x=MARGIN, y=y_pos)
)
group.append(
label.Label(
small_font,
text=f"{stats['rssi']} dBm {stats['count']} pkt",
color=color,
anchor_point=(1.0, 0.5),
anchored_position=(WIDTH - MARGIN, y_pos),
)
)
# Only worth a second line once NodeInfo has given us a name,
# otherwise it would just repeat the ID above it.
if has_name:
group.append(
label.Label(
small_font, text=node, color=color, x=MARGIN, y=y_pos + name_height
)
)
y_pos += block
shown += 1
return shown
def draw_weather(group, weather): # pylint: disable=too-many-locals
"""Forecast on the top half, newest messages underneath."""
if weather is None:
draw_empty(group, "No forecast yet...", CONTENT_TOP + 10)
else:
big_font, _ = face("large_bold")
name_font, name_height = face("name")
small_font, _ = face("small")
unit = "F" if USE_FAHRENHEIT else "C"
mesh_icons.draw_weather_icon(
group, weather["code"], MARGIN + 24, CONTENT_TOP + 14, RED
)
group.append(
label.Label(
big_font,
text=f"{weather['now']}{unit}",
color=RED,
x=MARGIN + 62,
y=CONTENT_TOP + 22,
)
)
group.append(
label.Label(
name_font,
text=mesh_weather.describe_code(weather["code"]),
color=RED,
x=MARGIN + 152,
y=CONTENT_TOP + 22,
)
)
for offset, rising, value in (
(SUN_ROW_Y, True, weather["sunrise"]),
(SUN_ROW_Y + 20, False, weather["sunset"]),
):
if not value:
continue
mesh_icons.icon_sun_marker(group, SKY_ICON_X, CONTENT_TOP + offset, rising)
group.append(
label.Label(
small_font,
text=value,
color=BLACK,
x=SKY_TEXT_X,
y=CONTENT_TOP + offset,
)
)
phase = mesh_weather.moon_phase(weather["date"])
if phase is not None:
moon_y = CONTENT_TOP + MOON_ROW_Y
mesh_icons.icon_moon(group, SKY_ICON_X, moon_y, phase)
# Two short lines keep the column narrow instead of running
# the name out to the panel edge.
for index, word in enumerate(mesh_weather.moon_name(phase).split(" ")[:2]):
group.append(
label.Label(
small_font,
text=word,
color=BLACK,
x=SKY_TEXT_X,
y=moon_y - 6 + index * 14,
)
)
labels = ("Today", "Tomorrow", "Next day")
y_pos = CONTENT_TOP + 48
for index in range(min(3, len(weather["highs"]))):
group.append(
label.Label(
small_font, text=labels[index], color=BLACK, x=MARGIN, y=y_pos
)
)
group.append(
label.Label(
small_font,
text=(
f"{weather['highs'][index]} / {weather['lows'][index]}{unit}"
f" {mesh_weather.describe_code(weather['codes'][index])}"
),
color=BLACK,
x=MARGIN + 90,
y=y_pos,
)
)
y_pos += name_height
add_rule(group, WEATHER_SPLIT_Y, thickness=1)
return draw_messages(
group, top=WEATHER_MSG_TOP, bottom=CONTENT_BOTTOM, limit=WEATHER_MSG_LIMIT
)
def draw_status(group, hidden):
"""Pinned status bar along the bottom of the panel."""
add_rule(group, STATUS_RULE_Y, thickness=1)
small_font, _ = face("small")
baseline = STATUS_RULE_Y + FOOTER_TEXT_OFFSET
if VIEWS[view_index] == "nodes":
left = f"{len(node_stats)} nodes heard"
elif hidden > 0:
left = f"{len(messages) - hidden} shown, {hidden} older"
elif messages:
left = f"{len(messages)} held"
else:
left = "no messages yet"
group.append(
label.Label(small_font, text=left, color=BLACK, x=MARGIN, y=baseline)
)
group.append(
label.Label(
small_font,
text=(
f"{radio_state['rssi']} dBm"
if radio_state["rssi"] is not None
else "listening"
),
color=BLACK,
anchor_point=(1.0, 0.5),
anchored_position=(WIDTH - MARGIN, baseline),
)
)
def build_group(air, charge, weather):
"""Build the full display group for the current view."""
group = displayio.Group()
background = displayio.Bitmap(WIDTH, HEIGHT, 1)
bg_palette = displayio.Palette(1)
bg_palette[0] = WHITE
group.append(displayio.TileGrid(background, pixel_shader=bg_palette))
draw_header(group, air, charge)
if VIEWS[view_index] == "nodes":
shown = draw_nodes(group)
hidden = max(len(node_stats) - shown, 0)
elif VIEWS[view_index] == "weather":
shown = draw_weather(group, weather)
hidden = max(len(messages) - shown, 0)
else:
shown = draw_messages(group)
hidden = max(len(messages) - shown, 0)
draw_status(group, hidden)
return group, shown, hidden
def blink(color):
"""Flash the pixel to signal something the panel cannot show yet."""
pixel[0] = color
time.sleep(BLINK_SECONDS)
pixel[0] = (0, 0, 0)
def refresh_weather():
"""Fetch the forecast if the cached copy is stale."""
elapsed = time.monotonic()
if forecast["data"] is not None and elapsed - forecast["at"] < mesh_weather.INTERVAL:
return
fetched = mesh_weather.fetch(
WIFI_SSID, WIFI_PASSWORD, LATITUDE, LONGITUDE, USE_FAHRENHEIT
)
if fetched is not None:
forecast["data"] = fetched
forecast["at"] = elapsed
def draw_board():
"""Render the board and block until the panel finishes updating."""
if VIEWS[view_index] == "weather":
refresh_weather()
group, shown, hidden = build_group(
read_air(), read_battery(), forecast["data"]
)
display.root_group = group
print("drawing...", shown, "shown,", hidden, "hidden")
pixel[0] = PIXEL_DRAWING
display.refresh()
time.sleep(REFRESH_SECONDS)
pixel[0] = (0, 0, 0)
print("refreshed")
def handle_packet(raw): # pylint: disable=too-many-return-statements
"""Decode one received buffer. Returns True if the panel should redraw."""
if len(raw) < adafruit_meshfruit.HEADER_LEN + 1:
return False
packet = mesh.decode(raw)
if packet.channel_hash != CHANNEL_HASH:
return False
# Sender plus packet ID uniquely identifies a message. The mesh
# rebroadcasts each one several times as the hop count decrements.
packet_key = bytes(raw[4:12])
if packet_key in seen:
return False
seen.append(packet_key)
if len(seen) > SEEN_HISTORY:
seen.pop(0)
if not packet.payload:
return False
node = packet.sender_id
radio_state["rssi"] = rfm9x.last_rssi
stats = node_stats.get(node)
if stats is None:
node_stats[node] = {"rssi": rfm9x.last_rssi, "count": 1}
else:
stats["rssi"] = rfm9x.last_rssi
stats["count"] += 1
if packet.portnum == adafruit_meshfruit.PORT_NODEINFO:
short = decode_name(packet.user.get("short_name"))
if short and node_names.get(node) != short:
node_names[node] = short
save_names()
print("node", node, "is", short)
# Redraw only if this node is already on the panel.
return any(entry[0] == node for entry in messages)
return False
if packet.portnum != adafruit_meshfruit.PORT_TEXT_MESSAGE:
return False
messages.insert(0, (node, packet.text or "<non-utf8>"))
del messages[MAX_MESSAGES:]
print(node, messages[0][1])
return True
def doze():
"""Light sleep until a packet arrives, or briefly for housekeeping.
The radio is left in continuous receive so its DIO0 line can wake
us, which means nothing is missed while asleep. Waking on a short
timer as well keeps the button and refresh cooldown responsive.
"""
rfm9x.listen()
# Setting up the alarm takes time of its own. If the wake time has
# already passed by the time it is armed, light_sleep raises, so
# skip the nap rather than crashing.
try:
alarm.light_sleep_until_alarms(
alarm.pin.PinAlarm(pin=RADIO_IRQ_PIN, value=True),
alarm.time.TimeAlarm(monotonic_time=time.monotonic() + SLEEP_SECONDS),
)
except ValueError:
pass
# --- main loop --------------------------------------------------------
load_names()
draw_board()
last_draw = time.monotonic()
print("listening on", FREQUENCY, "MHz")
while True:
doze()
button.update()
incoming = rfm9x.receive(with_header=True, timeout=0.05)
if incoming and handle_packet(incoming):
pending = True
blink(PIXEL_MESSAGE)
# Active low, so fell means pressed.
button.update()
if button.fell:
view_index = (view_index + 1) % len(VIEWS)
print("view ->", VIEWS[view_index])
blink(PIXEL_PRESS if display.time_to_refresh == 0 else PIXEL_QUEUED)
pending = True
if not pending and time.monotonic() - last_draw > IDLE_REFRESH_SECONDS:
pending = True
# One warning as the cooldown runs out, so a queued change does not
# appear to arrive from nowhere.
remaining = display.time_to_refresh
if pending and 0 < remaining <= REFRESH_SOON_SECONDS and not warned_soon:
blink(PIXEL_SOON)
warned_soon = True
if pending and remaining == 0:
draw_board()
last_draw = time.monotonic()
pending = False
warned_soon = False
Files
Four Python files go in the root of the CIRCUITPY drive.
- code.py — setup, display layout, packet handling, and the main loop
- mesh_weather.py — forecast lookup and moon phase
-
mesh_icons.py — weather glyphs drawn with
vectorio - mesh_fonts folder — the five bitmap fonts the layout uses
Libraries
The Project Bundle includes the libraries, but the screenshot above shows what the lib folder on CIRCUITPY should look like when you are done. These are the libraries this project uses.
- adafruit_bitmap_font
- adafruit_bus_device
- adafruit_debouncer
- adafruit_display_text
- adafruit_max1704x
- adafruit_meshfruit
- adafruit_requests
- adafruit_rfm9x
- adafruit_ssd1683
- adafruit_ticks
- neopixel
Settings
The weather view needs your WiFi credentials and your location. Open settings.toml on the CIRCUITPY drive and add the following.
CIRCUITPY_WIFI_SSID = "your-network-name" CIRCUITPY_WIFI_PASSWORD = "your-network-password" LATITUDE = "40.7128" LONGITUDE = "-74.0060"
Coordinates are in decimal degrees. Use a negative value for west and for south. Four decimal places is plenty.
The message and node views work without WiFi. Only the forecast needs it.
Radio settings
The radio has to match the mesh you want to listen to. These values are near the top of code.py.
-
FREQUENCYis set to906.875, which is slot 20 in the US 915MHz band. Slot N sits at 902.125 + (N - 1) * 0.25 MHz. Outside the US, runmeshtastic --infoon a node to read the frequency for your region. -
CHANNEL_HASHis set to0x08, which is the default public channel. -
MESH_SYNC_WORDis set to0x2B. This is written straight to the radio register because it differs from the library default.
Things to change
A few values near the top of code.py are worth knowing about.
-
USE_FAHRENHEITset toFalseswitches the sensor readings and the forecast to Celsius. -
MAX_MESSAGESis how many messages are kept in memory. The oldest drop off the end. -
IDLE_REFRESH_SECONDSis how often the board redraws when no messages arrive, so the battery and sensor readings stay current. -
SLEEP_SECONDSis how long the board naps between checks. Shorter is more responsive to button presses and uses more power.
Page last edited September 27, 2026
Text editor powered by tinymce.
3D Printing
3MF files for 3D printing are oriented and ready to print on FDM machines using PLA filament. Original design source files may be downloaded using the links below.
Slice with settings for PLA material
The parts were sliced using BambuStudio using the slice settings below.
- PLA filament 220c extruder
- 0.2 layer height
- 10% gyroid infill
- 200mm/s print speed
- Tree Supports
- 60 C heated bed
Design Source Files
The project assembly was designed in Fusion 360. Once opened in Fusion 360, It can be exported in different formats like STEP, STL and more.
Electronic components like Adafruit's boards, displays, connectors and more can be downloaded from the Adafruit CAD parts GitHub Repo
Page last edited September 27, 2026
Text editor powered by tinymce.
Assembly
Solder short headers to the bottom of the Feather. Solder short socket headers on top of the eInk Feather Friend to fit the Feather ESP32 S3 on top of the eInk Friend.
Measure 50mm long silicone ribbon wires to connect the LoRa board and button to the Feather.
A spring antenna is soldered to the LoRa board.
The eInk Feather Friend fastens to a plate mount with machine screws.
A 2000mAh LiPo battery is held in place with a retainer. The battery retainer use machine screws to secure it to the plate mount.
The STEMMA sensor is fastened to the edge of the plate, next to the vents on the case to allow air to reach it away from the Feather boards.
The mounting plate slides into the case from the vent side of the case, under the snap fits, and presses into the case to hold the display in place.
The ribbon cable has just enough length to reach the port on the eInk Feather Friend.
Gently bend the ribbon cable into the port on the eInk Feather Friend. Use tweezers to secure the ribbon cable with the two side tabs on the port.
The LoRa board is secure with a clip and mounts to the battery holder with additional machine screws.
Page last edited September 27, 2026
Text editor powered by tinymce.
Usage
Quick Reference
- Press the button — cycle to the next view: weather, messages, nodes
- Blue NeoPixel — button press registered, the panel is redrawing now
- Amber NeoPixel — button press registered, waiting on the refresh cooldown
- Green NeoPixel — a new message arrived
- Yellow NeoPixel — the panel will redraw within ten seconds
- Purple NeoPixel — the panel is updating, about twenty seconds
Boot Up
Plug in the battery or connect USB power. The board restores any node names it has learned, draws the current view, and starts listening.
The first draw takes about twenty seconds. The display holds whatever was on it before that, so the old image stays until the new one is ready.
Refresh Rate
Tri-color e-ink cannot redraw more than once every three minutes. The display driver enforces this, and refreshing sooner would damage the panel.
This makes the board a bulletin board rather than a chat window. A message that arrives right after a refresh waits for the next one. The board queues it and redraws as soon as the panel allows.
The NeoPixel is how you know a press or a message registered before the screen catches up.
The board also redraws every thirty minutes on its own, so the battery level, air readings, and forecast stay current even when no messages arrive.
Header
The top of every view shows the same three things.
- The name of the current view on the left
- CO2 in parts per million, temperature, and humidity in the middle
- Battery percentage and estimated runtime on the right
Runtime reads as 72% 14h on battery, or 72% +3h while charging, where the second number is time to full. A reading with no second number means the battery is holding steady and an estimate would be noise.
Status Bar
The bottom of every view shows how many messages are held and the signal strength of the last packet heard, in dBm. A number closer to zero is a stronger signal. Around -90 is a normal indoor reading.
Button
Press the button to move to the next view. The order is weather, then messages, then nodes, then back to weather.
Switching views does not reboot the board, so messages received earlier are still there when you come back.
Weather View
The current conditions row shows an icon, the temperature, and a description, all in red. Sunrise, sunset, and the moon phase sit on the right.
Below that is a three day forecast with a high, a low, and a description for each day.
The bottom half of the screen shows the two newest messages.
The forecast updates every thirty minutes. WiFi is only used for this, and the rest of the board works without it.
Messages View
Messages are listed newest first. The newest one is drawn in bold red so it reads from across a room. Older messages are black.
A long message drops to a smaller size so it does not crowd the others off the screen. It stays red.
The board shows as many complete messages as fit. It never cuts one off partway. When messages do not fit, the status bar reads something like 3 shown, 5 older.
Nodes View
Every radio the board has heard is listed, strongest signal first. Each row shows the node name, its ID, the signal strength, and how many packets have come from it.
A node shows its short name once it has sent a NodeInfo broadcast. Until then only the ID appears, which looks like !09d43fc8.
Node names are saved to the Feather's non-volatile memory, so they survive a reboot. Without this, the board would show raw IDs until every node happened to announce itself again, which can take up to three hours.
Sending a Message
Any node on the same channel can send to the board. Pair a Meshtastic node to your phone over Bluetooth, set the region to match your board, and send a message on the default channel.
The board is receive only. It listens to the mesh but never transmits, so it will not appear in anyone's node list.
Battery Life
The board runs for over a day on a 2000mAh battery.
E-ink draws power only while redrawing. Most of the remaining draw is the radio listening and the processor waking to check for packets. The board light sleeps between checks and wakes on the radio's interrupt pin, so nothing is missed while it naps.
Page last edited September 27, 2026
Text editor powered by tinymce.