{
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 ];
};
}