Skip to content

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

sudo raspi-config
# → Interface Options → I2C → Enable → Finish
sudo reboot

After reboot, confirm the LCD is visible on the bus:

sudo apt install -y i2c-tools
i2cdetect -y 1

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:

source ~/calendar-notifier/.venv/bin/activate

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()
python3 hello_lcd.py

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:

python3 pir_test.py

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:

sudo raspi-config
# → Interface Options → I2C → Enable

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

  1. Go to Google Cloud Console and create a new project.
  2. Enable the Google Calendar API.
  3. Create an OAuth 2.0 Client ID (Desktop app type) and download credentials.json.
  4. Copy credentials.json to 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:

python3 notifier.py

The output will include a line like:

Please visit this URL to authorize this application:
  http://localhost:58732/...

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 calendarId values to fetch_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.

References