Building a Google Calendar Event Notifier with Raspberry Pi Zero W
This project turns a Raspberry Pi Zero W, a 20×4 LCD display, an active buzzer, and a PIR motion sensor into a smart desk notifier that fetches your Google Calendar events over Wi-Fi and alerts you when one is about to start.
How It Works
The Raspberry Pi polls the Google Calendar API on a configurable interval. Upcoming events are displayed on the LCD2004 screen. When an event's start time arrives, the buzzer fires. The PIR motion sensor adds one quality-of-life touch: the display only activates when someone is actually at the desk, saving backlight life and avoiding distracting light in an otherwise dark room.
Components
| Component | Notes |
|---|---|
| Raspberry Pi Zero W (with headers) | Built-in Wi-Fi is essential for this project |
| I2C LCD2004 (20×4) | With the I2C adapter board pre-soldered |
| Active Buzzer | The "active" type drives itself — no PWM needed |
| PIR Motion Sensor | HC-SR501 or equivalent |
| Breadboard | 400-tie-point is plenty |
| Jumper Wires | Female-to-female and male-to-female |
| Micro-USB Power Supply | 5V/2A minimum |
Wiring
LCD2004 (I2C) → Raspberry Pi Zero W
The I2C adapter on the LCD2004 board means only four wires are needed.
| LCD2004 Pin | Pi Zero W Pin | GPIO |
|---|---|---|
| VCC | Pin 2 or 4 | 5V |
| GND | Pin 6 | GND |
| SDA | Pin 3 | SDA 1 / GPIO 2 |
| SCL | Pin 5 | SCL 1 / GPIO 3 |
Active Buzzer → Raspberry Pi Zero W
| Buzzer Leg | Pi Zero W Pin | GPIO |
|---|---|---|
| Long leg (+) | Pin 11 | GPIO 17 |
| Short leg (−) | Pin 9 | GND |
The active buzzer draws ~30 mA, well within the 50 mA per-pin safe limit of the Pi's GPIO. No transistor driver is required.
PIR Motion Sensor → Raspberry Pi Zero W
| PIR Pin | Pi Zero W Pin | GPIO |
|---|---|---|
| V / VCC | 5V | — |
| G / GND | GND | — |
| S / OUT | Pin 7 | GPIO 4 |
Adjust the sensitivity and delay trimpots on the HC-SR501 to suit your desk distance (typically 1–2 m) and a ~5-second hold time.
Phase 1 — Hello World on the LCD
Before wiring up the buzzer or PIR, or touching the Calendar API, it pays to verify the LCD is alive and responding. This phase gets text on the screen in under ten minutes.
Step 1 — Enable I2C on the Pi
After reboot, confirm the LCD is visible on the bus:
You should see a single address — 0x27 or 0x3F — in the grid. Note it
down; you'll use it in the script below.
0 1 2 3 4 5 6 7 8 9 a b c d e f
00: -- -- -- -- -- -- -- -- -- -- -- -- --
...
20: -- -- -- -- -- -- -- 27 -- -- -- -- -- -- -- --
!!! tip "Nothing shows up?" Double-check that SDA goes to Pin 3 (GPIO 2) and SCL to Pin 5 (GPIO 3). A reversed pair is the most common first-timer mistake.
Step 2 — Install the LCD Library
sudo apt install -y python3-venv python3-full python3-smbus
python3 -m venv ~/calendar-notifier/.venv
source ~/calendar-notifier/.venv/bin/activate
python -m pip install --upgrade pip
pip install RPLCD smbus2
Use this same virtual environment for every script in this project:
Step 3 — Run the Hello World Script
Save the following as hello_lcd.py in your home directory on the Pi and run it:
from RPLCD.i2c import CharLCD
import time
LCD_I2C_ADDRESS = 0x27 # change to 0x3F if that's what i2cdetect showed
lcd = CharLCD(
i2c_expander="PCF8574",
address=LCD_I2C_ADDRESS,
port=1,
cols=20,
rows=4,
dotsize=8,
)
lcd.clear()
lcd.write_string("Hello, Pi!")
lcd.crlf()
lcd.write_string("LCD is working :)")
time.sleep(10)
lcd.clear()
You should see two lines of text on the display for 10 seconds, then the screen clears.
Step 4 — Adjust the Contrast Potentiometer
If the characters appear too faint or completely invisible, locate the small blue trimmer pot on the I2C adapter board (the little PCB piggy-backed onto the LCD) and turn it with a small flathead screwdriver until the text is crisp.
Step 5 — Scroll a Long Message
Once static text works, try a scrolling message to confirm the library's cursor control is functioning correctly:
from RPLCD.i2c import CharLCD
import time
LCD_I2C_ADDRESS = 0x27
lcd = CharLCD(
i2c_expander="PCF8574",
address=LCD_I2C_ADDRESS,
port=1,
cols=20,
rows=4,
dotsize=8,
)
message = "Phase 1 complete — ready for Google Calendar! "
lcd.clear()
for _ in range(3): # scroll three times
for i in range(len(message)):
lcd.clear()
lcd.write_string(message[i:i + 20])
time.sleep(0.15)
lcd.clear()
When this runs cleanly you have confirmed:
- I2C is enabled and the address is correct
- The RPLCD library can write and clear the display
- The backlight and contrast are set properly
You're ready to move on to wiring the buzzer and PIR sensor, and then integrating the Google Calendar API in the sections below.
Phase 1.5 — PIR Motion Sensor Hello World
Before integrating PIR into the main notifier loop, run this quick standalone test to verify the sensor output changes when motion is detected.
import time
import RPi.GPIO as GPIO
PIR_PIN = 4 # BCM numbering, physical pin 7
GPIO.setmode(GPIO.BCM)
GPIO.setup(PIR_PIN, GPIO.IN)
print("PIR test started. Press Ctrl+C to stop.")
print("Waiting 20 seconds for PIR warm-up...")
time.sleep(20)
last_state = None
try:
while True:
state = GPIO.input(PIR_PIN)
if state != last_state:
if state:
print("Motion detected")
else:
print("No motion")
last_state = state
time.sleep(0.2)
except KeyboardInterrupt:
pass
finally:
GPIO.cleanup()
Run it with:
Expected behavior:
- The first ~20 seconds are stabilization time.
- After warm-up, the script prints only when the sensor state changes.
- If it constantly reports motion or feels too insensitive, tune the two trimpots on the HC-SR501 (sensitivity and retrigger delay).
Software Setup
1. Prepare the Pi
Flash Raspberry Pi OS Lite to a microSD card. Before first boot, pre-configure
headless Wi-Fi and SSH by dropping wpa_supplicant.conf and an empty ssh file
into the /boot partition.
Once booted, open raspi-config and enable the I2C interface:
Reboot, then verify the LCD is detected on the I2C bus:
sudo apt install -y i2c-tools
i2cdetect -y 1
# Expect to see address 0x27 or 0x3F for the LCD adapter
2. Install Python Dependencies
sudo apt install -y python3-venv python3-full python3-smbus
python3 -m venv ~/calendar-notifier/.venv
source ~/calendar-notifier/.venv/bin/activate
python -m pip install --upgrade pip
pip install RPLCD smbus2 RPi.GPIO google-api-python-client google-auth-oauthlib
If you already created the virtual environment earlier, you only need:
source ~/calendar-notifier/.venv/bin/activate
pip install RPLCD smbus2 RPi.GPIO google-api-python-client google-auth-oauthlib
| Library | Purpose |
|---|---|
RPLCD |
High-level driver for character LCDs over I2C |
smbus2 |
I2C/SMBus backend used by LCD libraries on newer Python setups |
RPi.GPIO |
GPIO control for buzzer and PIR |
google-api-python-client |
Google Calendar REST API client |
google-auth-oauthlib |
OAuth2 authentication flow |
3. Google Calendar API Credentials
- Go to Google Cloud Console and create a new project.
- Enable the Google Calendar API.
- Create an OAuth 2.0 Client ID (Desktop app type) and download
credentials.json. - Copy
credentials.jsonto your home directory on the Pi (e.g.,~/calendar-notifier/).
The first run will require an interactive OAuth consent flow. Since the Pi Zero W is
headless, use SSH port-forwarding to complete the initial authentication and generate
token.json. Subsequent runs use the cached token automatically.
SSH Port-Forwarding for the OAuth Flow
The OAuth flow opens a local web server on a random port and then redirects your
browser to http://localhost:<port> to capture the authorization code. Because the
Pi has no browser, you tunnel that port from the Pi back to your desktop.
1. Start the script on the Pi and note the redirect URL
Run the notifier (or a minimal auth-only script) on the Pi — but do not open the URL yet:
The output will include a line like:
Note the port number (e.g. 58732).
2. Open an SSH tunnel from your desktop
In a second terminal on your local machine, forward the Pi's localhost port to your local machine:
# macOS / Linux
ssh -N -L 58732:localhost:58732 pi@<pi-ip-address>
# Windows (PowerShell or Command Prompt)
ssh -N -L 58732:localhost:58732 pi@<pi-ip-address>
Replace 58732 with the actual port from step 1, and <pi-ip-address> with the Pi's
IP (find it with hostname -I on the Pi).
3. Open the URL in your desktop browser
Paste the full URL from step 1 into a browser on your desktop machine. Because the
tunnel maps localhost:58732 on your machine to localhost:58732 on the Pi, the
OAuth callback will reach the running script.
4. Approve the consent screen and finish
After clicking through the Google consent screen the browser is redirected to
localhost:58732. The script captures the code, writes token.json, and exits the
auth flow. You can now close the SSH tunnel (Ctrl+C in the tunnel terminal).
!!! tip "Port already chosen?"
You can force a fixed port by changing the run_local_server call:
```python
creds = flow.run_local_server(port=8080, open_browser=False)
```
Then always tunnel port `8080`, so you can set up the tunnel *before* starting
the script.
The Python Script
Below is the complete implementation with inline commentary.
import time
import datetime
import os
import RPi.GPIO as GPIO
from RPLCD.i2c import CharLCD
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
# ── Configuration ────────────────────────────────────────────────────────────
SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]
CREDS_FILE = "credentials.json"
TOKEN_FILE = "token.json"
BUZZER_PIN = 17
PIR_PIN = 4
POLL_INTERVAL = 600 # seconds between Calendar API calls (10 minutes)
ALERT_WINDOW = 5 * 60 # buzz when event starts within 5 minutes
LCD_I2C_ADDRESS = 0x27 # change to 0x3F if 0x27 is not detected
LCD_COLS = 20
LCD_ROWS = 4
# ── GPIO Setup ───────────────────────────────────────────────────────────────
GPIO.setmode(GPIO.BCM)
GPIO.setup(BUZZER_PIN, GPIO.OUT, initial=GPIO.LOW)
GPIO.setup(PIR_PIN, GPIO.IN)
# ── LCD Setup ────────────────────────────────────────────────────────────────
lcd = CharLCD(i2c_expander="PCF8574", address=LCD_I2C_ADDRESS,
port=1, cols=LCD_COLS, rows=LCD_ROWS, dotsize=8)
# ── Google Calendar Auth ─────────────────────────────────────────────────────
def get_calendar_service():
creds = None
if os.path.exists(TOKEN_FILE):
creds = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(CREDS_FILE, SCOPES)
creds = flow.run_local_server(port=0, open_browser=False)
with open(TOKEN_FILE, "w") as token:
token.write(creds.to_json())
return build("calendar", "v3", credentials=creds)
# ── Fetch Upcoming Events ────────────────────────────────────────────────────
def fetch_events(service, max_results=5):
now = datetime.datetime.utcnow().isoformat() + "Z"
# End of today in UTC
end_of_day = datetime.datetime.utcnow().replace(hour=23, minute=59, second=59).isoformat() + "Z"
result = service.events().list(
calendarId="primary",
timeMin=now,
timeMax=end_of_day,
maxResults=max_results,
singleEvents=True,
orderBy="startTime",
).execute()
# Filter to only events where user responded yes
items = result.get("items", [])
accepted_events = []
for event in items:
# If no attendees list, assume it's accepted (single-user event)
if "attendees" not in event:
accepted_events.append(event)
else:
# Check if user is an attendee with response status "accepted"
for attendee in event["attendees"]:
if attendee.get("self") and attendee.get("responseStatus") == "accepted":
accepted_events.append(event)
break
return accepted_events
# ── Display Helpers ──────────────────────────────────────────────────────────
def display_event(event):
lcd.clear()
summary = event.get("summary", "No title")[:LCD_COLS]
start = event["start"].get("dateTime", event["start"].get("date", ""))
try:
dt = datetime.datetime.fromisoformat(start)
time_str = dt.strftime("%b %d %H:%M")
except ValueError:
time_str = start[:LCD_COLS]
lcd.write_string(summary.ljust(LCD_COLS))
lcd.crlf()
lcd.write_string(time_str.ljust(LCD_COLS))
def display_idle():
lcd.clear()
lcd.write_string("No upcoming events".center(LCD_COLS))
# ── Buzzer Alert ─────────────────────────────────────────────────────────────
def buzz(times=3, on_ms=200, off_ms=200):
for _ in range(times):
GPIO.output(BUZZER_PIN, GPIO.HIGH)
time.sleep(on_ms / 1000)
GPIO.output(BUZZER_PIN, GPIO.LOW)
time.sleep(off_ms / 1000)
# ── Main Loop ────────────────────────────────────────────────────────────────
def main():
service = get_calendar_service()
events = []
alerted = set() # track event IDs we've already buzzed for
last_poll = 0
lcd_on = False
try:
while True:
now_ts = time.time()
# Poll Calendar API periodically
if now_ts - last_poll >= POLL_INTERVAL:
events = fetch_events(service)
last_poll = now_ts
# Check if there are upcoming events (not yet started)
now_dt = datetime.datetime.now(datetime.timezone.utc)
has_upcoming_events = False
for event in events:
start_str = event["start"].get("dateTime")
if not start_str:
continue
start_dt = datetime.datetime.fromisoformat(start_str)
if (start_dt - now_dt).total_seconds() > 0:
has_upcoming_events = True
break
# PIR gates the display: only on if motion AND upcoming events exist
motion = GPIO.input(PIR_PIN)
should_display_be_on = motion and has_upcoming_events
if should_display_be_on and not lcd_on:
lcd.backlight_enabled = True
lcd_on = True
print(f"Display ON: motion={motion}, has_events={has_upcoming_events}")
elif not should_display_be_on and lcd_on:
lcd.backlight_enabled = False
lcd_on = False
print(f"Display OFF: motion={motion}, has_events={has_upcoming_events}")
# Check for imminent events and trigger buzz
next_event = None
for event in events:
start_str = event["start"].get("dateTime")
if not start_str:
continue # skip all-day events
start_dt = datetime.datetime.fromisoformat(start_str)
delta = (start_dt - now_dt).total_seconds()
if 0 <= delta <= ALERT_WINDOW:
next_event = event
if event["id"] not in alerted:
buzz()
alerted.add(event["id"])
break
# Display content only if display is on
if lcd_on:
if next_event:
display_event(next_event)
elif events:
display_event(events[0])
else:
display_idle()
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
lcd.clear()
GPIO.cleanup()
if __name__ == "__main__":
main()
Running as a systemd Service
To start the notifier automatically on boot:
# /etc/systemd/system/calendar-notifier.service
[Unit]
Description=Google Calendar Event Notifier
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/python3 /home/pi/calendar-notifier/notifier.py
WorkingDirectory=/home/pi/calendar-notifier
Restart=on-failure
User=pi
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable calendar-notifier
sudo systemctl start calendar-notifier
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
i2cdetect shows nothing |
I2C not enabled or wiring error | Re-run raspi-config, check SDA/SCL connections |
| LCD shows garbled text | Wrong I2C address | Try 0x3F instead of 0x27 in the script |
google.auth.exceptions.RefreshError |
token.json expired with no refresh token |
Delete token.json and re-run the OAuth flow |
| Buzzer sounds continuously | Active buzzer pin left HIGH after crash | GPIO.cleanup() in a finally block (already in script) |
| PIR too sensitive / too slow | Trimpot mis-set | Adjust the two orange trimpots on the HC-SR501 |
Possible Extensions
- Multi-calendar support — pass a list of
calendarIdvalues tofetch_events. - Snooze button — wire a tactile button to a GPIO pin; pressing it delays the next buzz for that event by 5 minutes.
- LED ring accent — attach a NeoPixel ring to flash colors based on event category.
- Telegram / ntfy notification — send a push notification to your phone alongside the local buzz.