Build Your Own IoT Weather Station: Log Temperature and Humidity to Google Sheets with a Raspberry Pi Pico
Introduction
Imagine turning a tiny Raspberry Pi Pico into your very own internet-connected weather station! In this project, you’ll harness the power of MicroPython to read temperature and humidity data from a DHT11 sensor, optionally display it on a sleek I2C LCD, and beam it straight to a Google Sheet for real-time tracking—all over WiFi. A clever Google Apps Script acts as the bridge, catching data from your Pico and neatly logging it into rows. Whether you’re monitoring your room’s climate, experimenting with IoT, or just geeking out over data, this guide walks you through every step. It’s designed for hobbyists with a bit of experience—like familiarity with MicroPython or Arduino—who want to level up their skills. Let’s dive in and bring your sensor data to life!
Part 1: Hardware Setup on the Raspberry Pi Pico
What You’ll Need
- Raspberry Pi Pico with MicroPython installed
- DHT11 temperature and humidity sensor
- 16x2 I2C LCD (with PCF8574 backpack) — Optional: The LCD can be omitted if you don’t need a local display; the code will still work without it.
- Breadboard and jumper wires
- A WiFi network you can connect to
Steps
- Install MicroPython on the Pico
- If you haven’t already, download the MicroPython UF2 file from the official MicroPython site.
- Hold the BOOTSEL button on the Pico, plug it into your computer via USB, and release BOOTSEL. It’ll appear as a drive.
- Drag the UF2 file onto the Pico drive. It’ll reboot and run MicroPython.
- Connect the Hardware
- DHT11 Sensor:
- VCC to 3.3V (Pin 36 on Pico)
- GND to GND (Pin 38)
- Data to GP15 (Pin 20)
- I2C LCD (optional — you can skip this if you don’t want a display; the code handles its absence gracefully):
- VCC to VSYS (Pin 39) — This provides 5V to power the LCD when the Pico is powered via USB, which is ideal for the LCD’s requirements.
- GND to GND (Pin 38)
- SDA to GP0 (Pin 1)
- SCL to GP1 (Pin 2)
- Use a breadboard to keep connections tidy. Double-check your wiring against the Pico pinout (search “Pico pinout” online for a diagram).
- DHT11 Sensor:
- Test Your Setup
- You’ll load the code later, but for now, ensure all components are snug and powered. If using the LCD, its backlight should light up when connected correctly.
Part 2: Setting Up Google Sheets
Steps
- Create a New Google Sheet
- Go to sheets.google.com and sign into your Google account.
- Click “+ Blank” to create a new spreadsheet.
- Name it something like “Pico Weather Data” by clicking the title at the top-left.
- Set Up Headers
- In the first row (Row 1), add these headers in columns A to C:
- A1:
DateTime
- B1:
Temperature
- C1:
Humidity
- This matches the data your Pico will send: a single datetime followed by temperature and humidity.
- Adjust Sharing Permissions
- Click the green “Share” button at the top-right.
- In the “Share with people and groups” section, you won’t add specific people yet (the script handles data via a URL).
- Instead, click “Change to anyone with the link” under “General access.”
- Set it to “Viewer” (not “Editor”—the script will write data, not public users).
- Copy the link by clicking “Copy link,” but you won’t need it for now. Click “Done.”
- Why? The Apps Script will use a deployment URL to write data, and this setting ensures the sheet is accessible but secure.
Part 3: Setting Up Google Apps Script
Steps
- Open the Apps Script Editor
- In your Google Sheet, click “Extensions” in the top menu, then “Apps Script.”
- A new tab opens with a blank script editor.
- Paste the Google Apps Script Code
- Delete the default
function myFunction() {}
code. - Copy and paste this code:
function doGet(e) { var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); var datetime = e.parameter.datetime; var temp = e.parameter.temperature; var humidity = e.parameter.humidity; // Append data to the sheet with datetime in the first column sheet.appendRow([datetime, temp, humidity]); return ContentService.createTextOutput("Data received!"); }
- This script receives a single
datetime
value along with temperature and humidity from your Pico and logs them into the sheet.
- Delete the default
- Save and Name the Script
- Click the floppy disk icon or File > Save.
- Name it something like “PicoDataLogger.”
- Deploy the Script as a Web App
- Click “Deploy” at the top-right, then “New deployment.”
- In the dialog:
- Type: Leave as “Web app.”
- Description: Optional (e.g., “Logs Pico sensor data”).
- Execute as: Select “Me (your-email@gmail.com)” (it runs under your account).
- Who has access: Select “Anyone” (this allows the Pico to send data anonymously via the URL).
- Click “Deploy.”
- You’ll see a “Review Permissions” prompt. Click “Authorize access,” then sign in if prompted.
- If you see “This app isn’t verified,” click “Advanced” > “Go to PicoDataLogger (unsafe)” > “Allow.”
- After authorization, a “Deployment ID” and “Web app URL” appear. Copy the Web app URL (it looks like
https://script.google.com/macros/s/[long-id]/exec
). You’ll use this in thesecrets.py
file on the Pico.
- Test the Deployment (Optional)
- Paste the Web app URL into your browser with test parameters, e.g.:
https://script.google.com/macros/s/[long-id]/exec?datetime=03-02-2025%2014:30:00&temperature=72.5&humidity=45
- Hit Enter. If it works, you’ll see “Data received!” in the browser, and a new row appears in your sheet.
Part 4: Loading Code onto the Raspberry Pi Pico
What You’ll Need
- A computer with a USB port
- Thonny IDE (download from thonny.org if not installed)
Steps
- Install Thonny and Connect the Pico
- Open Thonny, plug in your Pico, and select “MicroPython (Raspberry Pi Pico)” from the bottom-right interpreter dropdown.
- If it connects, you’ll see a prompt like
>>>
in the shell.
- Upload Library Files
- This project uses four custom libraries:
ntptime.py
(for time sync),i2c_lcd.py
(I2C LCD driver),lcd_api.py
(LCD base API), andsecrets.py
(WiFi credentials and Google Sheets URL). These need to be loaded onto the Pico. - The code for
ntptime.py
,i2c_lcd.py
, andlcd_api.py
is provided in the “Library Code Appendix” at the end of this guide. Forsecrets.py
, create it now: - In Thonny:
- File > New, paste each library’s code from the appendix (for
ntptime.py
,i2c_lcd.py
, andlcd_api.py
), then File > Save As. - For
secrets.py
, use this template and fill in your details:
# secrets.py wifi_ssid = "YOUR_WIFI_SSID" wifi_password = "YOUR_WIFI_PASSWORD" google_sheets_url = "https://script.google.com/macros/s/[long-id]/exec"
- Replace the placeholders:
YOUR_WIFI_SSID
with your WiFi network name.YOUR_WIFI_PASSWORD
with your WiFi password.https://script.google.com/macros/s/[long-id]/exec
with the Web app URL from Step 3.4.- Save all files to the “Raspberry Pi Pico” device with these exact names:
ntptime.py
i2c_lcd.py
lcd_api.py
secrets.py
- This project uses four custom libraries:
- Upload the Main Code
- File > New in Thonny, then paste the updated main MicroPython code (below). Note that the
google_sheets_url
is imported fromsecrets.py
. - File > Save As, choose “Raspberry Pi Pico,” and name it
main.py
(this makes it run automatically on boot). - Main code:
import network import ntptime import time from machine import Pin, I2C from dht import DHT11 from i2c_lcd import I2cLcd import urequests # Define sensor read interval sensor_read_interval = 10 # Interval in seconds to read the sensor data # Import WiFi credentials and Google Sheets URL try: from secrets import wifi_ssid, wifi_password, google_sheets_url except ImportError: print("WiFi credentials or Google Sheets URL missing in secrets.py") raise # Set up I2C for the LCD try: i2c = I2C(0, scl=Pin(1), sda=Pin(0)) lcd = I2cLcd(i2c, 0x27, 2, 16) # Adjust the I2C address (0x27) if necessary # Define a custom character for the degree symbol degree_symbol = bytearray([ 0b00100, # * 0b01010, # * * 0b00100, # * 0b00000, # 0b00000, # 0b00000, # 0b00000, # 0b00000 # ]) lcd.custom_char(0, degree_symbol) lcd_connected = True except Exception as e: print("LCD not connected:", e) lcd_connected = False # Set up the DHT11 sensor try: dht_sensor = DHT11(Pin(15)) except Exception as e: print("DHT11 sensor not connected:", e) dht_sensor = None # Connect to WiFi with timeout def connect_wifi(ssid, password, timeout=10): wlan = network.WLAN(network.STA_IF) wlan.active(True) wlan.connect(ssid, password) print("Connecting to WiFi...") start_time = time.time() while not wlan.isconnected(): if time.time() - start_time >= timeout: print("Failed to connect to WiFi within the timeout period.") return False time.sleep(0.5) print("Connected to WiFi:", wlan.ifconfig()) return True if not connect_wifi(wifi_ssid, wifi_password): raise RuntimeError("WiFi connection failed") try: ntptime.host = "time.google.com" print("Synchronizing time...") ntptime.settime() print("Time synchronized!") except Exception as e: print("Failed to synchronize time:", e) # Convert UTC to Central Time (UTC-6 or UTC-5 during daylight saving) as a full datetime string def get_central_datetime(): tm = time.localtime() utc_offset = -6 # Central Standard Time (change to -5 for DST) central_time = time.localtime(time.mktime(tm) + utc_offset * 3600) return "{:02}-{:02}-{:04} {:02}:{:02}:{:02}".format( central_time[1], central_time[2], central_time[0], # Date: MM-DD-YYYY central_time[3], central_time[4], central_time[5] # Time: HH:MM:SS ) # Get single sensor reading def get_sensor_data(): if dht_sensor: dht_sensor.measure() temp_f = dht_sensor.temperature() * 9 / 5 + 32 # Convert to Fahrenheit humidity = dht_sensor.humidity() return temp_f, humidity else: return None, None # Function to send data to Google Sheets with manual space encoding def send_to_google_sheets(temp, humidity, datetime): # Replace space in datetime with %20 for URL encoding encoded_datetime = datetime.replace(" ", "%20") url = "{}?datetime={}&temperature={}&humidity={}".format( google_sheets_url, encoded_datetime, temp, humidity ) response = urequests.get(url) print("Data sent to Google Sheets:", response.text) # Initialize variables last_sensor_update = time.time() last_clock_update = time.time() temp_f = None humidity = None sensor_reads = 0 # Main loop while True: try: current_time = time.time() # Update the sensor every sensor_read_interval seconds if current_time - last_sensor_update >= sensor_read_interval: temp_f, humidity = get_sensor_data() sensor_reads += 1 last_sensor_update = current_time # Send data to Google Sheets with a single datetime datetime = get_central_datetime() # e.g., "03-02-2025 14:30:00" send_to_google_sheets(temp_f, humidity, datetime) # Update the clock every second (using time-only for LCD) if current_time - last_clock_update >= 1: last_clock_update = current_time if lcd_connected: tm = time.localtime() clock_str = "{:02}:{:02}:{:02}".format(tm[3], tm[4], tm[5]) lcd.move_to(0, 1) lcd.putstr("T:{} H:{}".format(temp_f if temp_f else "--", humidity if humidity else "--")) lcd.move_to(8, 1) lcd.putstr(clock_str) time.sleep(0.1) # Small delay to prevent overwhelming the loop except Exception as e: print("Error in main loop:", e) time.sleep(1) # Wait before retrying on error
- File > New in Thonny, then paste the updated main MicroPython code (below). Note that the
- Run and Test
- Click the green “Run” button in Thonny.
- Watch the shell output:
- It should connect to WiFi, sync time, and start sending data every 10 seconds.
- If using the LCD, check it for temperature, humidity, and time updates.
- Open your Google Sheet to see new rows appear with a single
DateTime
column followed by temperature and humidity.
Part 5: Troubleshooting and Final Touches
Common Issues
- WiFi Fails: Double-check
secrets.py
credentials and ensure the Pico is in range. - LCD Blank (if connected): Verify wiring and I2C address (default is 0x27; adjust in code if different—use an I2C scanner script if needed).
- No Data in Sheet: Ensure the Web app URL in
secrets.py
is correct and the script is deployed with “Anyone” access. - Time Sync Fails: Check internet connection; the Pico uses
time.google.com
.
Final Touches
- Adjust
sensor_read_interval
(in seconds) in the Pico code if 10 seconds is too frequent. - Format your Google Sheet’s
DateTime
column (Format > Number > Date time) for a clean display. - Unplug the Pico from your computer and power it with a USB adapter to run standalone.
How It All Works Together
- The Pico reads the DHT11 sensor every 10 seconds, displays data on the LCD (if connected), and sends it to the Google Sheets URL from
secrets.py
as a single datetime value with temperature and humidity. - The Apps Script (
doGet
) receives this data via HTTP GET parameters and appends it to your sheet in columns A (DateTime), B (Temperature), and C (Humidity). - Libraries like
ntptime.py
sync the clock,i2c_lcd.py
andlcd_api.py
drive the display (if used), andsecrets.py
keeps your WiFi creds and Google Sheets URL safe.
Library Code Appendix
The following library files (ntptime.py
, i2c_lcd.py
, and lcd_api.py
) are required for this project and should be uploaded to your Pico as described in “Upload Library Files” under Part 4. Copy each code block below into Thonny and save them with the specified filenames.
ntptime.py
from time import gmtime import socket import struct # The NTP host can be configured at runtime by doing: ntptime.host = 'myhost.org' host = "pool.ntp.org" # The NTP socket timeout can be configured at runtime by doing: ntptime.timeout = 2 timeout = 1 def time(): NTP_QUERY = bytearray(48) NTP_QUERY[0] = 0x1B addr = socket.getaddrinfo(host, 123)[0][-1] s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.settimeout(timeout) s.sendto(NTP_QUERY, addr) msg = s.recv(48) finally: s.close() val = struct.unpack("!I", msg[40:44])[0] # 2024-01-01 00:00:00 converted to an NTP timestamp MIN_NTP_TIMESTAMP = 3913056000 # Y2036 fix if val < MIN_NTP_TIMESTAMP: val += 0x100000000 EPOCH_YEAR = gmtime(0)[0] if EPOCH_YEAR == 2000: # (date(2000, 1, 1) - date(1900, 1, 1)).days * 24*60*60 NTP_DELTA = 3155673600 elif EPOCH_YEAR == 1970: # (date(1970, 1, 1) - date(1900, 1, 1)).days * 24*60*60 NTP_DELTA = 2208988800 else: raise Exception("Unsupported epoch: {}".format(EPOCH_YEAR)) return val - NTP_DELTA def settime(): t = time() import machine tm = gmtime(t) machine.RTC().datetime((tm[0], tm[1], tm[2], tm[6] + 1, tm[3], tm[4], tm[5], 0))
i2c_lcd.py
"""Implements a HD44780 character LCD connected via PCF8574 on I2C.""" from lcd_api import LcdApi from time import sleep_ms # The PCF8574 has a jumper selectable address: 0x20 - 0x27 DEFAULT_I2C_ADDR = 0x27 # Defines shifts or masks for the various LCD line attached to the PCF8574 MASK_RS = 0x01 MASK_RW = 0x02 MASK_E = 0x04 SHIFT_BACKLIGHT = 3 SHIFT_DATA = 4 class I2cLcd(LcdApi): """Implements a HD44780 character LCD connected via PCF8574 on I2C.""" def __init__(self, i2c, i2c_addr, num_lines, num_columns): self.i2c = i2c self.i2c_addr = i2c_addr self.i2c.writeto(self.i2c_addr, bytes([0])) sleep_ms(20) # Allow LCD time to power up # Send reset 3 times self.hal_write_init_nibble(self.LCD_FUNCTION_RESET) sleep_ms(5) # Need to delay at least 4.1 msec self.hal_write_init_nibble(self.LCD_FUNCTION_RESET) sleep_ms(1) self.hal_write_init_nibble(self.LCD_FUNCTION_RESET) sleep_ms(1) # Put LCD into 4-bit mode self.hal_write_init_nibble(self.LCD_FUNCTION) sleep_ms(1) LcdApi.__init__(self, num_lines, num_columns) cmd = self.LCD_FUNCTION if num_lines > 1: cmd |= self.LCD_FUNCTION_2LINES self.hal_write_command(cmd) def hal_write_init_nibble(self, nibble): """Writes an initialization nibble to the LCD.""" byte = ((nibble >> 4) & 0x0f) << SHIFT_DATA self.i2c.writeto(self.i2c_addr, bytes([byte | MASK_E])) self.i2c.writeto(self.i2c_addr, bytes([byte])) def hal_backlight_on(self): """Allows the hal layer to turn the backlight on.""" self.i2c.writeto(self.i2c_addr, bytes([1 << SHIFT_BACKLIGHT])) def hal_backlight_off(self): """Allows the hal layer to turn the backlight off.""" self.i2c.writeto(self.i2c_addr, bytes([0])) def hal_write_command(self, cmd): """Writes a command to the LCD.""" byte = ((self.backlight << SHIFT_BACKLIGHT) | (((cmd >> 4) & 0x0f) << SHIFT_DATA)) self.i2c.writeto(self.i2c_addr, bytes([byte | MASK_E])) self.i2c.writeto(self.i2c_addr, bytes([byte])) byte = ((self.backlight << SHIFT_BACKLIGHT) | ((cmd & 0x0f) << SHIFT_DATA)) self.i2c.writeto(self.i2c_addr, bytes([byte | MASK_E])) self.i2c.writeto(self.i2c_addr, bytes([byte])) if cmd <= 3: # The home and clear commands require a worst case delay of 4.1 msec sleep_ms(5) def hal_write_data(self, data): """Write data to the LCD.""" byte = (MASK_RS | (self.backlight << SHIFT_BACKLIGHT) | (((data >> 4) & 0x0f) << SHIFT_DATA)) self.i2c.writeto(self.i2c_addr, bytes([byte | MASK_E])) self.i2c.writeto(self.i2c_addr, bytes([byte])) byte = (MASK_RS | (self.backlight << SHIFT_BACKLIGHT) | ((data & 0x0f) << SHIFT_DATA)) self.i2c.writeto(self.i2c_addr, bytes([byte | MASK_E])) self.i2c.writeto(self.i2c_addr, bytes([byte]))
lcd_api.py
"""Provides an API for talking to HD44780 compatible character LCDs.""" import time class LcdApi: """Implements the API for talking with HD44780 compatible character LCDs.""" LCD_CLR = 0x01 # DB0: clear display LCD_HOME = 0x02 # DB1: return to home position LCD_ENTRY_MODE = 0x04 # DB2: set entry mode LCD_ENTRY_INC = 0x02 # DB1: increment LCD_ENTRY_SHIFT = 0x01 # DB0: shift LCD_ON_CTRL = 0x08 # DB3: turn lcd/cursor on LCD_ON_DISPLAY = 0x04 # DB2: turn display on LCD_ON_CURSOR = 0x02 # DB1: turn cursor on LCD_ON_BLINK = 0x01 # DB0: blinking cursor LCD_MOVE = 0x10 # DB4: move cursor/display LCD_MOVE_DISP = 0x08 # DB3: move display (0-> move cursor) LCD_MOVE_RIGHT = 0x04 # DB2: move right (0-> left) LCD_FUNCTION = 0x20 # DB5: function set LCD_FUNCTION_8BIT = 0x10 # DB4: set 8BIT mode (0->4BIT mode) LCD_FUNCTION_2LINES = 0x08 # DB3: two lines (0->one line) LCD_FUNCTION_10DOTS = 0x04 # DB2: 5x10 font (0->5x7 font) LCD_FUNCTION_RESET = 0x30 # See "Initializing by Instruction" section LCD_CGRAM = 0x40 # DB6: set CG RAM address LCD_DDRAM = 0x80 # DB7: set DD RAM address LCD_RS_CMD = 0 LCD_RS_DATA = 1 LCD_RW_WRITE = 0 LCD_RW_READ = 1 def __init__(self, num_lines, num_columns): self.num_lines = num_lines if self.num_lines > 4: self.num_lines = 4 self.num_columns = num_columns if self.num_columns > 40: self.num_columns = 40 self.cursor_x = 0 self.cursor_y = 0 self.implicit_newline = (num_lines > 1) self.backlight = True self.display_off() self.backlight_on() self.clear() self.hal_write_command(self.LCD_ENTRY_MODE | self.LCD_ENTRY_INC) self.hide_cursor() self.display_on() def clear(self): """Clears the LCD display and moves the cursor to the top left corner.""" self.hal_write_command(self.LCD_CLR) self.hal_write_command(self.LCD_HOME) self.cursor_x = 0 self.cursor_y = 0 def show_cursor(self): """Causes the cursor to be made visible.""" self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY | self.LCD_ON_CURSOR) def hide_cursor(self): """Causes the cursor to be hidden.""" self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY) def blink_cursor_on(self): """Turns on the cursor, and makes it blink.""" self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY | self.LCD_ON_CURSOR | self.LCD_ON_BLINK) def blink_cursor_off(self): """Turns on the cursor, and makes it not blink (i.e. be solid).""" self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY | self.LCD_ON_CURSOR) def display_on(self): """Turns on (i.e. unblanks) the LCD.""" self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY) def display_off(self): """Turns off (i.e. blanks) the LCD.""" self.hal_write_command(self.LCD_ON_CTRL) def backlight_on(self): """Turns the backlight on.""" self.backlight = True self.hal_backlight_on() def backlight_off(self): """Turns the backlight off.""" self.backlight = False self.hal_backlight_off() def move_to(self, cursor_x, cursor_y): """Moves the cursor position to the indicated position.""" self.cursor_x = cursor_x self.cursor_y = cursor_y addr = cursor_x & 0x3f if cursor_y & 1: addr += 0x40 # Lines 1 & 3 add 0x40 if cursor_y & 2: addr += 0x14 # Lines 2 & 3 add 0x14 self.hal_write_command(self.LCD_DDRAM | addr) def putchar(self, char): """Writes the indicated character to the LCD at the current cursor position.""" if char != '\n': self.hal_write_data(ord(char)) self.cursor_x += 1 if self.cursor_x >= self.num_columns or char == '\n': self.cursor_x = 0 self.cursor_y += 1 if self.cursor_y >= self.num_lines: self.cursor_y = 0 self.move_to(self.cursor_x, self.cursor_y) def putstr(self, string): """Write the indicated string to the LCD at the current cursor position.""" for char in string: self.putchar(char) def custom_char(self, location, char_map): """Writes a custom character to the LCD character generator RAM.""" location &= 0x7 self.hal_write_command(self.LCD_CGRAM | (location << 3)) for i in range(8): self.hal_write_data(char_map[i]) def hal_backlight_on(self): """Allows the hal layer to turn the backlight on.""" pass # To be implemented by a subclass def hal_backlight_off(self): """Allows the hal layer to turn the backlight off.""" pass # To be implemented by a subclass def hal_write_command(self, cmd): """Write a command to the LCD.""" pass # To be implemented by a subclass def hal_write_data(self, data): """Write data to the LCD.""" pass # To be implemented by a subclass
Wrapping It Up: Your IoT Victory and Beyond
Congratulations—you’ve just built a fully functional IoT weather station with a Raspberry Pi Pico! From wiring up sensors to syncing time with NTP, connecting to WiFi, and logging data to Google Sheets, you’ve crafted a system that bridges the physical and digital worlds. With a single DateTime
column, your data is now ready for easy analysis—try creating a line graph in Sheets with DateTime
as the x-axis to visualize temperature and humidity trends over time! Whether you added the optional LCD for a local display or kept it simple, you’ve got a powerful tool to monitor environmental data in real time. It’s a testament to what a little code and creativity can achieve. What’s next? Maybe tweak the sensor interval, add more sensors like light or pressure, or dive deeper into graphing your data. The possibilities are endless—keep tinkering, and let your IoT adventures soar!
Comments
Post a Comment