This guide shows how to build cheap data loggers to help keep plants happy in greenhouses or other indoor growing spaces. I developed this design to help a community garden group control temperatures in greenhouses for starting new plants in winter. By charting the temperatures, we were able to identify and fix problems with air sealing and heater thermostat settings. This logger design uses manual data collection over USB serial because there is no WiFi at the greenhouses.
Additional Tools & Materials
Tools:
- Soldering tools (soldering iron, diagonal flush cutters, wire strippers, etc.)
- Screwdriver with Phillips bit (or whatever matches your IP65 enclosure screws)
- Drill and hole bit set to make a hole in IP65 enclosure for cable gland
- Knife, file, or deburring tool to clean up edges of the cable gland hole
- Anti-static wrist strap and work mat
Materials:
- Solder
- Polyimide (Kapton) Tape, 1 cm wide: Adafruit #3057 or similar to protect against shorts and battery punctures
- 3M Scotch Vinyl Electrical Tape 35: Good quality electrical tape for sealing
- Ziploc bag: small resealable zip-top bag to hold desiccant packs while enclosure is unsealed
Optional
Because I didn't want this guide to be too long, it only documents how to build a temperature logger using an Adafruit ESP32-S3 Feather dev board. But, I've written the code to work on other ESP32-S3 boards. Adafruit Metro ESP32-S3 works, including built-in battery voltage monitoring. Adafruit QT Py ESP32-S3 works, but it lacks the MAX17048 battery monitor chip, so you'll have to check voltage manually with multimeter. Other ESP32-S3 boards may also work. If you want to use a different ESP32-S3 board, just adapt the enclosure, jumper, and 1-wire sensor connections as needed.
Wiring
If you are unfamiliar with soldering stacking headers, you might want to read:
Fritzing Diagram
This diagram shows the core circuit for the temperature logger, omitting the pin header and perma-proto stuff, and using a toggle switch to represent the jumper:
Headers, Jumper, and 1-Wire Resistor
- Solder male header pins onto the Feather board. (use a breadboard to hold the pins)
- Put female headers onto the male pins.
- Assemble the Feather board and headers onto a small mint tin size perma-proto board. Secure the Feather and headers to the perma-proto board with electrical tape or Kapton tape, being careful not to cover the pins on the bottom of the perma-proto.
- Solder the female headers to the perma-proto board and remove the tape.
- Prepare a 2-position chunk of male header and solder it to the perma-proto board on the breadboard rows for Feather pins GND and A0. This is where you will attach the jumper shunt that wakes the logger from deep sleep.
- Solder the 4.7 kΩ resistor from your DS18B20 kit to the perma-proto board on the rows for Feather pins 3V and A1. (The Feather ESP32-S3 has two 3V pins. Use the one next to RST.)
- Unplug the Feather board from the perma-proto board and set it aside.
- Prepare to trim the DS18B20 wires to length by routing them through the perma-proto board as shown in the picture below: red wire to Feather 3V pin, blue or black wire to Feather GND pin, yellow or white wire to Feather A1 pin. To avoid interfering with the battery, route the wires up through the bottom of the board, then back down. To avoid possible shorts if the wire insulation gets damaged, don't route the wires through two holes on the same breadboard row (check traces on the bottom of the board).
DS18B20 Temperature Sensor
- Use diagonal flush cutters to trim the temperature sensor wires to a suitable length.
- Carefully remove the wires from the perma-proto board.
- Use wire strippers to remove a short length of insulation from the end of each wire.
- Tin the ends of the wires with solder.
- Put the wires back in the perma-proto board in the same arrangement as before.
- Solder the DS18B20 wires in place: red wire to Feather 3V pin, blue or black wire to Feather GND pin, yellow or white wire to Feather A1 pin.
- Gently arrange the wires flush against the perma-proto board.
- Tape the wires down with Kapton tape on both sides of the perma-proto board. Use Kapton tape to make a strain relief where the sensor wire meets the perma-proto board. This is meant to avoid wiring damage when installing the battery and handling the board to download data logs over USB.
- On the bottom side of the perma-proto board, cover all the pins and breadboard connecting traces with Kapton tape. This is meant to protect against possible shorts or battery punctures.
Enclosure & Final Assembly
- Drill a hole, approximately in the center, on one of the short ends of the IP65 junction box. Be sure to check the outside diameter of the threads on the cable glands that come with your box and use a suitable sized hole drill bit. On my boxes, the outside thread diameter was 15.7 mm, so I used a 5/8" spade bit.
- If the hole has rough edges that might interfere with the cable gland seal, smooth them out with a knife, file, or deburring tool. Be careful not to enlarge the hole.
- Install a cable gland in the hole: remove nut, insert threaded section through the hole you just drilled, replace nut, tighten nut.
- Plug the Feather board into the proto-board.
- Install the jumper shunt for the GND and A0 pins.
- (optional) Cut a piece of Tamiya Universal Plate (or other similar material) to fit in the bottom of the junction box, attach the perma-proto board to it with Nylon M3 screws, then attach standoffs to the plate to make the whole assembly fit just inside the height of the closed junction box lid. The point is to make it so the board can't move around inside the box, reducing the possibility of accidents like unintentionally pressing the RESET button by jamming it against the sensor cable or enclosure lid.
- Put the battery in the gap between the Feather and perma-proto boards.
- Plug in the battery.
- Thread the temperature sensor cable through the cable gland, leaving enough slack to make a loop or two of cable inside the box.
- Put the logger board assembly in the box and arrange the cable loops so everything will fit when the lid is shut. Be careful not to accidentally press the RESET button.
- Check the inside diameter of the cable gland seal against the outside diameter of the temperature sensor cable. If the gap is too big, carefully adjust the cable loop and pull out some of the slack, wrap the temperature sensor cable in several layers of electrical tape to make up the difference, then push the cable back in so the tape fits inside the seal. Be careful to avoid creases in the tape.
- Tighten the cable gland seal.
- Get a 2 gram indicating silica gel desiccant pack, seal it in a Ziploc bag, and set it aside for later
- If your junction box did not come with a pre-installed lid seal, install the seal.
Install CircuitPython 9.2.1
The code for this project was developed and extensively tested on CircuitPython 9.2.1, which works well for both USB serial connectivity and low deep sleep current. CircuitPython 9.2.2 has a known issue with USB connectivity on ESP32-S3, so you should avoid 9.2.2. Probably 9.2.3 and later will be fine, but I haven't tested that.
To download the CircuitPython 9.2.1 .BIN files you can use this direct link to circuitpython.org:
Adafruit Feather ESP32-S3 No PSRAM (9.2.1 .BIN file)
If you prefer to try running the latest version of CircuitPython, you can use the .BIN download button from the board detail page at circuitpython.org:
Adafruit Feather ESP32-S3 No PSRAM (board page)
Once you have the .BIN file, follow the instructions in the Web Serial ESPTool section of the "CircuitPython on ESP32 Quick Start" learn guide to update your board: first erase the flash, then program the .BIN file.
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 9.x folder.
- Open the CircuitPython 9.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.
After copying the files from the project bundle, your CIRCUITPY drive should contain the files and directories shown in this screenshot:
CIRCUITPY/code.py
The code.py file has main(), which runs each time the Feather wakes from deep sleep, and admin_mode(), which runs when the GND to A0 jumper is active.
- In logging mode (A0 not grounded), the logger runs
main()which records a temperature measurement then enters deep sleep for 20 minutes (seeINTERVAL_S). When the logger wakes up from deep sleep, it starts over at the beginning ofmain(), which is whymain()does not have the normal type of event loop. - In admin mode (A0 is grounded), the logger wakes from deep sleep, activates the USB interface, and begins flashing the current battery voltage in Morse code on the LED. The LED voltage messages are disabled to conserve power when the battery voltage drops below 3.55V (
LOW_CV). - See util.py below for a description of additional features available by manually importing util.py from the CircuitPython REPL.
# SPDX-License-Identifier: MIT
import alarm
from alarm import (
light_sleep_until_alarms, exit_and_deep_sleep_until_alarms
)
from alarm.pin import PinAlarm
from alarm.time import TimeAlarm
from board import A0, A1
from digitalio import DigitalInOut, Direction, Pull
from micropython import const
import time
from time import monotonic, sleep
from datalogger import battery_centivolts, temp_f
from sleepmem import SleepMem
# The logging interval in seconds
INTERVAL_S = const(60 * 20)
# Target discharge centi-Volts to prepare for storing the logger
STORAGE_CV = const(380)
# Low voltage threshold (centi-Volts) for power conservation features
LOW_CV = const(355)
# Value to mark unavailable voltage measurement
NONE_CV = const(270)
def admin_mode(a0_gnd):
# Admin mode pauses logging and adjusts sleep mode usage. The point is to
# allow for USB connections (which don't work during deep sleep) and make
# it easier to tend the batteries.
# Putting these imports here reduces the amount of work that happens when
# waking from deep sleep in normal logging mode. (save some battery)
from redled import RedLED
import gc
# Blink logger status in morse on the LED for as long as the A0 USB-mode
# jumper is grounded and the battery is not too low.
led = RedLED()
while not a0_gnd.value:
cV = battery_centivolts() or NONE_CV
# Unless battery is low, send centi-Volts in Morse code on LED
if cV and ((cV > LOW_CV) or (cV == NONE_CV)):
gc.collect()
msg = ' ^ %d %d + ' % (cV, cV)
print(msg)
for c in msg:
if not a0_gnd.value:
led.morse_char(c)
# Wait for a bit.
# - When battery is above storage voltage, use time.sleep() to
# intentionally drain battery faster. This can be used to
# prepare for storing the logger.
# - When battery is below storage voltage, use power saving
# light sleep (which still allows USB connections)
if cV and (cV > STORAGE_CV):
# Higher current draw
led.value = True
sleep(8)
else:
# Reduced current draw
led.value = False
light_sleep_until_alarms(
TimeAlarm(monotonic_time=monotonic() + 15)
)
# Be sure LED is off
led.value = False
led.deinit()
def main():
# This will run each time the board wakes from deep sleep.
# If A0 is jumpered to GND, this admin mode loop will activate. Admin mode
# interrupts deep sleep to allow for making a USB connection to to download
# logs, etc.
with DigitalInOut(A0) as a0_gnd:
a0_gnd.direction = Direction.INPUT
a0_gnd.pull = Pull.UP
if not a0_gnd.value:
admin_mode(a0_gnd)
# Normal temperature logging mode...
# Record a measurement
sm = SleepMem()
tempF = temp_f()
cV = battery_centivolts() or 0
print("DS18B20: %d °F, batt: %d cV" % (tempF, cV))
sm.append_data(time.time(), tempF, cV)
# Do an ESP32 deep sleep to save battery power
exit_and_deep_sleep_until_alarms(
TimeAlarm(monotonic_time=monotonic() + INTERVAL_S),
PinAlarm(pin=A0, value=False, pull=True)
)
# This doesn't return (exit to deep sleep)
main()
CIRCUITPY/datalogger.py
The datalogger.py file has functions to check the battery voltage and the DS18B20 temperature. Since I wrote the firmware to work on four different ESP32-S3 boards, there is some logic to detect which hardware features are present according to the value of board.board_id.
- On boards which lack a MAX17048 battery monitor chip, the
battery_centivolts()returnsNoneinstead of a voltage. - The
temp_f()function returns DS18B20 temperature, converted to Fahrenheit, as a signed 8-bit integer. If there is a problem communicating with the the sensor, the return value is0x80(-128).
# SPDX-License-Identifier: MIT
from board import board_id, A1, I2C
from digitalio import DigitalInOut
from micropython import const
from rtc import RTC
import time
from time import sleep
from adafruit_onewire.bus import OneWireBus
from adafruit_ds18x20 import DS18X20
from adafruit_max1704x import MAX17048
# Special value that gets recorded when there's a problem with the sensor
NO_DATA = const(0x80)
# Pin for the 1-wire bus
ONEWIRE_PIN = A1
# Singleton for the 1-wire bus object to avoid pin in use errors
_1WIRE = None
def has_max17():
# Does this board have a MAX17048 battery fuel gauge? (return True/False)
return board_id in (
'adafruit_metro_esp32s3',
'adafruit_feather_esp32s3_nopsram',
)
def has_A3_divider():
# Does this board have a battery voltage divider on A3? (return True/False)
return board_id in (
'adafruit_qtpy_esp32s3_4mbflash_2mbpsram',
'adafruit_qtpy_esp32s3_nopsram',
)
def battery_centivolts():
# Return battery voltage (cV), or None if measurement is unavailable.
# Note that 3.70V = 370cV, 4.19V = 419cV, etc. Using centi-Volts makes it
# more convenient to send the voltage measurement in Morse code.
cV = None
if has_max17():
with I2C() as i2c:
max17 = MAX17048(i2c)
max17.wake()
sleep(0.5)
cV = round(max17.cell_voltage * 100)
elif has_A3_divider():
pass
return cV
def temp_f():
# Return a temperature measurement in Fahrenheit (int8)
global _1WIRE
if not _1WIRE:
_1WIRE = OneWireBus(ONEWIRE_PIN)
# Loop over all devices on the 1-wire bus
F = NO_DATA
for d in _1WIRE.scan():
if d.family_code != 0x28:
# Skip devices that don't have the DS18B20 family code
continue
# Return temperature of first DS18B20, converting ¡C to ¡F
ds18b20 = DS18X20(_1WIRE, d)
F = round((ds18b20.temperature * 1.8) + 32)
return F
CIRCUITPY/redled.py
The redled.py file implements the RedLed class for sending numeric Morse code messages on the board's LED or Neopixel.
-
RedLED.__init__()checks for whetherboard.LED,board.NEOPIXEL, andboard.NEOPIXEL_POWERare present to decide which type of LED to use. -
RedLED.morse_char()sends one numeric Morse code character (alphabet is limited to digits 0-9 along with the KA and AR prosigns). This is meant for sending 3-digit battery voltages using centi-Volt units.
# SPDX-License-Identifier: MIT
import board
from digitalio import DigitalInOut
from neopixel_write import neopixel_write
from time import sleep
MORSE = {
'^': (3, 1, 3, 1, 3), # -.-.- start of transmission prosign (CT, KA)
'+': (1, 3, 1, 3, 1), # .-.-. end of transmission prosign
'0': (3, 3, 3, 3, 3), # -----
'1': (1, 3, 3, 3, 3), # .----
'2': (1, 1, 3, 3, 3), # ..---
'3': (1, 1, 1, 3, 3), # ...--
'4': (1, 1, 1, 1, 3), # ....-
'5': (1, 1, 1, 1, 1), # .....
'6': (3, 1, 1, 1, 1), # -....
'7': (3, 3, 1, 1, 1), # --...
'8': (3, 3, 3, 1, 1), # ---..
'9': (3, 3, 3, 3, 1), # ----.
}
class RedLED:
def __init__(self):
# Find a way to blink a red LED (led pin, neopixel, etc).
#
# This is meant to work on a few different boards:
# - Metro S3 with LED pin (board has neopixel, but power jumper is cut)
# - Qt Py S3 neopixel with neopixel power pin
# - Feather S3 neopixel with neopixel power pin
# Decide which type of LED indication to use
b = board
self.hasled = hasattr(b, 'LED')
self.hasneo = hasattr(b, 'NEOPIXEL') and hasattr(b, 'NEOPIXEL_POWER')
self.value_ = False
self.led = self.neo = self.neopow = None
if self.hasled:
# First choice: Board has an LED pin
self.led = DigitalInOut(board.LED)
self.led.switch_to_output(value=False)
elif self.hasneo:
# Second choice: Board has NEOPIXEL and NEOPIXEL_POWER pins
self.neo = DigitalInOut(board.NEOPIXEL)
self.neopow = DigitalInOut(board.NEOPIXEL_POWER)
self.neopow.switch_to_output(value=False)
else:
# Board doesn't have a useable red led option
print("WARNING: RED LED NOT SUPPORTED ON THIS BOARD")
def deinit(self):
# Release CircuitPython pins
if self.led:
self.led.deinit()
if self.neo:
self.neo.deinit()
if self.neopow:
self.neopow.deinit()
@property
def value(self):
# Get value of LED: True means on, False means off
return self.value_
@value.setter
def value(self, val):
# Set LED value: True means on, False means off
self.value_ = val
if self.hasled:
# Set LED pin on or off
self.led.value = val
elif self.hasneo:
# No LED pin, but board does have NEOPIXEL and NEOPIXEL_POWER pins
if val:
# Set neopixel on red
grb_red = bytearray([0, 5, 0]) # red (GRB order)
self.neopow.value = True
sleep(0.001) # wait 5ms for neopixel power to stabilize
neopixel_write(self.neo, grb_red)
else:
# Set neopixel off
self.neopow.value = False
else:
# Board doesn't have a useable red led option
self.val_ = False
print("WARNING: RED LED NOT SUPPORTED ON THIS BOARD")
def morse_char(self, c):
# Send character in morse code using the LED.
# CAUTION: This uses a limited alphabet intended for numbers only.
#
# ITU-R 1677 Morse Code Timing: 3 dots per dash, 1 dot symbol gap,
# 3 dot character gap, 7 dot word gap
#
# WPM Calculations, PARIS method @ 50 dots per word:
# - dot time = (60 s) / (50 dots) / wpm = 1.20/wpm s/dot
# - 5 WPM: 1.2/5 = 0.24 s/dot
# - 8 WPM: 1.2/8 = 0.15 s/dot
#
dot = 0.17 # 7 WPM
slp_ = sleep
if c == ' ':
# Gap should be 7 dots worth, but assume this comes after a
# character that ended with a 3 dot gap. So, 3+4=7.
self.value = False
slp_(4 * dot)
elif c in MORSE:
# Loop over the dot and dash symbols of a character
for on_dots in MORSE[c]:
self.value = True # on for 1 or 3 dot lengths
slp_(on_dots * dot)
self.value = False # off for 1 dot length
slp_(dot)
# Finish the gap between characters (loop ended with 1, so 1+2=3)
slp_(2 * dot)
CIRCUITPY/sleepmem.py
The sleepmemp.py file implements the SleepMem class for managing access to the 4096 byte ESP32-S3 sleep memory region (alarm.sleep_memory) which stores the log of temperature measurements. Many of the methods in SleepMem have to do with scaling timestamps, battery voltages, and temperatures so that each measurement can be saved as 4 bytes.
- The first 96 bytes of sleep memory are reserved for header values
- The logger's maximum capacity is (4000 bytes) / (4 bytes per measurement) = 1000 measurements. Since the interval between measurements is approximately 20 minutes, the memory will fill up after (1000 x 20 minutes) / (60 x 24 minutes per day) = 13.8 days. Based on my testing, there's a reasonable chance the battery might actually last that long, but usually I only log for 3-4 days at a time.
- The
sleep_memory[EPOCH]header value stores a custom timestamp epoch value that gets set byutil.set_clock(). Timestamps typically use at least 32 bits (4 bytes) with a standard epoch of Jan 1, 1970. Using a custom epoch lets timestamps fit in fewer bits, so the logger can record more measurements. - The
sleep_memory[END]header value stores the index to the end of the data log - Voltage measurements in the range of 2.70V (270cV) to 4.30V (430cV) are scaled to fit into an 8-bit unsigned integer by the
scale_centivolts()andunscale_centivolts()methods. - The
append_data()method saves a timestamped temperature and voltage measurement using bitwise operations andstruct.packto fit a 16-bit time, an 8-bit temperature, and an 8-bit voltage into a single 32-bit value.
# SPDX-License-Identifier: MIT
from alarm import sleep_memory
from micropython import const
from struct import pack, unpack
import time
class SleepMem:
# Use ESP32-S3 4096 byte sleep memory as buffer for measurements.
HEADER = const(0)
DATA = const(96)
END = const(HEADER) # length 2
EPOCH = const(END + 2) # length 8
TIME_SHIFT = const(5) # quantize times to 32 seconds
TIME_MASK = const(0xFFFF) # max time is ((2**16-1)<<5)/60/60/24 = 24 days
def __init__(self):
# Set some non-terrible defaults on first boot or after a hard reset
if self.end == 0:
self.end = DATA
if self.epoch == 0:
self.epoch = time.time()
@property
def end(self):
# Getter for index of end of measurements in buffer (last is at end-1)
return unpack('<H', sleep_memory[END:END+2])[0]
@end.setter
def end(self, val):
# Setter for index of end of measurements in buffer.
# This clips the value to fit in the range DATA..len(sleep_memory).
clipped_val = max(DATA, min(val, len(sleep_memory)))
sleep_memory[END:END+2] = pack('<H', clipped_val)
@property
def epoch(self):
# Getter for epoch timestamp (32-bit unsigned int)
return unpack('<I', sleep_memory[EPOCH:EPOCH+4])[0]
@epoch.setter
def epoch(self, val):
# Setter for epoch timestamp (32-bit unsigned int)
sleep_memory[EPOCH:EPOCH+4] = pack('<I', val & 0xFFFFFFFF)
def scale_centivolts(self, cV):
# Scale a float battery voltage in centi-Volts to fit in uint8_t
if not ((type(cV) == int) and (270 < cV < 430)):
print("WARNING: VOLTS OUT OF RANGE", cV)
return 0
return cV - 270
def unscale_centivolts(self, u8_val):
# Invert scale_centivolts()
return u8_val + 270
def append_data(self, timestamp, tempF, cV):
# Append measurements to buffer: 16-bit time, 8-bit temp, 8-bit cV
n = self.end
# Warn if temperature is out of range
if not (-128 <= tempF <= 127):
print("WARNING: TEMPERATURE OUT OF RANGE", tempF)
# Warn if timestamp is out of range
if (
(timestamp < self.epoch)
or ((timestamp - self.epoch) >> TIME_SHIFT) > 0xFFFF
):
print("WARNING: TIMESTAMP OUT OF RANGE", timestamp)
# Pack timestamp as 16 bits, temp as 8 bits, cV as 8 bits, and
# save them in sleep_memory. To save space, this quantizes the
# timestamps. Out of range values will be masked with & 0xFF...
scv = self.scale_centivolts(cV)
if n + 4 < len(sleep_memory):
ts_u24 = ((timestamp - self.epoch) >> TIME_SHIFT) & TIME_MASK
data_u32 = (ts_u24 << 16) | (tempF & 0xFF) << 8 | (scv & 0xFF)
sleep_memory[n:n+4] = pack("<I", data_u32)
print("sleep_memory[%d] = %d F, %d cV" % (n, tempF, cV))
self.end = n + 4
else:
print("WARNING: BUFFER IS FULL")
CIRCUITPY/util.cpy
The util.py file provides administrative functions that are meant to be used from the CircuitPython REPL. For examples, refer to the "Logger Usage" section of this guide.
-
util.batt(): print the battery voltage (this will usually be inaccurate when a USB cable is connected) -
util.dump(): dump the log of timestamped measurements in CSV format (to copy and paste into CSV file) -
util.set_clock(): interactively set the RTC time, reset the log memory, and set the epoch value in sleep memory -
util.now(): print the RTC time
# SPDX-License-Identifier: MIT
#
# These are utility functions for configuring the datalogger and exporting
# logged data. This is meant to be used manually from the serial REPL.
from alarm import sleep_memory
import board
from board import board_id, I2C
from rtc import RTC
from struct import unpack
import time
from time import mktime, sleep, struct_time
from adafruit_datetime import datetime
from adafruit_max1704x import MAX17048
from datalogger import battery_centivolts
from redled import RedLED
from sleepmem import SleepMem
def batt():
# Check battery status on supported boards
cV = battery_centivolts()
if not (cV is None):
print('%d cV' % cV)
else:
print('Voltage measurement not available')
def dump():
# Print the data log in CSV format to serial console
sm = SleepMem()
percent = 100 * (sm.end - sm.DATA) / (len(sleep_memory) - sm.DATA)
print("# NVRAM end index: %d (%.0f%% of buffer)" % (sm.end, percent))
print("Date Time,°F,centi-Volts")
for i in range(sm.DATA, sm.end, 4):
data_u32 = unpack("<I", sleep_memory[i:i+4])[0] # unsigned u32
timestamp = (data_u32 >> (16 - sm.TIME_SHIFT)) + sm.epoch
(mon,d,h,min_,s) = datetime.fromtimestamp(timestamp).timetuple()[1:6]
tempF = unpack("<b", sleep_memory[i+1:i+2])[0] # signed i8
cV = sm.unscale_centivolts(sleep_memory[i]) # u8 (scaled cV)
print('%d/%d %02d:%02d,%d,%d' % (mon, d, h, min_, tempF, cV))
def set_clock():
# Clear memory, set real time clock (RTC) time, set epoch
ans = input("This will delete your data, are you sure? [y/N]: ")
if not (ans in ["y", "Y"]):
print("RESET CANCELED")
return
# Clear sleep memory
for i in range(len(sleep_memory)):
sleep_memory[i] = 0
print("SLEEP MEMORY CLEARED")
# Set clock
rtc = RTC()
print("Set RTC time...")
try:
y = int(input(" year: "))
mon = int(input(" month: "))
d = int(input(" day: "))
h = int(input(" hour: "))
min_ = int(input(" minute: "))
s = int(input("seconds: "))
t = struct_time((y, mon, d, h, min_, s, 0, -1, -1))
rtc.datetime = t
print("new RTC time: ", now())
except ValueError as e:
print("ERROR Bad value:", e)
# Set epoch
sm = SleepMem()
sm.epoch = time.time()
print("new epoch is: ", sm.epoch)
def now():
# Return ESP32-S3 RTC time formatted as a string
rtc = RTC()
struct_ = rtc.datetime
timestamp = mktime(struct_)
sm = SleepMem()
return "%04d-%02d-%02d %02d:%02d:%02d (epoch + %d)" % (
(rtc.datetime)[0:6] + (timestamp - sm.epoch,)
)
Set Clock & Seal Enclosure
To set the clock and seal the logger enclosure, you will need:
- Screwdriver
- Computer and USB data cable (not a charge-only cable!)
- Ziploc bag to store the desiccant pack while you have the logger open
- (Optional) Anti-static wrist strap
To keep the parts cost for low for this logger, it uses the ESP32-S3 sleep memory to store measurements and the built-in RTC to keep time. The sleep memory is small and the RTC isn't super accurate. So, for good results, you should clear the memory and set the clock each time you want to start a new data logging session:
- If you haven't already done so as part of downloading CSV data, open the enclosure, set the lid aside, and move the desiccant pack to a Ziploc bag.
- Carefully lift the board out of the enclosure and move the jumper shunt to the GND pin only (A0 pin should be visible and unconnected).
- Plug the logger into your computer. Be sure to use a USB data cable. Charge-only cables won't work.
- Wait for the CIRCUITPY drive to appear, then eject it. Leave the USB cable connected. This is to prepare for resetting the board.
- (CAUTION: this will clear the log, so download your old log first if needed) Press the board's RESET button.
- In a terminal shell on your computer, connect to the logger's serial console with
screen,tio, or your favorite serial monitor program. On my Mac, I usescreen -h 9999 -fn /dev/tty.usbmodem* 115200 - Type any key to start the CircuitPython REPL.
- In the REPL, run the command
import utilto load the data logger admin interface functions. - Run
util.set_clock()to enter the clock setting mode, then answer the prompts to set the date and time (see example below). - Use the
Ctrl-Dkey combo to exit the CircuitPython REPL. When code.py starts running, you should see a message with an initial temperature reading. - Disconnect your serial monitor program. If you use
screen, you can doCtrl-A k y(press and holdCtrlkey, pressAkey, releaseCtrlandA, press and releaseKkey, then press and releaseYkey). - (optional) Wait until the battery charging LED turns off. Or, if you just downloaded a log and saw that the battery has at least 3.8V (380 cV), you can probably skip this step. ESP32-S3 deep sleep is very easy on batteries.
- Eject the logger's CIRCUITPY drive and unplug the USB cable.
- Double check that the jumper shunt is only connected to the GND pin
- Take a silica gel desiccant pack out of the Ziploc bag, verify the indicator beads show the gel is still able to absorb water, then put the gel pack in the logger enclosure. Be careful not to accidentally press RESET.
- Double check that both halves of the enclosure lid are clear of anything that might interfere with the seal (hair, dirt, corner of silica gel pack, temperature sensor cable, etc).
- Tighten the lid screws to seal the enclosure.
This CircuitPython REPL log shows an example of what it looks like to set the clock over USB serial:
Auto-reload is on. Simply save files over USB to run them or enter REPL to disable.
Press any key to enter the REPL. Use CTRL-D to reload.
Adafruit CircuitPython 9.2.1 on 2024-11-20; Adafruit Feather ESP32S3 No PSRAM with ESP32S3
>>> import util
>>> util.set_clock()
This will delete your data, are you sure? [y/N]: y
SLEEP MEMORY CLEARED
Set RTC time...
year: 2025
month: 1
day: 17
hour: 12
minute: 48
seconds: 25
new RTC time: 2025-01-17 12:48:25 (epoch + 0)
new epoch is: 1737118105
>>>
Auto-reload is on. Simply save files over USB to run them or enter REPL to disable.
code.py output:
DS18B20: 70 °F, batt: 418 cV
sleep_memory[96] = 70 F, 418 cV
Code done running.
Press any key to enter the REPL. Use CTRL-D to reload.
Pretending to deep sleep until alarm, CTRL-C or file write.
When you have sealed up the logger, be sure to double check that the desiccant pack is inside the enclosure and that the jumper shunt is only on the GND pin (can you see the A0 pin?).
A fully assembled logger should look similar to this picture:
Download Log & Save to CSV
To collect data, you will need:
- Screwdriver
- Computer and USB data cable (not a charge-only cable!)
- Ziploc bag to store the desiccant pack while you have the logger open
- (Optional) Anti-static wrist strap
To collect your temperature data:
- Unscrew and remove the enclosure lid. Check seal for debris and clean it if needed. Set the lid aside.
- Move the desiccant pack to a Ziploc bag and set it aside.
- Lift the logger far enough out of the enclosure so you can access the jumper shunt and the USB port. CAUTION: To avoid data loss, be careful not to hit the
RESETbutton or zap the board with static. - Move the jumper shunt so that it connects the
GNDandA0pins. This will wake the logger out of deep sleep and enable USB serial. - Plug in the USB cable.
- Connect to the logger's USB serial port with your favorite serial monitor (
screen,tio, or whatever). Be sure that the program you use is configured to use a scrollback buffer large enough to hold several hundred lines of CSV data. On my Mac, I usescreen -h 9999 -fn /dev/tty.usbmodem* 115200for a 9999 line buffer. - Send the
Ctrl-Ckey combo to interrupt code.py, then type any key to start the CircuitPython REPL. - At the REPL prompt, run
import utilto load the logger's admin module functions. - Run
util.now()to check the logger's RTC clock time. When I do this, I like to also type a Python comment with the time from my watch so I can check how far the RTC time has drifted. - Run
util.dump()to dump a CSV formatted log of timestamp, temperature, and battery voltage (see example below). - Copy the CSV data from your serial monitor program into a text file using the
.csvfile extension so you can use it with a spreadsheet or other charting program. (see "Details on Copying and Pasting CSV Data..." below) - Make a note of the battery voltage shown on the last column of the last line of the CSV data. Use that battery voltage to decide if you need to charge the battery. For example, if you see 399, that means the MAX17048 measured 3.99V (399 centi-Volts), which should be more than enough to log for several days.
- Tell your serial monitor to disconnect from the serial port. For example, in
screen, type theCtrl-a k ykey combo. - Eject your logger's CIRCUITPY drive
- Unplug your logger's USB cable.
- Move the jumper shunt so it's only on the
GNDpin (A0should be visible and unconnected).
Details on Copying and Pasting CSV Data Using screen:
- Assuming you have just run
util.dump()from the CircuitPython REPL and you want to copy the CSV data dump to a text file: Begin by typing the key sequenceCtrl-a [to enterscreen's copy mode (holdCtrlkey, pressAkey, releaseAandCtrl, then press and release the[key). - Use the arrow keys to move your cursor up to the start of the CSV heading line.
- Press the spacebar once to mark the start of the text you want to copy.
- Use the arrow keys to move down to the end of the last line of CSV data.
- Press the spacebar to copy all the selected text. (NOTE: This uses
screen's copy buffer, which will probably not be synchronized with your system clipboard. To paste, you need to usescreen's paste feature.) - Open a second shell window in your
screensession withCtrl-a ckey sequence. - In the second shell, open your favorite command line text editor (
vim,emacs,nano, or whatever) and prepare your editor for pasting text into a new file. For example, from the shell prompt, dovim my-log.csvto start editing a file, run the:set pastevim command to avoid auto-indent weirdness, then press theIkey to enter insert mode. - Use
screen's paste function to paste the CSV data into your text editor with the key sequenceCtrl-a ]. - Save the CSV file and quit your text editor. For example, in vim, press the
Esckey, then run the:wqcommand. (Now you should be back at the shell prompt for screen's second shell.) - Quit the second shell with the
exitcommand. (now you should be back at the CircuitPython REPL)
If you don't like screen or vim, that's fine. All you need is a serial monitor with a reasonably large scrollback buffer, a text editor, and a method to copy and paste between them. Lots of programs will work for that.
Once you save the CSV data, if you want to start logging again, refer to the "Set Clock & Seal Enclosure" procedure above. If you're done logging, refer to the "Prep Logger for Storage" procedure below.
This is an example of what it looks like to dump the CSV data log in a CircuitPython REPL (the first two battery measurements were taken with USB connected and the charger on, the rest were running off the battery):
Adafruit CircuitPython 9.2.1 on 2024-11-20; Adafruit Feather ESP32S3 No PSRAM with ESP32S3 >>> import util >>> util.batt() 413 cV >>> util.now() '2025-01-17 12:59:04 (epoch + 333314)' >>> # 12:44:38 >>> util.dump() # NVRAM end index: 1212 (28% of buffer) Date Time,°F,centi-Volts 1/13 16:23,70,420 1/13 16:23,70,420 1/13 16:44,72,405 1/13 17:03,71,406 1/13 17:24,71,405 1/13 17:43,71,406 1/13 18:04,71,405 1/13 18:24,71,406 1/13 18:44,71,405 1/13 19:04,71,406 1/13 19:24,71,405 1/13 19:44,71,405 [---about 250 lines edited out---] 1/17 07:31,63,403 1/17 07:51,63,403 1/17 08:11,63,402 1/17 08:31,64,402 1/17 08:51,65,402 1/17 09:11,66,402 1/17 09:31,61,402 1/17 09:51,67,402 1/17 10:11,64,403 1/17 10:31,68,402 1/17 10:51,71,403 1/17 11:11,70,403 1/17 11:31,74,403 1/17 11:51,70,403 1/17 12:11,71,403 1/17 12:31,71,403 1/17 12:51,71,403 >>>
LED Morse Code Battery Check
Since it's not possible to get an accurate battery voltage measurement when the USB cable is connected, I implemented a Morse code LED battery check feature. When the jumper shunt connects GND to A0, the board wakes up from deep sleep, activates USB connectivity, and starts blinking battery voltage messages on the LED.
When the battery voltage is 3.80V or higher, the LED stays on between battery check messages. Between 3.55V and 3.80V, the LED stays off between messages. Below 3.55V, the LED stays off and stops sending messages to conserve power.
Each battery voltage message includes:
-
KAstart of transmission prosign (-.-.-) - Three digit battery voltage in centi-Volts, repeated twice, with a word space in between
-
ARend of transmission prosign (.-.-.)
If you wondered why the logger uses centi-Volts instead of Volts for battery units, it's because using cV instead of V makes the Morse code messages shorter (no decimal points).
The main point of the battery voltage messages is to help with discharging the battery to a good storage voltage. To gently discharge the battery, you can jumper GND to A0, unplug USB, and wait until the voltage goes down.
Prep Logger for Storage
To extend the useable lifetime of your batteries, I recommend following this procedure when you are done using your logger for a while:
- If you haven't already done so, remove the enclosure lid and move the desiccant pack to a Ziploc bag.
- If you haven't already done so, carefully lift the logger out of the enclosure and move the jumper shunt so it connects the
GNDandA0pins. - Check the LED. If it does not turn on or blink, that means the battery voltage is below 3.55V. If the LED stays off for about 15 seconds between Morse code voltage check messages, that means the voltage is between 3.55V and 3.80V. In that case, plug the USB cable in, wait until the battery is charged, then eject the CIRCUITPY drive and unplug the logger.
- At this point, the logger should be unplugged with
GNDconnected toA0by the jumper shunt. The LED should be blinking battery voltage messages in Morse code. Between messages, the LED should stay on solid for about 8 seconds. Put the logger aside in a place where you can periodically look at the battery messages. Wait until the message shows the voltage is close to 3.8V (it may take several hours). - Start checking the LED more frequently and wait until the LED turns off between battery check messages. When the LED stays off between messages, it means the voltage is below 3.80V, so the battery is ready to store.
- Unplug the battery pack from the ESP32-S3 Feather board.
- (optional) Put the desiccant back back in the enclosure and re-seal the lid.
Making Charts
My ultimate goal for this greenhouse data logger project was making charts so the folks from the community garden group could compare how the temperatures in their three greenhouses change over time.
In most spreadsheet programs, making a 2D line chart is fairly easy when you have a 2-column CSV file with X-axis values in the first column and Y-axis values in the second column. But, in this case, it's trickier because I need to make a 2D scatter plot with 3 data series where the X-axis values are out of phase from each other. (It takes me 5 minutes or so to set the clock on a logger, seal it up, and move on to the next one.)
Spreadsheet Programs
Lots of software can read data from CSV files and make 2D scatter plots with timestamps on the X axis and temperatures on the Y axis. For my purposes, the easiest thing to use was Apple's "Numbers" spreadsheet app that comes with macOS, so I will explain how to use Numbers. Some possible alternatives include:
- Microsoft Excel
- Google Sheets
- gnuplot command line program (Ubuntu, Debian, etc)
- Matplotlib (pypi) Python charting library
2D Scatter Plot for 3 CSV Files
The Apple Numbers app is powerful, but some chart features that you might expect to have their own settings end up being determined as a non-obvious side effect of some other setting elsewhere in the program. This guide isn't about spreadsheets, so I'm just going to list off the steps of the chart-making incantation I use without getting into the rationale for why each step is necessary.
First Part: Combine CSV data into a single spreadsheet with the formatting that makes 2D scatter plots happy:
- In the macOS Finder, select your 3 CSV files (or 1 for each logger you built) that you copied and pasted from the serial monitor with the CircuitPython REPL and
util.dump(). To select multiple files at once, you can hold down the command key while left-clicking on the files. - Open the CSV files with Command-O, a double-click, or whatever. You should see 3 spreadsheet windows open in Numbers.
- Create a new blank spreadsheet file with File menu > New. In later steps, this is "the chart spreadsheet". The leftmost column and topmost row should both be gray, indicating that you have the default 1 header row and 1 header column.
- In right sidebar > Table tab > Header & Footer section: set the leftmost pulldown menu to 0 (remove row headers). The background color of Column A should turn from gray to white.
- Switch windows to your first CSV data log file.
- Select and copy all cells of the first two columns,
Date Timeand°F(skip the centi-Volts column). - Switch windows to the chart spreadsheet.
- Click on cell A1 (first row of first column) and paste the CSV data.
- Change the text of A1 from "Date Time" to something useful like "Greenhouse 1" or whatever.
- Click on cell A1 to make sure that Numbers shows the letter and number row and column labels.
- Click on the "A" column label to select the entire column of timestamp data.
- In right sidebar > Cell tab > Data Format: change the pulldown menu from "Automatic" to "Date & Time"
- Repeat the previous 8 steps for each of the remaining CSV files, pasting the second file's data into columns C & D, the third file's data into columns E & F, etc. Be sure to set the timestamp columns to the "Date & Time" data format.
When you've finished pasting data series into the chart spreadsheet, it should look similar to this screenshot:
Second Part: Make the 2D scatter plot chart:
- In the window for the chart spreadsheet, select all 6 columns (or 2 for each CSV file you pasted).
- From the menubar, select Insert > Chart > 2D Scatter. You should see a chart next to your data, but it will look wrong.
- Click the green "Edit Data References" button below the chart.
- You should see a new control overlay appear on the column headings for columns B, C, D, E, and F. The overlay has a dark gray background with rounded corners on the top and colored dots in the middle.
- Click the colored dot for the column heading overlay of column B. It should open a context menu.
- From the context menu, uncheck the item for "Share X Values". This should change the appearance of the chart and reduce the number of column heading overlays.
- With the chart still selected, in the right sidebar, switch to the Series tab.
- In Series tab > Data Symbols > Size: click once on the up arrow control, changing size from "Auto" to "2".
- In Series tab > Connection Lines: change "No Lines" to "Curved".
- In the right sidebar, switch to the Axis tab
- In Axis tab > Value (X) > Label Angle: change "Horizontal" to "Left Diagonal"
- In Axis tab > Value (X) > Axis Scale > Min: enter a date time string, such as "1/14 12 pm" where the time is the earliest timestamp in your data, rounded down to the previous multiple of 4 hours.
- In Axis tab > Value (X) > Axis Scale > Max: enter a date time string, such as "1/17 12 pm" where the time is the latest timestamp in your data, rounded up to the next multiple of 4 hours.
- In Axis tab > Value (X) > Axis Scale > Steps > Major: click the up arrow button until the second X axis label on the chart is exactly 4 hours later than the first label. For example, if your locale is set to 24 hour time and the first label is "1/14 12:00", the second label should be "1/14 16:00".
- In Axis tab > Value (X) > Major Gridlines: change "None" to the solid line and change the color to light gray. You should now have X-axis grid lines dividing your chart into 4 hour intervals.
- In Axis tab > Value (Y) > Axis Scale > Min: enter a temperature number, such as "10", where the number is the lowest temperature in your data rounded down to the previous multiple of 10.
- In Axis tab > Value (Y) > Axis Scale > Max: enter a temperature number, such as "90", where the number is the highest temperature in your data rounded up to the next multiple of 10.
- In Axis tab > Value (Y) > Axis Scale > Steps > Major: click the up arrow button until the second Y axis label on the chart is exactly 10 degrees higher than the first label. For example, if your first label is 10, the second should be 20.
- In Axis tab > Value (Y) > Major Gridlines: change "None" to the solid line and change the color to light gray. You should now have Y-axis grid lines dividing your chart into 10 degree intervals.
The resulting chart should look something like this:
Interpreting Charts
The chart above shows a temperature scatter plot for 3 greenhouses over a 3 day period (phase starts at noon):
- Greenhouse 1 is blue
- Greenhouse 2 is green
- Greenhouse 3 is gray
Some observations:
- The peaks between 8 AM and 5 PM are from solar heating
- The dense nighttime zigzag pattern on the blue line is because that greenhouse has a potent heater with a thermostat that initially maintained the temperature between about 62°F and 73°F. On the second day, somebody adjusted the thermostat to maintain temperature between about 60°F and 68°F.
- For the green line, the nights start with a zigzag pattern that quickly begins to sag down to below the thermostat setting. This greenhouse has good double layer insulated plastic glazing which retains heat well. But, the heater is undersized and unable to hold the requested temperature.
- The gray line is for an unheated greenhouse with a single layer of plastic. It gets pretty cold.
This page (Greenhouse Temperature Logger) was last updated on January 28, 2025.
Text editor powered by tinymce.