This commit is contained in:
thetek 2023-05-18 19:15:51 +02:00
commit 598bfe812b
4 changed files with 610 additions and 0 deletions

25
LICENSE Normal file
View File

@ -0,0 +1,25 @@
Copyright (c) 2023 thetek <mail@thetek.de>
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

58
README.md Normal file
View File

@ -0,0 +1,58 @@
# waybar-weather
A simple weather module for Waybar based on data from OpenWeatherMap that also
comes with a handy CLI.
## Preview
<center>
![preview](preview.png)
</center>
## Usage
### Initial Steps
1. Obtain a valid API key from OpenWeatherMap (the free tier is enough)
2. Change the `API_KEY` variable in the script to your API key
3. Get the coordinates of the place you want to receive the data for by running
`./weather.py geocoding <city[,state][,country]>` with an appropriate search
term, e.g. `./weather.py geocoding London,CA` for London, Canada.
4. Change the `LATITUDE` and `LONGITUDE` variables in the script to the values
received in the previous step
### CLI
The available subcommands for CLI usage, besides the already discussed
`geocoding` are:
- `current`: information about current weather conditions
- `forecast[-daily]`: forecast data for the next ~5 days, daily
- `forecast-detail`: forecast data for the next ~5 days in 3h intervals
Sample usage: `./weather.py current`
### Waybar Integration
To integrate the weather widget with Waybar, create a custom module:
```json
"custom/weather": {
"exec": "~/.config/waybar/weather.py waybar",
"restart-interval": 900,
"return-type": "json",
},
```
Remember to change the path to the `weather.py` script specified within the
`exec` field.
Then, include the `custom/weather` module in your desired module list.
By default, the module will display the current weather category and
temperature in the bar. When hovering the mouse over the widget, a tooltip will
pop up with detailed information about the current weather and a forecast for
the next ~5 days.
The colors used within the widget and tooltip may be changed in the
`weather.py` script in order to match your colorscheme.

BIN
preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

527
weather.py Executable file
View File

@ -0,0 +1,527 @@
#!/usr/bin/env python3
import datetime
import json
import requests
import statistics
import sys
# TODO
# - snowfall data
# - weather warnings
### CONSTANTS ###
# api key - get it at https://openweathermap.org/
API_KEY = None
# latitude and longitude of the city you want to query
# can be obtained through `./weather.py geocoding <city[,state,country]>`
LATITUDE = None
LONGITUDE = None
# waybar colors
GRAY = '#859289'
DARK = '#5a6772'
GREEN = '#a7c080'
YELLOW = '#dbbc7f'
ORANGE = '#e69875'
RED = '#e67e80'
PURPLE = '#d699b6'
BLUE = '#7fbbb3'
### UTILITIES ###
def print_error(msg: str):
'''
print an error message with appropriate prefix.
@param str: the message to print
'''
print('\x1b[90m[\x1b[31merr\x1b[90m]\x1b[0m', msg)
def print_help():
'''
print help message, to be used for the `--help` flag and as response to
incorrect usage
'''
print('usage: \x1b[33m./weather.py <subcommand> [options]\x1b[0m')
print()
print('available subcommands:')
print(' - \x1b[32mgeocoding\x1b[0m <city[,state][,country]> : search for city to get its coordinates')
print(' - \x1b[32mcurrent\x1b[0m : print current weather information')
print(' - \x1b[32mforecast[-daily]\x1b[0m : print forecast for the next ~5 days')
print(' - \x1b[32mforecast-detail\x1b[0m : print detailed forecast in 3h intervals')
print(' - \x1b[32mwaybar\x1b[0m : get output for usage with waybar')
def make_request(call: str) -> list | dict:
'''
make a request to an openweathermap api and returns the response as either
a list or a dictionary. the api key is added automatically. quits on error.
@param call: the api path, e.g. `data/2.5/weather?...`
@return response of the api request as a list or dict
'''
try:
req = requests.get(f'https://api.openweathermap.org/{call}&appid={API_KEY}')
return req.json()
except:
print_error(f'failed to make request to `/{call}`')
quit(1)
def get_wind_direction(deg: int) -> str:
'''
turn a wind direction specified by meteorological degrees into a
human-readable form
@param deg: degrees. expected to be in range [0..360]
@return human-readable form (e.g. 'NE' for `deg == 45`)
'''
if deg < 22.5: return 'N'
if deg < 67.5: return 'NE'
if deg < 112.5: return 'E'
if deg < 157.5: return 'SE'
if deg < 202.5: return 'S'
if deg < 247.5: return 'SW'
if deg < 292.5: return 'W'
if deg < 337.5: return 'NW'
else: return 'N'
def print_entry(label: str, content: str, indent: int = 0, label_width: int = 8):
'''
print an 'entry' that consists of a label (printed in gray) and some
content. the labels are automatically filled with whitespace to align
multiple lines properly.
@param label: the label of the line
@param content: content, printed after the label
@param indent: number of spaces to indent with
@param label_width: width of label
'''
label_with_whitespace = label.ljust(label_width)
print(f'{" " * indent}\x1b[90m{label_with_whitespace}\x1b[0m {content}')
def get_weekday(date: str) -> str:
'''
get weekday from date
@param date: date as ISO-8601-formatted string (YYYY-mm-dd)
@return weekday as lowercase string (e.g. 'monday')
'''
return datetime.datetime.strptime(date, '%Y-%m-%d').strftime('%A').lower()
def colorize(text: str, color: str) -> str:
'''
wrap `text` with pango markup to colorize it for usage with waybar.
@param text: the text to colorize
@param color: the color as string ('#rrggbb')
'''
return f'<span foreground="{color}">{text}</span>'
def waybar_entry(label: str, content: str, indent: int = 2, label_width: int = 9):
'''
create an 'entry' for a waybar tooltip that consists of a label (printed in
gray) and some content. the labels are automatically filled with whitespace
to align multiple lines properly.
@param label: the label of the line
@param content: content, printed after the label
@param indent: number of spaces to indent with
@param label_width: width of label
@return the entry for use within waybar
'''
label_with_whitespace = label.ljust(label_width)
return f'{" " * indent}{colorize(label_with_whitespace, GRAY)} {content}\n'
### GEOCODING ###
def geocoding(search: str):
'''
call openweathermap's geocoding api to find the coordinates of cities. can
be used to find the values required for the `LATITUDE` and `LONGITUDE`
constants within this script. the results are printed.
@param: search term in the format 'city[,state][,country]'
'''
res = make_request(f'geo/1.0/direct?q={search}&limit=5')
num_results = len(res)
if (num_results == 0):
print_error('no results found')
else:
print(f'found {num_results} result{"" if num_results == 1 else "s"}:')
for entry in res:
# obtain data
name = entry['name']
state = entry['state'] if 'state' in entry.keys() else None
country = entry['country']
latitude = entry['lat']
longitude = entry['lon']
# print data
print(f' - \x1b[32m{name}\x1b[90m',
f'({f"{state}, " if state else ""}{country})\x1b[0m:',
f'latitude = \x1b[35m{latitude}\x1b[0m,',
f'longitude = \x1b[35m{longitude}\x1b[0m')
### CURRENT WEATHER ###
def get_current_weather_data() -> dict:
'''
get current weather data from openweathermap api. when the `rain` is not
set (due to there not being any rain), it will be added with a rain amount
of 0 mm over the last hour.
@return api response
'''
res = make_request(f'data/2.5/weather?lat={LATITUDE}&lon={LONGITUDE}&units=metric')
if 'rain' not in res.keys():
res['rain'] = { '1h': 0 }
return res
def current_weather():
'''
print current weather information
'''
data = get_current_weather_data()
# collect relevant data
weather = data['weather'][0]['description'].lower()
temperature = round(data['main']['temp'], 1)
temperature_felt = round(data['main']['feels_like'], 1)
humidity = data['main']['humidity']
wind_speed = round(data['wind']['speed'], 1)
wind_direction = get_wind_direction(data['wind']['deg'])
rainfall = data['rain']['1h']
# print data
print_entry('weather', f'\x1b[32m{weather}\x1b[0m')
print_entry('temp', f'\x1b[33m{temperature} °C\x1b[90m, feels like \x1b[33m{temperature_felt} °C\x1b[0m')
print_entry('humidity', f'\x1b[31m{humidity} % RH\x1b[0m')
print_entry('wind', f'\x1b[35m{wind_speed} m/s\x1b[90m ({wind_direction})\x1b[0m')
print_entry('rain', f'\x1b[34m{rainfall} mm\x1b[0m')
### FORECAST ###
def get_forecast_data() -> dict:
'''
get forecast data for the next ~5 days from the openweathermap api, with
data points separated by 3 hours. the data is grouped by date (the api does
not group the data by default and instead sends it as one sequence).
@return api response grouped by date
'''
res = make_request(f'data/2.5/forecast?lat={LATITUDE}&lon={LONGITUDE}&units=metric')
days = dict()
for i in res['list']:
day = i['dt_txt'].split(' ')[0]
if day not in days.keys():
days[day] = list()
if 'rain' not in i.keys():
i['rain'] = { '3h': 0 }
days[day].append(i)
return days
def get_daily_forecast_data() -> dict[dict]:
'''
obtain forecast data for the next ~5 days, where values are grouped by day.
since the api only provides the data in 3h intervals, the properties of
different data points are combined in order to provide appropriate data for
each day.
@return the processed data as a dict with key = date and value = data as
another dict
'''
res = get_forecast_data()
output = dict()
for day in sorted(res):
data = res[day]
number_of_data_points = len(data)
# collect relevant from data for each day
temperatures = [i['main']['temp'] for i in data]
weather_descriptions = [i['weather'][0]['description'].lower() for i in data]
humidity = [i['main']['humidity'] for i in data]
rainfall = [i['rain']['3h'] for i in data]
precipitation_prob = [i['pop'] for i in data]
wind_speeds = [i['wind']['speed'] for i in data]
weekday = get_weekday(day)
# min and max temperature for the day
min_temperature = round(min(temperatures))
max_temperature = round(max(temperatures))
# obtain the average weather by finding the weather description with the highest number of occurances in `weather_descriptions`
weather_count = {i: weather_descriptions.count(i) for i in set(weather_descriptions)}
weather_count_max = max(weather_count.values())
weather_average = tuple(filter(lambda x: weather_count[x] == weather_count_max, weather_count.keys()))[0]
# humidiy
humidity_average = round(statistics.mean(humidity))
# total rainfall and probability of precipitation. also, estimate the total rainfall if not all data points for a day are available
rainfall_total = round(sum(rainfall), 1)
rainfall_total_estimated = round(rainfall_total / number_of_data_points * 8, 1)
max_precipitation_prob = round(max(precipitation_prob) * 100)
# average wind
wind_average = round(statistics.mean(wind_speeds), 1)
output[day] = {
'number_of_data_points': number_of_data_points,
'min_temperature': min_temperature,
'max_temperature': max_temperature,
'weather_average': weather_average,
'humidity_average': humidity_average,
'rainfall_total': rainfall_total,
'rainfall_total_estimated': rainfall_total_estimated,
'max_precipitation_prob': max_precipitation_prob,
'wind_average': wind_average,
'weekday': weekday,
}
return output
def daily_forecast():
'''
print forecast data for the next ~5 days
'''
daily_data = get_daily_forecast_data()
for day in sorted(daily_data):
data = daily_data[day]
# only display data point if at least half of the data points are available
if data['number_of_data_points'] < 4:
continue
# print data
print(f'\x1b[1m{day}\x1b[0m ({data["weekday"]}):')
print_entry('weather', f'\x1b[32m{data["weather_average"]}\x1b[0m', indent = 2)
print_entry('temp', f'\x1b[33m{data["min_temperature"]} °C\x1b[0m / \x1b[33m{data["max_temperature"]} °C\x1b[0m', indent = 2)
print_entry('humidity', f'\x1b[31m{data["humidity_average"]} % RH\x1b[0m', indent = 2)
print_entry('wind', f'\x1b[35m{data["wind_average"]} m/s\x1b[0m', indent = 2)
print_entry('rain', f'\x1b[34m{data["rainfall_total_estimated"]} mm\x1b[0m' +
f'\x1b[90m{f""" ({data["max_precipitation_prob"]}%)""" if data["max_precipitation_prob"] > 0 else ""}' +
f'{f" (estimated)" if data["rainfall_total"] != data["rainfall_total_estimated"] else ""}\x1b[0m\n', indent = 2)
### DETAILED FORECAST ###
def detailed_forecast():
'''
print forecast data for the next ~5 days, where values are printed for
every 3h interval provided by the api. some data (e.g. humidity or wind
speeds) are omitted.
'''
res = get_forecast_data()
for day in sorted(res):
weekday = get_weekday(day)
print(f'\x1b[1m{day}\x1b[0m ({weekday})')
for entry in res[day]:
# collect data
weather = entry['weather'][0]['description'].lower()
temperature = round(entry['main']['temp'], 1)
rainfall = entry['rain']['3h']
precipitation_prob = round(entry['pop'] * 100)
time = f"{int(entry['dt_txt'].split(' ')[1].split(':')[0]):2}h"
# print data
output = ''
output += f'\x1b[33m{temperature:4} °C\x1b[90m, '
output += f'\x1b[32m{weather}\x1b[0m'
if rainfall > 0:
output += f'\x1b[90m: \x1b[34m{rainfall} mm \x1b[90m({precipitation_prob}%)\x1b[0m'
print_entry(time, output, indent = 2, label_width = 3)
print()
### WAYBAR ###
def waybar_widget(data: dict) -> str:
'''
get the widget component of the waybar output. contains the current weather
group and temperature.
@param current weather data
@return widget component
'''
weather = data['weather'][0]['main'].lower()
temperature = round(data['main']['temp'])
return f'{colorize(weather, DARK)} {temperature}°'
def waybar_current(data: dict) -> str:
'''
get the current weather overview for the tooltip of the waybar output.
@param current weather data
@return formatted current weather overview
'''
# retrieve relevant data
weather = data['weather'][0]['description'].lower()
temperature = round(data['main']['temp'], 1)
temperature_felt = round(data['main']['feels_like'], 1)
humidity = data['main']['humidity']
wind_speed = round(data['wind']['speed'], 1)
wind_direction = get_wind_direction(data['wind']['deg'])
rainfall = data['rain']['1h']
# generate output
output = ''
output += waybar_entry('weather', colorize(weather, YELLOW))
output += waybar_entry('temp', f'{colorize(f"{temperature} °C", ORANGE)}{colorize(", feels like ", GRAY)}{colorize(f"{temperature_felt} °C", ORANGE)}')
output += waybar_entry('humditiy', colorize(f'{humidity} % RH', RED))
output += waybar_entry('wind', f'{colorize(f"{wind_speed} m/s", PURPLE)} {colorize(f"({wind_direction})", GRAY)}')
output += waybar_entry('rain', colorize(f"{rainfall} mm", BLUE))
return output
def waybar_forecast(data: dict) -> str:
'''
get the daily forecast for the tooltip of the waybar output.
@param forecast weather data
@return formatted daily forecast
'''
output = ''
daily_data = get_daily_forecast_data()
for day in sorted(daily_data):
data = daily_data[day]
line_content = colorize(f'{data["min_temperature"]:2}°', ORANGE) + \
colorize(' / ', GRAY) + \
colorize(f'{data["max_temperature"]:2}°', ORANGE) + \
colorize(', ', GRAY) + \
colorize(data['weather_average'], YELLOW)
if data['rainfall_total_estimated'] > 0:
line_content += colorize(': ', GRAY) + \
colorize(f'{data["rainfall_total_estimated"]} mm ', BLUE) + \
colorize(f'({data["max_precipitation_prob"]}%)', GRAY)
output += waybar_entry(data['weekday'], line_content)
return output.rstrip()
def waybar():
'''
get current and forecast weather data and output it formatted in a way that
allows it to be included as a widget in waybar. only shows weather category
and temperature in the widget, but reveals detailed weather information and
a ~5 day forecast in the tooltip.
'''
current_data = get_current_weather_data()
forecast_data = get_forecast_data()
widget = waybar_widget(current_data)
current = waybar_current(current_data)
forecast = waybar_forecast(forecast_data)
tooltip = colorize('current weather', GREEN) + '\n' + current + '\n' + \
colorize('forecast', GREEN) + '\n' + forecast
print(json.dumps({
'text': widget,
'tooltip': tooltip,
}))
### MAIN ###
def main():
'''
main function
'''
# no parameters or `--help`
if len(sys.argv) == 1 or sys.argv[1] in ('help', '-h', '--help'):
print_help()
# >= 1 parameter provided
else:
# geocoding
if sys.argv[1] == 'geocoding':
if len(sys.argv) == 3:
geocoding(sys.argv[2])
else:
print_error('expected argument `<city[,state][,country]>`')
print_help()
# constants not set
elif API_KEY is None or LATITUDE is None or LONGITUDE is None:
print_error('please modify the constants within the script before use.')
# current weather
elif sys.argv[1] == 'current':
current_weather()
# daily forecast
elif sys.argv[1] == 'forecast' or sys.argv[1] == 'forecast-daily':
daily_forecast()
# detailed forecast
elif sys.argv[1] == 'forecast-detail':
detailed_forecast()
# waybar
elif sys.argv[1] == 'waybar':
waybar()
# unknown command
else:
print_error(f'unknown command `{sys.argv[1]}`')
print_help()
if __name__ == '__main__':
main()