{ config, lib, pkgs, namespace, ... }: let cfg = config.${namespace}.programs.waybar; waybar-weather = pkgs.writeScriptBin "waybar-weather" '' #!/usr/bin/env nix-shell #! nix-shell -i python3 --pure #! nix-shell -p python3 python3Packages.requests import os import json import shutil from datetime import datetime, timedelta import argparse import math import requests parser = argparse.ArgumentParser(prog='waybar-weather') parser.add_argument('--waybar', action='store_true') parser.add_argument('--hyprlock', action='store_true') args = parser.parse_args() # --- MAPPINGS --- WWO_CODE = { "113": "Sunny", "116": "PartlyCloudy", "119": "Cloudy", "122": "VeryCloudy", "143": "Fog", "176": "LightShowers", "179": "LightSleetShowers", "182": "LightSleet", "185": "LightSleet", "200": "ThunderyShowers", "227": "LightSnow", "230": "HeavySnow", "248": "Fog", "260": "Fog", "263": "LightShowers", "266": "LightRain", "281": "LightSleet", "284": "LightSleet", "293": "LightRain", "296": "LightRain", "299": "HeavyShowers", "302": "HeavyRain", "305": "HeavyShowers", "308": "HeavyRain", "311": "LightSleet", "314": "LightSleet", "317": "LightSleet", "320": "LightSnow", "323": "LightSnowShowers", "326": "LightSnowShowers", "329": "HeavySnow", "332": "HeavySnow", "335": "HeavySnowShowers", "338": "HeavySnow", "350": "LightSleet", "353": "LightShowers", "356": "HeavyShowers", "359": "HeavyRain", "362": "LightSleetShowers", "365": "LightSleetShowers", "368": "LightSnowShowers", "371": "HeavySnowShowers", "374": "LightSleetShowers", "377": "LightSleet", "386": "ThunderyShowers", "389": "ThunderyHeavyRain", "392": "ThunderySnowShowers", "395": "HeavySnowShowers", } # Maps WMO codes (OpenMeteo) to WWO codes (wttr.in) WMO_TO_WWO = { 0: "113", # Clear sky -> Sunny 1: "113", # Mainly clear -> Sunny 2: "116", # Partly cloudy 3: "122", # Overcast -> VeryCloudy 45: "143", # Fog 48: "248", # Depositing rime fog 51: "266", # Drizzle: Light 53: "266", # Drizzle: Moderate (mapped to LightRain) 55: "296", # Drizzle: Dense intensity (LightRain usually suits better than heavy) 56: "281", # Freezing Drizzle: Light 57: "284", # Freezing Drizzle: Dense 61: "296", # Rain: Slight 63: "302", # Rain: Moderate 65: "308", # Rain: Heavy 66: "311", # Freezing Rain: Light 67: "314", # Freezing Rain: Heavy 71: "326", # Snow fall: Slight 73: "332", # Snow fall: Moderate 75: "338", # Snow fall: Heavy 77: "350", # Snow grains 80: "353", # Rain showers: Slight 81: "356", # Rain showers: Moderate 82: "359", # Rain showers: Violent 85: "368", # Snow showers: Slight 86: "371", # Snow showers: Heavy 95: "386", # Thunderstorm: Slight or moderate 96: "389", # Thunderstorm with slight hail 99: "395", # Thunderstorm with heavy hail } WEATHER_SYMBOL = { "Unknown": "", "Cloudy": "", "Fog": "", "HeavyRain": "", "HeavyShowers": "", "HeavySnow": "", "HeavySnowShowers": "", "LightRain": "", "LightShowers": "", "LightSleet": "", "LightSleetShowers": "", "LightSnow": "", "LightSnowShowers": "", "PartlyCloudy": "󰖕", "Sunny": "", "ThunderyHeavyRain": "", "ThunderyShowers": "", "ThunderySnowShowers": "", "VeryCloudy": "", } WEATHER_CODES = {key: WEATHER_SYMBOL[value] for key, value in WWO_CODE.items()} WIND_DIRECTION = { "S": "↓", "SW": "↙", "SSW": "↙", "W": "←", "NW": "↖", "NNW": "↖", "N": "↑", "NE": "↗", "NNE": "↗", "E": "→", "SE": "↘", "SSE": "↘", } WEATHER_SYMBOL_WI_DAY = { "Unknown": "", "Cloudy": "", "Fog": "", "HeavyRain": "", "HeavyShowers": "", "HeavySnow": "", "HeavySnowShowers": "", "LightRain": "", "LightShowers": "", "LightSleet": "", "LightSleetShowers": "", "LightSnow": "", "LightSnowShowers": "", "PartlyCloudy": "󰖕", "Sunny": "", "ThunderyHeavyRain": "", "ThunderyShowers": "", "ThunderySnowShowers": "", "VeryCloudy": "", } WEATHER_CODES_WI_DAY = {key: WEATHER_SYMBOL_WI_DAY[value] for key, value in WWO_CODE.items()} WEATHER_SYMBOL_WI_NIGHT = { "Unknown": "", "Cloudy": "", "Fog": "", "HeavyRain": "", "HeavyShowers": "", "HeavySnow": "", "HeavySnowShowers": "", "LightRain": "", "LightShowers": "", "LightSleet": "", "LightSleetShowers": "", "LightSnow": "", "LightSnowShowers": "", "PartlyCloudy": "󰼱", "Sunny": "󰖔", "ThunderyHeavyRain": "", "ThunderyShowers": "", "ThunderySnowShowers": "", "VeryCloudy": "", } WEATHER_CODES_WI_NIGHT = {key: WEATHER_SYMBOL_WI_NIGHT[value] for key, value in WWO_CODE.items()} WEATHER_SYMBOL_WEGO = { "Unknown": [ " .-. ", " __) ", " ( ", " `-’ ", " • "], "Sunny": [ ' \\ / ', ' .-. ', ' ― ( ) ― ', ' `-’ ', ' / \\ '], "PartlyCloudy": [ ' \\ / ', ' _ /\'\'".-. ', ' \\_"( ). ', ' /"(___(__) ', ' ' ], "Cloudy": [ ' ', ' .--. ', ' .-( ). ', ' (___.__)__) ', ' '], "VeryCloudy": [ ' ', ' .--. ', ' .-( ). ', ' (___.__)__) ', ' '], "LightShowers": [ ' _`/\'\'".-. ', ' ,\\_"( ). ', ' /"(___(__) ', ' ‘ ‘ ‘ ‘ ', ' ‘ ‘ ‘ ‘ '], "HeavyShowers": [ ' _`/\'\'".-. ', ' ,\\_"( ). ', ' /"(___(__) ', ' ‚‘‚‘‚‘‚‘ ', ' ‚’‚’‚’‚’ '], "LightSnowShowers": [ ' _`/\'\'".-. ', ' ,\\_"( ). ', ' /"(___(__) ', ' * * * ', ' * * * '], "HeavySnowShowers": [ ' _`/\'\'".-. ', ' ,\\_"( ). ', ' /"(___(__) ', ' * * * * ', ' * * * * '], "LightSleetShowers": [ ' _`/\'\'".-. ', ' ,\\_"( ). ', ' /"(___(__) ', '"*""* ', ' *""*"'], "ThunderyShowers": [ ' _`/\'\'".-. ', ' ,\\_"( ). ', ' /"(___(__) ', ' ⚡\\"‘ ‘"⚡\\"‘ ‘ ', ' ‘ ‘ ‘ ‘ '], "ThunderyHeavyRain": [ ' .-. ', ' ( ). ', ' (___(__) ', ' ‚‘"⚡\\"‘‚"⚡\\"‚‘ ', ' ‚’‚’"⚡\\"’‚’ '], "ThunderySnowShowers": [ ' _`/\'\'".-. ', ' ,\\_"( ). ', ' /"(___(__) ', ' *"⚡\\"*"⚡\\"* ', ' * * * '], "LightRain": [ ' .-. ', ' ( ). ', ' (___(__) ', ' ‘ ‘ ‘ ‘ ', ' ‘ ‘ ‘ ‘ '], "HeavyRain": [ ' .-. ', ' ( ). ', ' (___(__) ', ' ‚‘‚‘‚‘‚‘ ', ' ‚’‚’‚’‚’ '], "LightSnow": [ ' .-. ', ' ( ). ', ' (___(__) ', ' * * * ', ' * * * '], "HeavySnow": [ ' .-. ', ' ( ). ', ' (___(__) ', ' * * * * ', ' * * * * '], "LightSleet": [ ' .-. ', ' ( ). ', ' (___(__) ', '"*""* ', ' *""*"'], "Fog": [ ' ', ' _ - _ - _ - ', ' _ - _ - _ ', ' _ - _ - _ - ', ' '], } WEATHER_CODES_WEGO = {key: WEATHER_SYMBOL_WEGO[value] for key, value in WWO_CODE.items()} CACHE_DIR = os.path.join(os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")), "waybar-weather") CACHE_FILE = os.path.join(CACHE_DIR, "wttr.json") CACHE_MOON_FILE = os.path.join(CACHE_DIR, "moon.json") CACHE_MOON_ICON_FILE = os.path.join(CACHE_DIR, "moon-icon") CACHE_TTL = timedelta(minutes=10) data = {} data["text"] = "" def format_time(time): return datetime.strptime(format_24_time(time), "%H").strftime("%I %p") def format_24_time(time): return time.replace("00", "").zfill(2) def format_temp(temp): return (str(temp) + "°").ljust(3) def format_chances(hour): chances = { "chanceoffog": "Fog", "chanceoffrost": "Frost", "chanceofovercast": "Overcast", "chanceofrain": "Rain", "chanceofsnow": "Snow", "chanceofsunshine": "Sunshine", "chanceofthunder": "Thunder", "chanceofwindy": "Wind", } conditions = [] for chance, event in chances.items(): if int(hour.get(chance, 0)) > 0: conditions.append(event + " " + str(hour[chance]) + "%") return ", ".join(conditions) def deg_to_compass(num): val = int((num / 22.5) + 0.5) arr = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"] return arr[(val % 16)] def build_text(current_condition): feels_like_f = current_condition["FeelsLikeF"] weather_code = current_condition["weatherCode"] # Check if we have a mapped format; if not, fallback to Unknown if weather_code not in WEATHER_CODES: weather_code = "113" # Fallback to sunny/default to prevent crash tempint = int(float(feels_like_f)) # float cast just in case extrachar = "" if 0 < tempint < 10: extrachar = "+" current_weather = f"{WEATHER_CODES[weather_code]} {extrachar} {int(feels_like_f)}°F" return current_weather def build_tooltip(current_condition, astronomy, moon_icon): weather_description = current_condition['weatherDesc'][0]['value'] feels_like_f = current_condition["FeelsLikeF"] temp_f = current_condition['temp_F'] humidity = current_condition['humidity'] wind_speed = current_condition['windspeedMiles'] wind_dir = current_condition['winddir16Point'] moon_phase = astronomy.get('moon_phase', 'Unknown') weather_code = current_condition['weatherCode'] if weather_code not in WEATHER_CODES_WEGO: weather_code = "113" wego = WEATHER_CODES_WEGO[weather_code] current = f"{wego[0]}{weather_description} {temp_f}°\n" feels = f"{wego[1]}Feels like: {feels_like_f}°\n" wind = f"{wego[2]}Wind: {wind_speed}mph {WIND_DIRECTION.get(wind_dir, ''\'')}\n" # Safe get for direction humidityl = f"{wego[3]}Humidity: {humidity}%\n" moon = f"{wego[4]}Moon phase: {moon_phase} " + moon_icon + "\n" tooltip = current + feels + wind + humidityl + moon return tooltip def build_forecast(weather): tooltip = "\n" for i, day in enumerate(weather): if i == 0: tooltip += "Today, " if i == 1: tooltip += "Tomorrow, " date = datetime.strptime(day['date'], "%Y-%m-%d").strftime("%a %b %d %Y") tooltip += f"{date}\n" max_temp = day['maxtempF'] min_temp = day['mintempF'] tooltip += f" {max_temp}°F  {min_temp}°F" sunrise = day['astronomy'][0]['sunrise'] sunset = day['astronomy'][0]['sunset'] tooltip += f" {sunrise}  {sunset}\n" tooltip += build_hourly_forecast(i, day['hourly'], sunrise, sunset) return tooltip def build_hourly_forecast(day_num, hourly, sunrise, sunset): try: sunrise_hour = datetime.strptime(sunrise, "%I:%M %p").hour sunset_hour = datetime.strptime(sunset, "%I:%M %p").hour except ValueError: # Fallback if time format is different (OpenMeteo might send 24h) sunrise_hour = int(sunrise.split(':')[0]) sunset_hour = int(sunset.split(':')[0]) current_hour = datetime.now().hour tooltip = "" for hour in hourly: time_24_hr = int(format_24_time(hour["time"])) if day_num == 0: if time_24_hr < current_hour - 2: continue if is_night_hour(time_24_hr, sunrise_hour, sunset_hour): codes = WEATHER_CODES_WI_NIGHT else: codes = WEATHER_CODES_WI_DAY current_time = format_time(hour['time']) wcode = hour['weatherCode'] if wcode not in codes: wcode = "113" # Fallback current_weather_code = codes[wcode] feels_like = format_temp(hour['FeelsLikeF']) weather_desc = hour['weatherDesc'][0]['value'] current_chances = format_chances(hour) tooltip += f"{current_time} {current_weather_code} " tooltip += f"{feels_like} {weather_desc}, {current_chances}\n" return tooltip def is_night_hour(time_24_hr, sunrise_hour, sunset_hour): before_sunrise = time_24_hr < sunrise_hour after_sunset = time_24_hr > sunset_hour return after_sunset or before_sunrise def load_cache(path, ttl): try: if not os.path.exists(path): return None mtime = datetime.fromtimestamp(os.path.getmtime(path)) if datetime.now() - mtime > ttl: return None with open(path, "r") as f: if path.endswith(".json"): return json.load(f) return f.read().strip() except Exception: return None def save_cache(path, data): os.makedirs(os.path.dirname(path), exist_ok=True) tmp = path + ".tmp" try: with open(tmp, "w") as f: if isinstance(data, dict): json.dump(data, f) else: f.write(str(data)) shutil.move(tmp, path) except Exception: pass # --- OPEN-METEO INTEGRATION HELPER FUNCTIONS --- def get_lat_lon(): """Attempt to get location via IP if using OpenMeteo""" try: resp = requests.get("http://ip-api.com/json/", timeout=5).json() return resp.get('lat'), resp.get('lon') except: # Default to a generic location if IP fetch fails (NYC) return 40.71, -74.00 def fetch_open_meteo(): """Fetch and Transform OpenMeteo data to match Wttr.in JSON structure""" lat, lon = get_lat_lon() url = "https://api.open-meteo.com/v1/forecast" params = { "latitude": lat, "longitude": lon, "current": "temperature_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m,wind_direction_10m,relative_humidity_2m", "daily": "weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max", "hourly": "temperature_2m,apparent_temperature,precipitation_probability,weather_code", "temperature_unit": "fahrenheit", "wind_speed_unit": "mph", "precipitation_unit": "inch", "timezone": "auto" } response = requests.get(url, params=params, timeout=10) om_data = response.json() # Transform Current Condition current = om_data["current"] wmo_code = current["weather_code"] wwo_code = WMO_TO_WWO.get(wmo_code, "113") wttr_current = { "temp_F": str(int(current["temperature_2m"])), "FeelsLikeF": str(int(current["apparent_temperature"])), "weatherCode": wwo_code, "weatherDesc": [{"value": WWO_CODE.get(wwo_code, "Unknown")}], "humidity": str(current["relative_humidity_2m"]), "windspeedMiles": str(int(current["wind_speed_10m"])), "winddir16Point": deg_to_compass(current["wind_direction_10m"]), } # Transform Daily Forecast (OpenMeteo gives 7 days, we need 3) wttr_weather = [] daily = om_data["daily"] hourly = om_data["hourly"] for i in range(3): date_str = daily["time"][i] # Build Hourly for this day (wttr uses 3-hour intervals: 0, 300, 600...) # OpenMeteo gives 0, 1, 2... wttr_hourly = [] for h in range(0, 24, 3): # Step by 3 hours to mimic wttr idx = (i * 24) + h h_code = hourly["weather_code"][idx] h_wwo = WMO_TO_WWO.get(h_code, "113") wttr_hourly.append({ "time": str(h * 100), # 0, 300, 600 "weatherCode": h_wwo, "weatherDesc": [{"value": WWO_CODE.get(h_wwo, "Unknown")}], "FeelsLikeF": str(int(hourly["apparent_temperature"][idx])), "chanceofrain": str(hourly["precipitation_probability"][idx]), # Fill other chances with 0 as API doesn't provide them easily "chanceoffog": "0", "chanceofsnow": "0", "chanceofthunder": "0", "chanceofwindy": "0", "chanceofsunshine": "0" }) # Astronomy sunrise = datetime.fromisoformat(daily["sunrise"][i]).strftime("%I:%M %p") sunset = datetime.fromisoformat(daily["sunset"][i]).strftime("%I:%M %p") wttr_weather.append({ "date": date_str, "maxtempF": str(int(daily["temperature_2m_max"][i])), "mintempF": str(int(daily["temperature_2m_min"][i])), "astronomy": [{"sunrise": sunrise, "sunset": sunset, "moon_phase": "Unknown"}], "hourly": wttr_hourly }) return { "current_condition": [wttr_current], "weather": wttr_weather } def get_wttr_json(hyprlock=False): # Try loading cached JSON cached = load_cache(CACHE_FILE, CACHE_TTL) cached_moon = load_cache(CACHE_MOON_FILE, CACHE_TTL) cached_moon_icon = load_cache(CACHE_MOON_ICON_FILE, CACHE_TTL) if cached and cached_moon and cached_moon_icon: weather = cached current_condition = weather["current_condition"][0] astronomy = cached_moon moon_icon = cached_moon_icon else: try: # Primary Source: wttr.in weather = requests.get("https://wttr.in/?u&format=j1", timeout=5).json() moon = requests.get("https://wttr.in/?format=%m", timeout=5) moon_icon = moon.text current_condition = weather["current_condition"][0] astronomy = weather["weather"][0]['astronomy'][0] except Exception: # Fallback Source: Open-Meteo try: print("open_mateo fallback") weather = fetch_open_meteo() current_condition = weather["current_condition"][0] astronomy = weather["weather"][0]['astronomy'][0] moon_icon = "" # Generic moon icon for fallback except Exception as e: # If both fail raise e # Save cache (works for both sources since we transformed OM data) save_cache(CACHE_FILE, weather) save_cache(CACHE_MOON_FILE, astronomy) save_cache(CACHE_MOON_ICON_FILE, moon_icon) if hyprlock: return build_tooltip(current_condition, astronomy, moon_icon) else: text = build_text(current_condition) tooltip = build_tooltip(current_condition, astronomy, moon_icon) + build_forecast(weather["weather"]) data["text"] = text data["tooltip"] = tooltip return json.dumps(data) def main(): if args.hyprlock: try: print(get_wttr_json(hyprlock=True)) except Exception as e: print("error") # print(e) # Uncomment for debug else: try: print(get_wttr_json()) except Exception as e: print(json.dumps({"text": "Err", "tooltip": str(e)})) if __name__ == "__main__": main() ''; in { imports = [ ../options.nix ]; config = lib.mkIf cfg.enable { home.packages = [ waybar-weather ]; }; }