Entry for Hacktoberfest 2026, Week 1: "Touch Grass" — build something with open-source AI that gets people off the screen and into the world.

Picture this: you're standing at the top of Chembra Peak, looking down at the heart-shaped lake, your phone showing zero signal bars. Your friend pulls up three different trekking apps and every single one is useless — no cached data, no offline briefings, and the one "AI trail assistant" needs the cloud just to tell you what to pack. That exact frustration is why I built Trek Buddy.

The premise is simple: if the whole point of an app is to help you on a trail, it has to work where trails actually are — offline. And once you commit to offline-first, the AI has to follow: no cloud calls, no API keys, no subscription. Just an open-weight model running on your own machine.

What I Built

  • 10 curated Kerala trails — Chembra, Meesapulimala, Agasthyarkoodam, Kolukkumalai, Paithalmala, Ranipuram, Pakshipathalam, Brahmagiri, Dhoni Hills, Ilaveezhapoonchira. Filter by difficulty, and each one carries distance, duration, best months, highlights, and whether you need forest department permission.
  • Weather check with a backbone — pulls forecasts from Open-Meteo (free, no API key), and it understands Kerala's monsoon: heavy rain, violent showers, and thunderstorms get flagged as rethink your plans, not just shown as an icon.
  • A packing checklist that actually thinks — generated from the trail's difficulty, the season, and live weather. Pick a trail in July and it'll tell you to bring leech socks. Pick a moderate trail in pouring rain and rain gear jumps to the top. This is the kind of detail that makes or breaks a trek.
  • An AI trail briefing from a model on your laptop — via Ollama and any open-weight model (I use gemma3). It writes a personalized briefing for your chosen trail: what to expect, how to pace, what to watch out for. No Ollama installed? It falls back to built-in guide notes, and everything else works identically.
  • Fully offline mode — forecasts are cached as JSON, so with zero signal the app keeps working off your last check. Your diary of completed treks lives in a local file too. Nothing ever leaves your machine.

Demo

Here's a real session — browsing trails, picking Chembra Peak, and getting the weather-aware plan in one go:

--- Trek Buddy 🥾 --- (type 'quit' to exit) 1. Browse trails 2. Filter by difficulty 3. View trek diary What do you want to do? (1/2/3 or quit): 1 1. Chembra Peak (Wayanad) — moderate, 9 km 2. Meesapulimala (Idukki) — hard, 8 km 3. Agasthyarkoodam (Thiruvananthapuram) — hard, 28 km ... (10 Kerala trails total) Pick a trail (number): 1 🥾 Chembra Peak — Wayanad Difficulty : moderate Distance : 9 km Duration : 4-5 hours round trip Best months : Sep–May Permission : Forest dept permission from Meppady office; limited daily slots Checking weather... 🌦️ Light drizzle, 16.4°C — wind 6.9 km/h (live) 🌧️ Rain expected — pack rain gear and watch for slippery trails. 🎒 Packing checklist: [ ] 2L water (minimum) [ ] Snacks / energy bars [ ] First-aid kit [ ] Fully charged phone + power bank [ ] Trekking shoes with grip [ ] Trekking pole [ ] Raincoat / poncho [ ] Dry bag for phone 📖 Guide notes: Difficulty is moderate — 4-5 hours round trip. Don't miss: heart-shaped Hridaya Saras lake. Permission required: Forest dept permission from Meppady office; limited daily slots. Leech territory — tuck your pants into your socks. 

With Ollama running, the guide notes are replaced by a personalized AI briefing written by gemma3 on your own machine. Without internet, the same screen renders from the cached forecast instead — the app never breaks.

Code

The whole thing is one Python file — 100% standard library, no dependencies to install:

trek_buddy.py (370 lines — click to expand)

""" Trek Buddy — your offline trekking companion for Kerala. 🥾 Pick a trail, check the weather, get a packing checklist, and get a trail briefing from a local AI running 100% on your own machine (no internet, no API keys, no data leaving your laptop). How to run: python trek_buddy.py Optional AI layer: 1. Install Ollama from https://ollama.com 2. Pull a small open-weight model: ollama pull gemma3 3. Make sure Ollama is running (it starts automatically) Without Ollama the app still works fully — the briefing just comes from the built-in guide notes instead of the local model. Trail coordinates are approximate (district level) — good enough for a weather check, not for navigation. Always trek with a guide where required. """ import datetime import json import os import urllib.parse import urllib.request BASE_DIR = os.path.dirname(os.path.abspath(__file__)) TRAILS_FILE = os.path.join(BASE_DIR, "trails.json") CACHE_FILE = os.path.join(BASE_DIR, "weather_cache.json") LOG_FILE = os.path.join(BASE_DIR, "trip_log.json") WEATHER_URL = "https://api.open-meteo.com/v1/forecast" OLLAMA_URL = "http://localhost:11434/api/generate" OLLAMA_TAGS_URL = "http://localhost:11434/api/tags" # WMO weather codes -> description + emoji (same mapping as the weather app) WEATHER_CODES = { 0: ("Clear sky", "☀️"), 1: ("Mainly clear", "🌤️"), 2: ("Partly cloudy", "⛅"), 3: ("Overcast", "☁️"), 45: ("Fog", "🌫️"), 48: ("Icy fog", "🌫️"), 51: ("Light drizzle", "🌦️"), 53: ("Drizzle", "🌦️"), 55: ("Heavy drizzle", "🌦️"), 61: ("Light rain", "🌧️"), 63: ("Rain", "🌧️"), 65: ("Heavy rain", "⛈️"), 71: ("Light snow", "🌨️"), 73: ("Snow", "🌨️"), 75: ("Heavy snow", "❄️"), 80: ("Light showers", "🌦️"), 81: ("Showers", "🌧️"), 82: ("Violent showers", "⛈️"), 95: ("Thunderstorm", "⛈️"), 96: ("Thunderstorm with hail", "⛈️"), 99: ("Thunderstorm with hail", "⛈️"), } # Weather codes that should make you think twice about trekking BAD_WEATHER = {65, 82, 95, 96, 99} RAINY_WEATHER = {51, 53, 55, 61, 63, 80, 81} BASE_PACKING = [ "2L water (minimum)", "Snacks / energy bars", "First-aid kit", "Fully charged phone + power bank", "Sunscreen and cap", ] MONSOON_MONTHS = {"Jun", "Jul", "Aug", "Sep"} # ---------- data loading ----------  def load_trails(): """Read the curated trail database.""" with open(TRAILS_FILE) as f: return json.load(f)["trails"] def load_cache(): """Read cached weather forecasts (used when offline).""" if not os.path.exists(CACHE_FILE): return {} try: with open(CACHE_FILE) as f: return json.load(f) except (json.JSONDecodeError, OSError): return {} def save_cache(cache): """Persist weather forecasts for offline use.""" with open(CACHE_FILE, "w") as f: json.dump(cache, f, indent=2) def load_log(): """Read the trip diary.""" if not os.path.exists(LOG_FILE): return [] try: with open(LOG_FILE) as f: return json.load(f) except (json.JSONDecodeError, OSError): return [] def save_log(entries): """Persist the trip diary.""" with open(LOG_FILE, "w") as f: json.dump(entries, f, indent=2) # ---------- weather (Open-Meteo, cached for offline use) ----------  def fetch_weather(lat, lon): """Fetch the current weather from Open-Meteo (free, no key).""" params = urllib.parse.urlencode({ "latitude": lat, "longitude": lon, "current": "temperature_2m,weather_code,wind_speed_10m", "timezone": "auto", }) with urllib.request.urlopen(f"{WEATHER_URL}?{params}", timeout=10) as r: return json.loads(r.read().decode())["current"] def get_weather(trail): """Get weather for a trail — live if online, cached if offline. Returns (weather_dict, was_cached). """ cache = load_cache() try: weather = fetch_weather(trail["lat"], trail["lon"]) weather["fetched_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") cache[trail["id"]] = weather save_cache(cache) return weather, False except Exception: # Offline — fall back to the last cached forecast  if trail["id"] in cache: return cache[trail["id"]], True return None, True def weather_warning(code): """Return a warning string for bad trekking weather, or None.""" if code in BAD_WEATHER: return "⚠️ Heavy rain or thunderstorm — seriously consider postponing this trek." if code in RAINY_WEATHER: return "🌧️ Rain expected — pack rain gear and watch for slippery trails." return None # ---------- local AI layer (Ollama, 100% on-device) ----------  def ollama_model(): """Return the name of a usable local model, or None if Ollama is down.""" try: with urllib.request.urlopen(OLLAMA_TAGS_URL, timeout=3) as r: models = json.loads(r.read().decode()).get("models", []) if models: return models[0]["name"] return None except Exception: return None def ask_local_model(prompt, model): """Send a prompt to the local model and return its reply (no streaming).""" body = json.dumps({ "model": model, "prompt": prompt, "stream": False, }).encode() req = urllib.request.Request( OLLAMA_URL, data=body, headers={"Content-Type": "application/json"} ) with urllib.request.urlopen(req, timeout=120) as r: return json.loads(r.read().decode())["response"].strip() def ai_briefing(trail, weather, model): """Ask the local model for a trail briefing. Returns None on failure.""" text, _ = WEATHER_CODES.get(weather["weather_code"], ("Unknown", "🌡️")) prompt = ( f"You are a friendly Kerala trekking guide. Trail: {trail['name']} " f"({trail['district']}, {trail['difficulty']}, {trail['distance_km']} km, " f"{trail['duration']}). Current weather there: {text}, " f"{weather['temperature_2m']}°C. Highlights: " f"{', '.join(trail['highlights'])}. " "Give a short trail briefing: what to expect, one safety tip, " "and one thing to look forward to. Under 120 words. " "Warm tone, no fluff." ) try: return ask_local_model(prompt, model) except Exception: return None def guide_notes(trail): """Rule-based briefing used when no local model is available.""" notes = [ f"Difficulty is {trail['difficulty']} — {trail['duration']}.", f"Don't miss: {trail['highlights'][0].lower()}.", ] if trail["permission_needed"]: note = trail["permission_note"].rstrip(".") notes.append(f"Permission required: {note}.") if trail["leech_risk"]: notes.append("Leech territory — tuck your pants into your socks.") return " ".join(notes) # ---------- packing checklist ----------  def packing_checklist(trail, weather): """Build a packing list from trail difficulty, season, and live weather.""" items = list(BASE_PACKING) month = datetime.datetime.now().strftime("%b") if trail["difficulty"] in ("moderate", "hard"): items += ["Trekking shoes with grip", "Trekking pole"] if trail["difficulty"] == "hard": items += ["Headlamp", "Extra food for the long haul"] if trail["leech_risk"] and month in MONSOON_MONTHS: items += ["Leech socks", "Salt / sanitizer for leeches"] if weather and weather["weather_code"] in RAINY_WEATHER | BAD_WEATHER: items += ["Raincoat / poncho", "Dry bag for phone"] if weather and weather["temperature_2m"] < 15: items += ["Warm layer / jacket"] return items # ---------- UI ----------  def show_trail(trail, index): print(f" {index}. {trail['name']} ({trail['district']}) — " f"{trail['difficulty']}, {trail['distance_km']} km") def show_trail_detail(trail): print(f"\n🥾 {trail['name']} — {trail['district']}") print("-" * 45) print(f" Difficulty : {trail['difficulty']}") print(f" Distance : {trail['distance_km']} km") print(f" Duration : {trail['duration']}") print(f" Best months : {', '.join(trail['best_months'])}") if trail["permission_needed"]: print(f" Permission : {trail['permission_note']}") print(" Highlights :") for h in trail["highlights"]: print(f" • {h}") print() def show_weather(weather, cached): text, emoji = WEATHER_CODES.get(weather["weather_code"], ("Unknown", "🌡️")) tag = f"(cached, {weather.get('fetched_at', 'unknown time')})" if ca...
Source:https://dev.to/abhiyog_nejin_69b4b7518e1/trek-buddy-an-offline-trekking-companion-that-runs-its-ai-on-your-laptop-not-in-the-cloud-47km