This project uses 900 MHz RFM95W LoRa FeatherWing modules to transmit temperature measurements from greenhouses and receive them at a base station about half a kilometer away. The base station hardware outputs sensor reports over USB serial, an optional 2x16 Character LCD, and an optional ESP-NOW gateway.
The LoRa radio settings are tuned for extended battery life at a range of up to 500m in suburban or rural conditions (non line of sight with limited obstructions). With a fully charged 400 mAh LiPo battery and a 9 minute reporting interval, typical sensor runtime should be about 4 weeks (~22µA deep sleep current, ~2667ms of time per wake cycle, ~0.222 coulombs of charge per wake cycle).
To optimize the transmitter for running on a small LiPo cell, I used a Nordic nRF-PPK2 power analyzer to tune the firmware. Some of the power saving tricks I used include reducing the cpu frequency, putting the RFM95W radio in sleep mode, putting the MAX17048 fuel gauge in hibernate mode, and using ESP32-S3 deep sleep. To compare the impact of different changes, I took extensive measurements using Nordic's Power Profiler app with the PPK2 connected to the Feather board's battery jack and GPIO outputs.
Transparency note: Adafruit provided some of the parts I used for this guide (Thanks Adafruit!).
Related Projects
For logging, charting, and IRC status notifications, check out my related projects:
Parts
The firmware can be configured to run in several modes:
- LoRa temperature sensor (ESP32-S3 Feather + LoRa FeatherWing)
- LoRa base station (ESP32-S3 Feather + LoRa FeatherWing + optional I2C LCD)
- LoRa base station with ESP-NOW gateway (ESP32-S3 Feather + LoRa FeatherWing + optional I2C LCD)
- ESP-NOW base station (ESP32-S3 Feather, QT Py, or Metro + optional I2C LCD)
At minimum, you will need one LoRa sensor and one LoRa base station. If you want to operate your LoRa base station in ESP-NOW gateway mode, you can use other ESP-NOW boards to receive the sensor packets forwarded by the gateway.
LoRa Sensor Parts:
- ESP32-S3 Feather
- RFM05W LoRa FeatherWing (+ hookup wire)
- 915MHz spring antenna
- FeatherWing Doubler
- 400 mAh LiPo battery
- 100mm STEMMA QT / Qwiic I2C cable
- MCP9808 I2C temperature sensor
- IP67 rated waterproof enclosure (preferably with plastic screws)
LoRa Base Station Parts:
- ESP32-S3 Feather
- RFM05W LoRa FeatherWing (+ hookup wire)
- 915MHz spring antenna
- Edge launch SMA connector
- SMA mount 915MHz half-wave dipole antenna
- FeatherWing Doubler (or FeatherWing Tripler)
- [optional] I2C character LCD backpack
- [optional] RGB backlit 2x16 character LCD
ESP-NOW Base Station Parts:
- ESP-NOW capable board such as ESP32-S3 Feather, QT Py, or Metro
- [optional] I2C character LCD backpack
- [optional] RGB backlit 2x16 character LCD
Soldering & Pinout
This project involves a lot of soldering. Most of it is easy, but the antenna connector is a little harder. You should definitely use a flux pen to apply flux to the antenna connector. Without extra flux, you're gonna have a hard time. It also helps to use Kapton tape to hold the spring antenna or SMA connector while you solder.
Check out these Learn guides for soldering tips and pinout info:
- Adafruit Guide To Excellent Soldering
- Radio FeatherWing Wiring (see pinout below)
- Radio FeatherWing Assembly
- I2C/SPI LCD Backpack
LoRa Radio FeatherWing Pinout
- RST goes to C (gray wire)
- CS goes to B (yellow wire)
- IRQ goes to A (orange wire)
Cut MCP9808 LED Jumper
For lower current draw, use a hobby knife to cut the PCB trace joining the two pads on the temperature sensor's LED jumper. The jumper is located next to the green LED at the top left of the board.
I2C LCD Backpack
To assemble the I2C character LCD backpack, you should pay attention to:
- DANGER: Be very sure the screw terminal pins on the backpack do not touch components on your LCD module. You may need to trim the pins. Check clearance before soldering. (see photos below)
- To get the classic 90's green backlit LCD look using Adafruit's RGB backlit character LCD module, you can use a short piece of hookup wire to connect the green LED pin to the backpack's backlight pin. If you just wire the pins straight across (no angled hookup wire), you'll get a red backlight.
- To hold the backpack PCB parallel to the LCD module PCB while you solder, you can shim the far side with some scraps of pin header turned sideways (see photos below)
- You will probably need to adjust the contrast trim pot on the back of the I2C backpack. If the LCD looks blank when the code runs (backlight on but no text), it's probably because the contrast is set wrong.
LoRa Node Addresses & HMAC Keys
This project doesn't really care how many sensor nodes you use or how you assign the addresses. But, if you want to use my serial-sensor-hub and irc-display-bot code too, those work best with 1 to 3 sensors, using node addresses 1, 2, and 3. For base station node addresses, you can use whatever so long as it doesn't conflict with the sensor addresses (maybe start at 200).
Sensors and base stations use HMAC signatures with a shared key for message integrity, sender authentication, and duplicate packet filtering. The sensors sign their temperature report packets, which include a sequence number (derived from RTC). The base station(s) verify the HMAC signature and check that the sequence numbers are monotonic. The signature implementation is based on TOTP, but the message to be signed includes node address, battery voltage, temperature, etc. instead of only a UNIX timestamp.
CAUTION: If you reset or power cycle a sensor, you need to reset or reboot the base station(s) to reset their sequence number checking.
Configuration
The project code can be configured to run in several modes:
- LoRa temperature sensor (ESP32-S3 Feather + LoRa FeatherWing)
- LoRa base station (ESP32-S3 Feather + LoRa FeatherWing)
- LoRa base station with ESP-NOW gateway (ESP32-S3 Feather + LoRa FeatherWing)
- ESP-NOW base station (ESP32-S3 Feather, QT Py, or Metro)
Each sensor and base station needs to have some CIRCUITPY/settings.toml values set to configure which mode to operate in, what key to use for HMAC, what LoRa node address to use (except for the ESP-NOW base station), etc.
The HMAC key should be the same for all the sensors and base stations you want to be able to communicate with each other. If you want more than one sensor network without cross-talk interference, you can assign each network its own key.
Settings.toml for Sensor Mode
To run in sensor mode, put this stuff in your settings.toml file, editing the HMAC key and node address values as needed:
HMAC_KEY = "YOUR HMAC PASSPHRASE GOES HERE"
BASE_MODE = 0
LORA_NODE = 1
Settings.toml for Plain LoRa Base Station
To run in base station mode, put this stuff in your settings.toml file, editing the HMAC key and node address values as needed:
HMAC_KEY = "YOUR HMAC PASSPHRASE GOES HERE"
BASE_MODE = 1
LORA_NODE = 200
LCD_BACKLIGHT = 0
ESPNOW_GATEWAY = 0
ESPNOW_RX = 0
If you've attached an I2C 2x16 character LCD and you want to turn the backlight on, do LCD_BACKLIGHT = 1.
Settings.toml for LoRa Base Station with ESP-NOW Gateway
To run a base station in LoRa to ESP-NOW gateway mode, put this stuff in your settings.toml file, editing the HMAC key and node address values as needed:
HMAC_KEY = "YOUR HMAC PASSPHRASE GOES HERE"
BASE_MODE = 1
LORA_NODE = 201
LCD_BACKLIGHT = 0
ESPNOW_GATEWAY = 1
ESPNOW_RX = 0
If you've attached an I2C 2x16 character LCD and you want to turn the backlight on, do LCD_BACKLIGHT = 1.
Settings.toml for ESP-NOW Base Station
To run a base station ESP-NOW receiver mode (receiving packets forwarded by a LoRa to ESP-NOW gateway), put this stuff in your settings.toml file, editing the HMAC key value as needed:
HMAC_KEY = "YOUR HMAC PASSPHRASE GOES HERE"
BASE_MODE = 1
LCD_BACKLIGHT = 0
ESPNOW_GATEWAY = 0
ESPNOW_RX = 1
If you've attached an I2C 2x16 character LCD and you want to turn the backlight on, do LCD_BACKLIGHT = 1.
Updating CircuitPython
As I write this (November 14, 2025), CircuitPython 10.0.3 is the most recent release build. To keep your board up to date, you can use the "DOWNLOAD .BIN NOW" button on the download page of circuitpython.org:
- Feather ESP32-S3 8MB page
To install the .BIN File:
- Connect your board to a computer with a USB data cable (charge-only cables won't work! )
- Press and hold the board's boot button
- Press and release the reset button
- Release the boot button
- Follow the instructions in the Web Serial ESPTool section of the "CircuitPython on ESP32 Quick Start" learn guide to update your board with CircuitPython (I've had the best luck with using Adafruit WebSerial ESPTool to erase the whole flash then program the .BIN file)
CircuitPython Code
You can view the code at the samblenny/lora-greenhouse-monitor GitHub repository. To download a zip archive project bundle with the code and all the necessary libraries, use the "Download Project Bundle" button:
Install Project Bundle
To copy the project bundle files to your CIRCUITPY drive:
- Download the project bundle .zip file using the "Download Project Bundle" button above.
- Expand the zip file by opening it, or use unzip in a Terminal. The zip archive should expand to a folder. When you open the folder, it should contain a README.txt file and a CircuitPython 10.x folder.
- Open the CircuitPython 10.x folder and copy all of its contents to your CIRCUITPY drive.
To learn more about copying libraries to your CIRCUITPY drive, check out the CircuitPython Libraries section of the Welcome to CircuitPython! learn guide.
CIRCUITPY/code.py
You can ignore the stuff about a Feather RP2350 mentioned in the code below. That's left over from some early experiments that I never got around to cleaning up. I suggest using a Feather ESP32-S3 with BASE_MODE = 1 in settings.toml so that you have the option to use the ESP-NOW gateway mode.
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2025 Sam Blenny
#
# See NOTES.md for documentation links and pinout info.
#
import board
import digitalio
import os
import time
# Record powerup time for calculating delay until I2C power is stable. The
# Feather ESP32-S3 board draws a big current spike when I2C_POWER gets
# turned on by the supervisor prior to loading boot.py on boot or wake from
# deep sleep. So, when running on a small lipo, we must allow time to
# recover from a possible brownout on the I2C bus power rail and pullups.
t0 = time.monotonic()
# ---------------------------------------------------------------------------
# Base Station Mode Override Option for settings.toml
#
# Put `BASE_MODE = 1` in settings.toml to force base station mode without
# regard to board_id. This gives me an easy way to use a Feather ESP32-S3
# sensor build as an extra base station (see also: `LCD_ENABLE = 0`).
#
BASE_MODE = False
if (base := os.getenv("BASE_MODE")) is not None:
BASE_MODE = bool(base)
# ---------------------------------------------------------------------------
# Each time board resets or wakes from deep sleep, select mode from board_id:
if BASE_MODE or board.board_id == 'adafruit_feather_rp2350':
import base_mode
base_mode.run()
elif board.board_id == 'adafruit_feather_esp32s3_nopsram':
# Toggle A1 to mark the start of code.py for the power profiler
a1 = digitalio.DigitalInOut(board.A1)
a1.switch_to_output(value=True)
# start sensor mode
import sensor_mode
sensor_mode.run(a1, t0)
else:
raise ValueError("Unexpected board_id. Halting. (check code.py)")
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2025 Sam Blenny
#
# LoRa Sensor Mode (wireless thermometer on Feather ESP32-S3)
#
import alarm
from board import A0, A1, D9, D10, SCL, SDA, SPI
import busio
from digitalio import DigitalInOut
from microcontroller import cpu
from micropython import const
import os
import struct
import sys
import time
from adafruit_max1704x import MAX17048
from adafruit_mcp9808 import MCP9808
from common import HMAC_KEY, HMAC_TRUNC, LORA_NODE, encode_, rfm9x_factory
from sb_hmac import hmac_sha1
TARGET_INTERVAL = const(540.0) # approximate seconds between transmits
WAKE_SECONDS = const(1.963) # wake runtime as measured by power profiler
BROWNOUT_DELAY = const(0.300) # time to wait for I2C bus brownout to clear
SENSOR_DELAY = const(0.067) # time to wait for I2C sensors to initialize
LOW_VOLTS = const(3.450) # threshold to begin power conservation
def run(a1, t0):
# Initialize and run in remote sensor hardware configuration (LoRa TX)
# Use GPIO outs to mark progress of run() for power analyzer logic inputs
a0 = DigitalInOut(A0)
a0.switch_to_output(value=True)
a1.value = not a1.value
# Reduce CPU frequency to save power during I2C and LoRa IO delays. The
# default frequency is 240 MHz. To avoid messing up time.monotonic(), don't
# attempt to set this below 80 MHz.
if cpu.frequency == 240_000_000:
cpu.frequency = 80_000_000
# Configure LoRa radio while we wait for I2C power to settle
cs, rst = DigitalInOut(D10), DigitalInOut(D9)
rfm95 = rfm9x_factory(SPI(), cs, rst)
a1.value = not a1.value
# Make sure we've waited long enough since boot or wake. The MCP9808
# can get confused if you talk to it too soon after switching on I2C_POWER
# when running on a small lipo (likely due to brownout).
elapsed = time.monotonic() - t0
if (diff := BROWNOUT_DELAY - elapsed) > 0:
time.sleep(diff)
a1.value = not a1.value
# Configure I2C sensors
i2c = busio.I2C(SCL, SDA, frequency=250_000) # default bus clock is slow
try:
mcp98 = MCP9808(i2c) # temperature sensor
mcp98.resolution = 1 # 0.25°C, 65ms conversion time
except ValueError:
# MCP9808() raises ValueError if it doesn't find sensor on I2C bus.
# If that happens, carry on and report bad temperature values to the
# base station. The point of this is to avoid the firmware draining
# the battery if there's a hardware issue on the I2C bus.
mcp98 = None
max17 = MAX17048(i2c) # battery fuel gauge
a1.value = not a1.value
# Allow sensors some time for first temperature conversion, etc.
time.sleep(SENSOR_DELAY)
a1.value = not a1.value
# Get truncated Unix timestamp from RTC to use as message sequence number
tstamp = time.time() & 0xffffffff
# Check sensors then build packet with message (node address, timestamp,
# volts, temperature) + truncated HMAC of message (modeled on TOTP)
v = max17.cell_voltage # lipo cell volts
max17.hibernate() # put it in low power mode
if mcp98:
f = (mcp98.temperature * 9/5) + 32 # °C -> °F
else:
# If sensor unplugged, send error value
f = -127
msg = encode_(LORA_NODE, tstamp, v, f) # pack everything into bytes
msg += hmac_sha1(HMAC_KEY, msg)[:HMAC_TRUNC] # append truncated HMAC
if v < LOW_VOLTS:
# For low volts, skip transmit to avoid damaging LiPo cell
pass
else:
# Send packet on LoRa radio with configurable repeats for better
# reliability. You can modify the array of power levels to experiment
# with tx power vs. range. Note that the node address is included in
# the message for the HMAC, but that address byte gets sent as part of
# the header rather than in the payload.
print('%d, %08x, %.2f, %.1f' % (LORA_NODE, tstamp, v, f))
for tx_pow in [23, 23]: # tx power range is 5..23 dB, default 13
a1.value = not a1.value
rfm95.tx_power = tx_pow
rfm95.send(msg[1:], node=msg[0])
time.sleep(0.005)
a1.value = not a1.value
# Prepare peripherals and pins for low power
rfm95.sleep()
a0.value, a1.value = False, False
# Calculate target sleep interval
if v < LOW_VOLTS:
# For low volts, sleep longer. This is speculatively intended to
# possibly recover from temporary voltage sag due to extreme cold.
t1 = 3600
else:
# Otherwise, use the normal interval
t1 = TARGET_INTERVAL - WAKE_SECONDS
# Begin deep sleep with ±2% random jitter added to target sleep interval
t2 = t1 + (t1 * 0.02 * os.urandom(1)[0] / 255)
alarm.exit_and_deep_sleep_until_alarms(
alarm.time.TimeAlarm(monotonic_time=time.monotonic() + t2))
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2025 Sam Blenny
#
# LoRa Base Station
#
# See NOTES.md for related documentation
#
import board
from board import SCL, SDA, SPI
import busio
from collections import namedtuple
from digitalio import DigitalInOut
from microcontroller import cpu
import os
import struct
import time
from adafruit_character_lcd.character_lcd_i2c import Character_LCD_I2C
from common import HMAC_TRUNC, HMAC_KEY, decode_, rfm9x_factory
from sb_hmac import hmac_sha1
# ---------------------------------------------------------------------------
# Options for settings.toml
# - `LCD_BACKLIGHT = 1` enable character LCD backlight
# - `ESPNOW_GATEWAY = 1` retransmit LoRa packets as ESP-NOW packets
# - `ESPNOW_RX = 1` use ESP-NOW radio on builds that don't have a LoRa radio
#
LCD_BACKLIGHT = False
if (backlight := os.getenv("LCD_BACKLIGHT")) is not None:
LCD_BACKLIGHT = bool(backlight)
ESPNOW_GATEWAY = False
if (gateway := os.getenv("ESPNOW_GATEWAY")) is not None:
ESPNOW_GATEWAY = bool(gateway)
ESPNOW_RX = False
if (display_ := os.getenv("ESPNOW_RX")) is not None:
ESPNOW_RX = bool(display_)
# ---------------------------------------------------------------------------
Report = namedtuple("Report",
["node_id", "timestamp", "volts", "temp_f", "rssi", "snr"])
class SensorReports:
# This tracks reports from multiple sensors to format them for a 2x16 LCD
def __init__(self):
self.ready_timestamp = time.monotonic()
self.lora_nodes = {}
self.last_report = None
def new_report(self, node_id, volts, temp_f, rssi, snr):
r = Report(node_id, time.monotonic(), volts, temp_f, rssi, snr)
self.lora_nodes[node_id] = r
self.last_report = r
def freshness_tag(self, seconds):
minutes = round(max(0, seconds)) // 60
hours = seconds // 3600 # 60*60=3600 seconds/hour
days = seconds // 86400 # 60*60*24=86400 seconds/day
if days > 0:
return '%dd' % days
elif hours > 0:
return '%dh' % hours
else:
return '%dm' % minutes
def __str__(self):
# Format a two line status string for display on the 2x16 LCD
now = time.monotonic()
if len(self.lora_nodes) == 0:
tag = self.freshness_tag(now - self.ready_timestamp)
return 'Ready %s' % tag
# Display latest report info formatted for 2x16 LCD
r = self.last_report
centivolts = round(r.volts * 100)
tag = self.freshness_tag(now - r.timestamp)
return '%d %d %d %s\n r%d s%.1f' % (
r.node_id, r.temp_f, centivolts, tag, r.rssi, r.snr)
def run():
# Initialize and run in base station hardware configuration (LoRa RX)
# Print a banner showing which modes are active from settings.toml
modes = ['=== Base Station']
if ESPNOW_GATEWAY:
modes.append(' + ESP-NOW Gateway')
if ESPNOW_RX:
modes.append(' + ESP-NOW Display')
modes.append(' ===')
banner = ''.join(modes)
hr = '=' * len(banner)
print()
print(hr)
print(banner)
print(hr)
# Reduce CPU frequency so the board runs cooler. The default ESP32-S3
# default frequency is 240 MHz. To avoid messing up time.monotonic(), don't
# attempt to set this below 80 MHz.
if 'esp32s3' in board.board_id:
cpu.frequency = 80_000_000
# Try to initialize LoRa raido on SPI unless ESPNOW_RX option is turned on
rfm95 = None
if not ESPNOW_RX:
cs, rst = DigitalInOut(board.D10), DigitalInOut(board.D9)
rfm95 = rfm9x_factory(SPI(), cs, rst)
rfm95.node = 255 # receive from all stations (nodes)
# Try to initialize an I2C Character LCD. This is an optional feature
# to assist with placing the antenna for good RSSI & SNR. The main output
# for logging and charting goes over USB serial to a Raspberry Pi.
lcd = None
try:
i2c = busio.I2C(SCL, SDA, frequency=250_000) # default clock is slow
cols, rows = 16, 2
lcd = Character_LCD_I2C(i2c, cols, rows)
lcd.clear()
if LCD_BACKLIGHT:
lcd.backlight = True
lcd.message = 'Ready 0m'
except RuntimeError as e:
# This can happen on QT Py if you don't have pullups connected:
# - RuntimeError: No pull up found on SDA or SCL; check your wiring
pass
except ValueError as e:
# This happens if no I2C LCD backpack is present at 0x20
pass
# If one of the ESP-NOW modes is active, try to initialize wifi radio
espnow_obj = None
if ESPNOW_GATEWAY or ESPNOW_RX:
import espidf
import espnow
import wifi
# Force wifi radio onto channel 6 because esp-now needs that
wifi.radio.start_ap(" ", "", channel=6, max_connections=0)
wifi.radio.stop_ap()
# Initialize the ESP-NOW object in broadcast mode
espnow_obj = espnow.ESPNow()
peer = espnow.Peer(mac=b'\xff\xff\xff\xff\xff\xff', channel=6)
espnow_obj.peers.append(peer)
# -----
# BEGIN Receiver Loop
# -----
reports = SensorReports()
EXPECTED_SIZE = 4 + 6 + HMAC_TRUNC # size of header + message + MAC
seq_list = {}
TIMEOUT = 60
while True:
# Listen for message from ESP-NOW or LoRa radio, depending on settings
if ESPNOW_RX:
# Radio for RX is ESP-NOW
# Since espnow.read() is buffered async, put it in a loop to make
# it act like a blocking read in the style of RFM9x.receive()
stop_time = time.monotonic() + TIMEOUT
data = None
while time.monotonic() < stop_time:
if len(espnow_obj) > 1:
packet = espnow_obj.read()
data = packet.msg
espnow_rssi = packet.rssi
# ESP-NOW packets have int16_t rssi and snr at the end of
# the payload so the ESP-NOW receiver can see how good the
# signal was at the LoRa receiver on the gateway
if len(data) == EXPECTED_SIZE + 4:
i = EXPECTED_SIZE
# pop the LoRa rssi and snr values
snr = struct.unpack('>h', data[i+2:i+4])[0]
rssi = struct.unpack('>h', data[i:i+2])[0]
data = data[:EXPECTED_SIZE]
else:
rssi = packet.rssi
snr = 0
break
else:
time.sleep(0.01)
else:
# Radio for RX is LoRa
data = rfm95.receive(with_header=True, timeout=TIMEOUT)
rssi = rfm95.last_rssi # signal strength
snr = rfm95.last_snr # signal to noise ratio
# Handle the packet (check length, verify HMAC, etc).
# Data should be None for timeout or a bytearry of header+packet.
if data:
# Skip wrong-size packets
if EXPECTED_SIZE != len(data):
continue
# Re-assemble message including the node address byte from header.
# Message format: node address, sequence number, volts, °F.
msg = data[1:2] + data[4:-HMAC_TRUNC]
msg_hash = data[-HMAC_TRUNC:]
node, seq, v, f = decode_(msg)
# Verify HMAC of message (configure nodes to share the same key)
if msg_hash != hmac_sha1(HMAC_KEY, msg)[:HMAC_TRUNC]:
continue
# Check for monotonic sequence number (per node)
# CAUTION: After boot, this accepts the first sequence number it
# sees for each node address (values don't persist across reset)
seq_check = False
prev_seq = seq_list.get(node)
if (prev_seq is None) or (prev_seq < seq):
seq_check = True
seq_list[node] = seq
# Now packet is authenticated (HMAC) but possibly a duplicate.
# Print decoded packet regardless, as it can be useful to see
# RSSI and SNR for retry packets.
check_tag = "OK" if seq_check else "DUP"
radio_tag = "ESPNOW" if ESPNOW_RX else "LORA"
print('%s: %d, %.1f, %d, %08x, %.2f, %.0f, %s' %
(radio_tag, rssi, snr, node, seq, v, f, check_tag))
# -----
# END receive section
# BEGIN display / repeater / gateway section
# -----
# Don't do any of the stuff below for duplicate packets that failed
# the monotonic sequence number check
if not seq_check:
continue
# Update the reports data structure
reports.new_report(node, v, f, rssi, snr)
# If there's a character LCD available, show report message
if lcd:
lcd.clear()
lcd.message = str(reports)
# If ESPNOW_GATEWAY mode is enabled, check the hop count then
# retransmit the packet if hops are low enough.
to = data[0]
from_ = data[1]
id_ = data[2]
hops = data[3] & 0x0f # Low 4 bits of flags field of LoRa header
max_hops = 1
# Slice payload out of header+payload
payload = data[4:]
# Sush RSSI & SNR at end of payload
payload += struct.pack('>hh', max(-256, min(0, round(rssi))),
max(-256, min(256, round(snr))))
if ESPNOW_GATEWAY and seq_check and hops < max_hops:
new_hop = min(16, hops+1)
enow_msg = bytearray([to, from_, id_, new_hop]) + payload
try:
espnow_obj.send(enow_msg, peer)
except Exception as e:
print('E', e) # Log the error and keep going
else:
# For receive timeout, update report age tags on LCD
if lcd:
lcd.message = str(reports)
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2025 Sam Blenny
#
# See NOTES.md for related documentation
#
from micropython import const
import os
import struct
from adafruit_rfm9x import RFM9x
# -------------------------------------------------------------------------
# Config Options
# Sensor Float Range Limits (for encoding LoRa messages)
BATT_LO = const(3.2)
BATT_HI = const(4.4)
TEMP_LO = const(-128)
TEMP_HI = const(127)
HMAC_TRUNC = const(4) # size in bytes of truncated hash (similar to HOTP)
# Defaults for optional settings.toml variables
HMAC_KEY = b'Please set your own HMAC_KEY in settings.toml'
LORA_NODE = 0 # LoRa node address (range 0..254)
# Read settings.toml
if key := os.getenv("HMAC_KEY"):
HMAC_KEY = key
if lora_node := os.getenv("LORA_NODE"):
LORA_NODE = int(lora_node) & 0xff
# -------------------------------------------------------------------------
def rfm9x_factory(spi, cs, rst):
# Configure an RFM9x object for the 900 MHz RFM95W LoRa FeatherWing.
r = RFM9x(spi, cs, rst, 915.0, baudrate=5_000_000, agc=False, crc=False)
r.tx_power = 13 # range 5..23 dB, default: 13
r.spreading_factor = 10 # default: 7
r.preamble_length = 16 # range uint16, default: 8
r.coding_rate = 8
return r
def scale_to_byte(val, lo, hi):
# Scale and convert float in range lo..hi to integer in range 0..255
return min(255, max(0, round(255 * (val - lo) / (hi - lo))))
def scale_from_byte(b, lo, hi):
# Scale and convert integer in range 0..255 to float in range lo..hi
return (b * (hi - lo) / 255) + lo
def encode_(node, count_, volt, degree_F):
# Compress message counter and float measurements into bytes value
v = scale_to_byte(volt, BATT_LO, BATT_HI)
t = scale_to_byte(degree_F, TEMP_LO, TEMP_HI)
return struct.pack('>BLBB', node & 0xff, count_, v, t)
def decode_(data):
# Expand bytes into float measurements with correct range
node, count_, v_byte, t_byte = struct.unpack('>BLBB', data)
v = scale_from_byte(v_byte, BATT_LO, BATT_HI)
t = scale_from_byte(t_byte, TEMP_LO, TEMP_HI)
return node, count_, v, t
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
#
# Related Docs:
# - https://datatracker.ietf.org/doc/html/rfc2104 (HMAC RFC)
# - https://nvlpubs.nist.gov/nistpubs/fips/nist.fips.198-1.pdf (FIPS PUB 198-1)
#
import hashlib
def hmac(K, text, hash_fn, B, L):
# Return HMAC digest for key and text using the named hash algorithm.
#
# The non-pythonic parameter names correspond to the notation used in
# RFC 2104 and FIPS 198-1. The point of doing it this way is to make
# it easier to compare this implementation against the specs.
# Params:
# K: secret key (UTF-8 string or bytes)
# text: hash function input data (UTF-8 string or bytes)
# hash_fn: name of hash function for use with hashlib.new()
# B: block size in bytes of hash function input
# L: byte-length of hash function output
# FIPS 198-1 Crypto Math Notation:
# ⨁: plus sign in a circle means bitwise XOR
# ||: two vertical bars means concatenate bitstreams
# Convert text from string to bytes if needed
if isinstance(text, str):
text = text.encode('utf-8')
# Convert key from string to bytes if needed
if isinstance(K, str):
K = K.encode('utf-8')
# Prepare key as block sized byte buffer (hash or pad as needed)
if len(K) == B:
K0 = K
elif len(K) > B:
H = hashlib.new('sha1')
H.update(K)
K0 = H.digest() + (b'\x00' * (B - L))
elif len(K) < B:
K0 = K + (b'\x00' * (B - len(K)))
# Calculate inner hash: H((K₀ ⨁ ipad)||text)
K0_xored = bytearray([b ^ 0x36 for b in K0])
H = hashlib.new('sha1')
H.update(K0_xored)
H.update(text)
inner_hash = H.digest()
# Calculate outer hash: H((K₀ ⨁ opad)||inner_digest)
K0_xored = bytearray([b ^ 0x5c for b in K0])
H = hashlib.new('sha1')
H.update(K0_xored)
H.update(inner_hash)
return H.digest()
def hmac_sha1(key, data):
# HMAC-SHA1 digest for key and data
return hmac(K=key, text=data, hash_fn='sha1', B=64, L=20)
import board
import digitalio
import supervisor
import usb_hid
usb_hid.disable()
# Toggle A0 to mark start of boot.py for power profiler
a0 = digitalio.DigitalInOut(board.A0)
a0.switch_to_output(value=True)
# Disable status bar to stop the default USB serial escape sequence noise
try:
supervisor.status_bar.console = False
supervisor.status_bar.display = False
except Exception:
pass
PPK2 Power Profiling
I spent a long time iterating over small code changes, measuring their effects using Nordic Power Profiler and a PPK2 power analyzer. If you want all the step by step details, check out samblenny/lora-greenhouse-monitor/power_analysis/README.md in my GitHub repo for this project. For this writeup, I've included a small excerpt with Power Profiler screenshots showing power use for my second range test.
The main things I learned are:
- For IO bound code that spends a lot of time waiting on I2C and SPI perhipherals, reducing
microcontroller.cpu.frequencysaves a lot of power. But, it's more efficient to let imports and the bytecode compiler run at full speed. - A significant portion of the total charge per wake cycle gets spent on booting the CircuitPython VM, so there's not really anything to do about that.
- To minimize deep sleep current, be sure to put I2C and SPI peripherals in their lowest power mode before starting deep sleep. For example,
RFM9x.sleep()is much better thanRFM9x.idle(). - Something about switching
board.I2C_POWERon the Feather ESP32-S3 causes a large (500+ mA peak) transient. The CircuitPython VM does this before running boot.py, but you can also trigger the transient in your own code. You can verify this for yourself by running code with a loop to toggleI2C_POWERthensleep()for a while. The transient seems to cause a brownout on the I2C bus power when the board is running on a small LiPo cell (e.g. 400 mAh). For me this caused faulty MCP9808 temperature readings until I re-ordered my code. Waiting 300ms after the start of code.py before attempting to talk to anything on the I2C bus resolved the faulty temperature reading problem. - For RFM95 LoRa transmitting, you have a variety of options to improve the received signal quality at your receiver. Some of those options increase the time spent transmitting each symbol, and some don't. For example, increasing spreading factor by 1 boosts receiver sensitivity by about 3 dB, but it also doubles the on-air time. So, boosting your signal quality by increasing spreading factor incurs the current penalty of from having the cpu running during the extra transmit time. On the other hand, getting more dB by boosting
RFM95.tx_powerdoesn't increase your on-air time. - Minimizing the number of bytes in each RFM95 packet is an easy way to save power. But, if you take it too far, you will sacrifice important message integrity and security properties. I chose to spend 8 bytes of packet payload on a timestamp and a truncated HMAC hash (modeled on how TOTP tokens work). For those 8 bytes, I get message authentication, protection against replay attacks, and an easy way to filter out my own repeated packets that I use for reliability and range testing.
Sensor & Nordic PPK2 Wiring
For the power connection, I made an adapter cable from DuPont connectors to a JST PH 2-pin battery connector to fit the Feather's battery jack.
For the logic port connections, I lifted the RFM95W FeatherWing slightly out of its socket so that I could clip probe hooks around the pins. After attaching the hooks, I pressed the FeatherWing back down snugly against the plastic of the probes for better mechanical stability. I used the probe hooks that came with a Saleae Logic 8. You could also use the hooks from a Bus Pirate probe cable set. Or, you could put the Feather and FeatherWing on a FeatherWing Tripler backplane and use regular DuPont wires.
NOTE: You need to hook up both the GND (to Feather GND) and VCC (to Feather 3V) or the logic port inputs won't work. I didn't see an explanation in the Nordic documentation, but it acts like there's an isolator or logic level converter powered by the device under test.
Wake Cycle 2.667 s, 221.55 mC
This shows how a full wake cycle lasts for about 2.667 seconds and uses about 221.55 millicoulombs (mC) of charge from the battery. The gray selected are to the right is the time spent on boot.py, code.py, and sensor_mode.py. Logic port input 0 gets turned on by boot.py. Logic port input 1 gets toggled by code.py and sensor_mode.py as the code progresses. As you can see, about half of the wake cycle time, and about 30% of the charge, gets spent on booting the CircuitPython VM.
Deep Sleep 22µA
This shows the average deep sleep current is about 22µA. Note this would be much, much higher if I didn't call RFM9x.sleep() before starting deep sleep. Calling MAX17048.hibernate() also helps.
This page (LoRa Wireless Greenhouse Monitor) was last updated on November 24, 2025.