🏁 F1 Calendar Script – Google Calendar Integration for Race Automation

F1 Calendar Script is a lightweight personal automation tool that integrates with Google Calendar to detect upcoming Formula 1 events and trigger local scripts β€” such as launching a Formula 1 streaming app on an Android TV box.

The script runs entirely on your own Windows PC or server, respects your privacy, and requires only read-only access to your Google Calendar. It’s ideal for fans who want to automate their race-day setup without cloud dependencies.


πŸ”§ Features

  • Monitors shared or personal Google Calendar events with β€œF1” in the title
  • Automatically runs .bat scripts to start and stop apps or systems
  • Works with Android TV via ADB, smart home hubs, or any local automation
  • Logs activity and avoids duplicate triggers
  • Runs on Windows (Python 3.x required)

πŸ” Privacy & Security


πŸ“₯ Download & Setup


πŸ§ͺ Verification Status

This app has been submitted to Google for verification to remove unverified app warnings and allow long-term token use. Until approved, you may see a security prompt when authorizing access.


πŸ’‘ Want to Contribute or Learn More?

This tool was developed in collaboration with ChatGPT by OpenAI to help F1 fans streamline their home race setup.

import datetime
import os
import re
import json
import sys
import warnings
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

# === CONFIGURATION ===
START_SCRIPT = r"C:\Users\mnorth\Desktop\F1-Start.bat"
STOP_SCRIPT = r"C:\Users\mnorth\Desktop\F1-Stop.bat"
CALENDAR_ID = 'lr1dt7l71u9929odlmfkvuq8u56gso3q@import.calendar.google.com'
KEYWORD = 'F1'
LOG_FILE_PATH = r"C:\Users\mnorth\Desktop\f1_calendar_log.txt"
STATE_FILE = r"C:\Users\mnorth\Desktop\f1_state.json"
LOCK_FILE = r"C:\Users\mnorth\Desktop\f1_calendar.lock"
ENABLE_LOGGING_TO_FILE = True

# === EARLY LOGGING FUNCTION ===
def early_log(msg):
    timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    try:
        with open(LOG_FILE_PATH, "a", encoding="utf-8") as f:
            f.write(f"{timestamp} - {msg}\n")
    except Exception as e:
        print(f"[EARLY_LOG ERROR] {e}")
    print(msg)

# === LOCK FILE CHECK ===
if os.path.exists(LOCK_FILE):
    early_log("[INFO] Lock file exists. Another instance may be running. Exiting.")
    sys.exit()
else:
    with open(LOCK_FILE, "w") as f:
        f.write(str(os.getpid()))

# === Suppress warnings ===
warnings.filterwarnings("ignore", category=DeprecationWarning)

# === Logging with 24-hour retention and max 1000 lines ===
def log_message(msg):
    print(msg)
    if not ENABLE_LOGGING_TO_FILE:
        return
    try:
        now = datetime.datetime.now()
        lines_to_keep = []
        if os.path.exists(LOG_FILE_PATH):
            with open(LOG_FILE_PATH, "r", encoding="utf-8") as f:
                for line in f:
                    match = re.match(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", line)
                    if match:
                        timestamp = datetime.datetime.strptime(match.group(1), "%Y-%m-%d %H:%M:%S")
                        if (now - timestamp).total_seconds() <= 86400:
                            lines_to_keep.append(line)
                    elif line.startswith("==="):
                        lines_to_keep.append(line)
        if not any(f"=== {now.strftime('%Y-%m-%d')} ===" in line for line in lines_to_keep):
            lines_to_keep.append(f"\n=== {now.strftime('%Y-%m-%d')} ===\n")
        new_line = f"{now.strftime('%Y-%m-%d %H:%M:%S')} - {msg}\n"
        lines_to_keep.append(new_line)
        if len(lines_to_keep) > 1000:
            lines_to_keep = lines_to_keep[-1000:]
        with open(LOG_FILE_PATH, "w", encoding="utf-8") as f:
            f.writelines(lines_to_keep)
    except Exception as e:
        print(f"[ERROR] Failed to write to log file: {e}")

def load_state():
    if os.path.exists(STATE_FILE):
        try:
            with open(STATE_FILE, "r", encoding="utf-8") as f:
                state = json.load(f)
                log_message(f"[STATE] Loaded state file with {len(state)} entries.")
                return state
        except Exception as e:
            log_message(f"[WARN] Failed to load state file: {e}")
            return {}
    log_message("[STATE] No existing state file found.")
    return {}

def save_state(state):
    with open(STATE_FILE, "w", encoding="utf-8") as f:
        json.dump(state, f)

log_message("[INFO] Script launched.")

# === Authenticate ===
SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']
try:
    creds = Credentials.from_authorized_user_file('token.json', SCOPES)
    service = build('calendar', 'v3', credentials=creds)
except Exception as e:
    log_message(f"[ERROR] Failed to authenticate: {e}")
    if os.path.exists(LOCK_FILE): os.remove(LOCK_FILE)
    sys.exit()

# === Define time window ===
now_utc = datetime.datetime.now(datetime.timezone.utc)
past = (now_utc - datetime.timedelta(hours=4)).isoformat()
future = (now_utc + datetime.timedelta(hours=2)).isoformat()
log_message(f"[INFO] Checking for '{KEYWORD}' events from {past} to {future}")

# === Fetch events ===
try:
    events_result = service.events().list(
        calendarId=CALENDAR_ID,
        timeMin=past,
        timeMax=future,
        singleEvents=True,
        orderBy='startTime'
    ).execute()
    events = events_result.get('items', [])
except Exception as e:
    log_message(f"[ERROR] Failed to retrieve events: {e}")
    if os.path.exists(LOCK_FILE): os.remove(LOCK_FILE)
    sys.exit()

log_message(f"[DEBUG] Found {len(events)} total upcoming event(s).")
state = load_state()
found_matching = False

for event in events:
    title = event.get('summary', '(No Title)')
    start_str = event['start'].get('dateTime')
    end_str = event['end'].get('dateTime')
    event_id = event.get('id')
    log_message(f"[EVENT] Title: {title}")
    log_message(f"        Start: {start_str}")
    log_message(f"        End:   {end_str}")
    if KEYWORD not in title:
        continue
    if not start_str or not end_str:
        log_message(f"[WARN] Skipping event with missing times.")
        continue
    found_matching = True
    try:
        start_dt = datetime.datetime.fromisoformat(start_str).astimezone(datetime.timezone.utc)
        end_dt = datetime.datetime.fromisoformat(end_str).astimezone(datetime.timezone.utc)
    except Exception as e:
        log_message(f"[ERROR] Failed to parse datetime: {e}")
        continue
    seconds_to_start = (start_dt - now_utc).total_seconds()
    seconds_since_end = (now_utc - end_dt).total_seconds()
    if 300 <= seconds_to_start <= 600:
        if state.get(event_id) != "started":
            log_message(f"[ACTION] 5–10 minutes before event start. Running START script...")
            os.system(f'"{START_SCRIPT}"')
            state[event_id] = "started"
            save_state(state)
        else:
            log_message(f"[SKIP] START script already run for this event.")
    elif 3600 <= seconds_since_end <= 7200:
        if state.get(event_id) != "stopped":
            log_message(f"[ACTION] 1–2 hours after event end. Running STOP script...")
            os.system(f'"{STOP_SCRIPT}"')
            state[event_id] = "stopped"
            save_state(state)
        else:
            log_message(f"[SKIP] STOP script already run for this event.")

if not found_matching:
    log_message(f"[INFO] No matching '{KEYWORD}' events found in the checked window.")

log_message("[INFO] Script finished.")
if os.path.exists(LOCK_FILE):
    os.remove(LOCK_FILE)