diff --git a/home/gui/files/sway/weather.conf b/home/gui/files/sway/weather.conf new file mode 100644 index 0000000..22b0dd0 --- /dev/null +++ b/home/gui/files/sway/weather.conf @@ -0,0 +1,4 @@ +lat=29.749 +lon=-95.773 +units=imperial +appid=0ea01c37da47559728248e3be2872b20 diff --git a/home/gui/files/sway/weather.py b/home/gui/files/sway/weather.py new file mode 100755 index 0000000..e4fc3b0 --- /dev/null +++ b/home/gui/files/sway/weather.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 + +import re +import os +import sys +import subprocess +import json +import urllib.request +import datetime +import math +import random + +def decode_icon(code, is_day): + icons = { + "sun": "  ", + "moon": "  ", + "cloud": "", + "cloud-bolt": "", + "snowflake": "  ", + "wind": "", + "tornado": "", + "temperature-low": "", + "temperature-high": "", + "smog": "", + "cloud-sun-rain": "", + "cloud-sun": "", + "cloud-showers-water": "", + "cloud-showers-heavy": "", + "cloud-rain": "", + "cloud-moon-rain": "", + "cloud-moon": " ", + "default": " " + } + match code: + case 200: + icon_status = "cloud-bolt" + case 201: + icon_status = "cloud-bolt" + case 202: + icon_status = "cloud-bolt" + case 210: + icon_status = "cloud-bolt" + case 211: + icon_status = "cloud-bolt" + case 212: + icon_status = "cloud-bolt" + case 221: + icon_status = "cloud-bolt" + case 230: + icon_status = "cloud-bolt" + case 231: + icon_status = "cloud-bolt" + case 232: + icon_status = "cloud-bolt" + case 500: + icon_status = "cloud-sun-rain" if is_day else "cloud-moon-rain" + case 501: + icon_status = "cloud-sun-rain" if is_day else "cloud-moon-rain" + case 502: + icon_status = "cloud-sun-rain" if is_day else "cloud-moon-rain" + case 503: + icon_status = "cloud-sun-rain" if is_day else "cloud-moon-rain" + case 504: + icon_status = "cloud-sun-rain" if is_day else "cloud-moon-rain" + case 511: + icon_status = "snowflake" + case 520: + icon_status = "cloud-rain" + case 521: + icon_status = "cloud-rain" + case 522: + icon_status = "cloud-showers-heavy" + case 531: + icon_status = "cloud-rain" + case 600: + icon_status = "snowflake" + case 601: + icon_status = "snowflake" + case 602: + icon_status = "snowflake" + case 611: + icon_status = "snowflake" + case 612: + icon_status = "snowflake" + case 613: + icon_status = "snowflake" + case 615: + icon_status = "snowflake" + case 616: + icon_status = "snowflake" + case 620: + icon_status = "snowflake" + case 621: + icon_status = "snowflake" + case 622: + icon_status = "snowflake" + case 800: + icon_status = "sun" if is_day else "moon" + case 801: + icon_status = "cloud-sun" if is_day else "cloud-moon" + case 802: + icon_status = "cloud" + case 803: + icon_status = "cloud" + case 804: + icon_status = "cloud" + case 10001: + icon_status = "temperature-low" + case 10002: + icon_status = "temperature-high" + case _: + icon_status = "default" + icon = ( + icons[icon_status] + if icon_status in icons + else icons["default"] + ) + return icon + +def is_daytime(dt, resp): + events = [] + for day in range(8): + events.append(resp['daily'][day]['sunrise']) + events.append(resp['daily'][day]['sunset']) + events.append(dt) + events.sort() + return events.index(dt) % 2 == 1 + +def format_temp(temp): + rounded_temp = round(temp) + return f"{rounded_temp: >3}°" + +def render_minutely_precip_chart(resp): + chart = "" + icons = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] + for minute in range(60): + precip = resp['minutely'][minute]['precipitation'] + precip = math.ceil(precip) + precip = 8 if precip > 8 else precip +# precip = random.randint(0, 8) + chart = chart + icons[precip] + return chart + +def format_precip_chart_string(chart, resp): + total_precip = 0 + for minute in range(60): + total_precip = total_precip + resp['minutely'][minute]['precipitation'] +# total_precip = 1 + if total_precip == 0: + return "" + else: + first_minute = resp['minutely'][minute]['dt'] + first_minute = datetime.datetime.fromtimestamp(first_minute).minute + + seq = ["15", "30", "45", " 0"] + + first_target = seq[int(first_minute / 15)] + init_spaces = int(first_target) - first_minute + timelabel = " " + for i in range(init_spaces): + timelabel = timelabel + " " + for i in range(4): + timelabel = timelabel + seq[(int(first_minute / 15) + i) % 4] + if i < 3: + timelabel = timelabel + " " + + chart_string = f" {chart} \n" + chart_string = chart_string + f"{timelabel}\n\n" + return chart_string + +def get_hourly_hours(hours, resp): + hour_string = "" + for hour in range(hours): + dt = resp['hourly'][hour]['dt'] + dt = datetime.datetime.fromtimestamp(dt).hour + hour_string = hour_string + " " + f"{dt:2}" + " " + return hour_string + +def get_hourly_icons(hours, resp): + icon_string = "" + for hour in range(hours): + status_code = resp['hourly'][hour]['weather'][0]['id'] + dt = resp['hourly'][hour]['dt'] + hour_is_daytime = is_daytime(dt, resp) + icon_string = icon_string + " " + decode_icon(status_code, hour_is_daytime) + " " + return icon_string + +def get_hourly_temps(hours, resp): + temp_string = "" + for hour in range(hours): + temp_string = temp_string + format_temp(resp['hourly'][hour]['temp']) + return temp_string + +def compute_daily_minmax(days, resp): + dmin = 100 + dmax = -100 + for day in range(days): + day_min = resp['daily'][day]['temp']['min'] + day_max = resp['daily'][day]['temp']['max'] + if day_min < dmin: dmin = day_min + if day_max > dmax: dmax = day_max + return dmin, dmax + +def format_percentage(num): + num = str(int(100 * num)) + for i in range(3 - len(num)): + num = " " + num + num = num + "%" + return num + +def get_daily(days, resp, dlow, dhigh): + delta = dhigh - dlow + steps = 20 + incr = delta / steps + + if days == 0: + return "" + daily_string = "\n" + for day in range(days): + dt = resp['daily'][day]['dt'] + dt = datetime.datetime.fromtimestamp(dt) + dt = dt.strftime('%a %b %e') + code = resp['daily'][day]['weather'][0]['id'] + icon = decode_icon(code, True) + day_min = resp['daily'][day]['temp']['min'] + day_max = resp['daily'][day]['temp']['max'] + lt = format_temp(day_min) + ht = format_temp(day_max) + pop = format_percentage(resp['daily'][day]['pop']) + daily_string = daily_string + "\n" + dt \ + + " " + icon + " " + pop + " " + lt + " " + day_tempc_startc = int((day_min - dlow) / incr) + day_tempc_stopc = int((day_max - dlow) / incr) + + for character in range(steps): + if character < day_tempc_startc: + daily_string = daily_string + " " + elif character > day_tempc_stopc: + daily_string = daily_string + " " + else: + daily_string = daily_string + "─" + + daily_string = daily_string + ht + "" + return daily_string + +def validate_latitude(lat): + try: + lat = float(lat) + return -90 <= lat <= 90 + except ValueError: + return False + +def validate_longitude(lon): + try: + lon = float(lon) + return -180 <= lon <= 180 + except ValueError: + return False + +def validate_units(units): + valid_units = ['metric', 'standard', 'imperial'] + return units.lower() in valid_units + +URL = "https://api.openweathermap.org/data/3.0/onecall?" + +try: + config_file = os.path.expanduser("~/.config/waybar/weather.conf") + with open(config_file, "r") as file: + for line in file: + key, value = line.strip().replace(" ", "").split("=") + if key == "lat": + if not validate_latitude(value): + print("Error: Invalid latitude") + sys.exit(1) + elif key == "lon": + if not validate_longitude(value): + print("Error: Invalid longitude") + sys.exit(1) + elif key == "units": + if not validate_units(value): + print("Error: Invalid units") + sys.exit(1) + elif key == "appid": + pass + else: + print("Error: Unknown key '{}'".format(key)) + sys.exit(1) + URL = URL + "&" + key + "=" + value +except FileNotFoundError: + print("Error: File '{}' not found".format(config_file)) + sys.exit(1) + +with urllib.request.urlopen(URL) as url: + resp = json.load(url) + +current_status_code = resp['current']['weather'][0]['id'] +current_temp = round(resp['current']['temp']) +current_desc = resp['current']['weather'][0]['description'] +current_dt = resp['current']['dt'] +current_is_daytime = is_daytime(current_dt, resp) +current_icon = decode_icon(current_status_code, current_is_daytime) + +precipitation_chart = render_minutely_precip_chart(resp) +precip_chart_string = format_precip_chart_string(precipitation_chart, resp) + +hours_to_show = 16 +hourly_hours = get_hourly_hours(hours_to_show, resp) +hourly_icons = get_hourly_icons(hours_to_show, resp) +hourly_temps = get_hourly_temps(hours_to_show, resp) + +days_to_show = 8 +dlow, dhigh = compute_daily_minmax(days_to_show, resp) +daily_forecast = get_daily(days_to_show, resp, dlow, dhigh) + +tooltip_text = f"{current_icon} {current_desc}\n\n" \ + + precip_chart_string \ + + f"{hourly_hours}\n" \ + + f"{hourly_icons}\n" \ + + f"{hourly_temps}" \ + + daily_forecast + +out_data = { + "text": f"{current_icon} {current_temp} °C", + "class": current_status_code, + "alt": current_desc, + "tooltip": tooltip_text +} + +print(json.dumps(out_data)) diff --git a/home/gui/sway.nix b/home/gui/sway.nix index f33324c..2c9cff6 100644 --- a/home/gui/sway.nix +++ b/home/gui/sway.nix @@ -7,6 +7,8 @@ ... }: { xdg.configFile."sway/config".source = ./files/sway/${osConfig.networking.hostName}; + xdg.configFile."waybar/weather.py".source = ./files/sway/weather.py; + xdg.configFile."waybar/weather.conf".source = ./files/sway/weather.conf; programs = { swaylock = { @@ -30,7 +32,8 @@ spacing = 0; modules-left = ["sway/workspaces" "sway/mode" "wlr/workspaces" "custom/mycal"]; modules-center = []; - modules-right = ["idle_inhibitor" "custom/mytimew" "pulseaudio" "custom/mymusic" "bluetooth" "network" "backlight" "battery" "battery#bat2" "tray" "custom/mycal" "clock"]; + # modules-right = ["idle_inhibitor" "custom/mytimew" "pulseaudio" "custom/mymusic" "bluetooth" "network" "backlight" "battery" "battery#bat2" "tray" "custom/mycal" "clock"]; + modules-right = ["idle_inhibitor" "custom/weather" "pulseaudio" "custom/mymusic" "bluetooth" "network" "backlight" "battery" "battery#bat2" "tray" "custom/mycal" "clock"]; bluetooth = { format-alt = "bt: {status}"; format-on = "bt"; @@ -47,6 +50,11 @@ on-scroll-down = "${pkgs.light}/bin/light -A 1"; on-scroll-up = "${pkgs.light}/bin/lightlight -U 1"; }; + "custom/weather" = { + exec = "~/.config/waybar/weather.py"; + interval = 300; + return-type = "json"; + }; "custom/mytimew" = { exec = "~/bin/timebar"; interval = 30; @@ -157,7 +165,7 @@ #workspaces button.focused { background: #64727D; border-bottom: 3px solid #ffffff; } #workspaces button.urgent { background-color: #eb4d4b; } #mode { background: #64727D; border-bottom: 3px solid #ffffff; } - #clock, #battery, #cpu, #memory, #temperature, #backlight, #network, #pulseaudio, #custom-mymusic, #tray, #mode, #idle_inhibitor, #bluetooth { + #clock, #battery, #cpu, #memory, #temperature, #backlight, #network, #pulseaudio, #custom-weather, #custom-mymusic, #tray, #mode, #idle_inhibitor, #bluetooth { padding: 0 5px; margin: 0 0px; } @@ -182,6 +190,7 @@ #network.disconnected { background: #ff5555; } #pulseaudio { background: #ffb86c; color: #000000; } #pulseaudio.muted { background: #90b1b1; color: #2a5c45; } + #custom-weather { background: #bd93f9; color: #000000; } #custom-mytimew { background: #bd93f9; color: #000000; } #custom-mymusic { background: #8be9fd; color: #000000; } #custom-mycal { background: #cccc99; color: #2a5c45; }