├── requirements.txt ├── test_all_specifiers.sh ├── .gitignore ├── LICENSE ├── CHANGELOG.md ├── README.md └── weather.py /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2018.11.29 2 | chardet==3.0.4 3 | geojson==2.4.1 4 | idna==2.8 5 | pyowm==2.10.0 6 | requests==2.21.0 7 | urllib3==1.26.5 8 | -------------------------------------------------------------------------------- /test_all_specifiers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This is a cheap, unsophisticated substitute for actual software tests... 4 | if [ -z "$OWM_KEY" ]; then 5 | echo "must set OWM_KEY in order to run tests" 6 | exit 1 7 | fi 8 | 9 | if [ -z "$OWM_CITY_ID" ]; then 10 | echo "must set OWM_CITY_ID in order to run tests" 11 | exit 1 12 | fi 13 | 14 | .env/bin/python weather.py \ 15 | --api-key $OWM_KEY \ 16 | --city-id $OWM_CITY_ID \ 17 | --format "{city}, {country}: {text}, {temp_f}°F ({temp_c}°C, {temp_k}K), Relative humidity {humidity}%, Wind: {wind_speed_mph} MPH ({wind_speed_ms} m/s) from {wind_direction}° ({wind_direction_arrow} {wind_direction_fuzzy}), Daylight from {sunrise} to {sunset}, Pressure {pressure} millibars" 18 | 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | # vim swapfiles 56 | *.swp 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2019 Calvin Montgomery and Colin Chan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2019-01-06 2 | ========== 3 | 4 | Yahoo deprecated their old weather API in early January 2019, which broke 5 | i3-weather. There is a replacement API, but it requires manual effort to get an 6 | API key (you have to email them) and doesn't seem like a convenient solution 7 | going forward. i3-weather has now been updated to retrieve weather from 8 | OpenWeatherMap instead. 9 | 10 | **Breaking changes:** 11 | 12 | * OpenWeatherMap requires an API key. You can register one for free 13 | [here](https://home.openweathermap.org/users/sign_up) 14 | * The `{region}` format specifier is no longer supported, since 15 | OpenWeatherMap's results only include city and country, not state/province 16 | * The `{visibility}` and `{wind_chill}` format specifiers are no longer 17 | supported 18 | * The `{wind_speed}` and `{unit_speed}` format specifiers have been replaced 19 | by `{wind_speed_mph}` and `{wind_speed_ms}` 20 | * The `{temp}` and `{unit_temperature}` format specifiers have been replaced 21 | by `{temp_f}`, `{temp_c}`, and `{temp_k}` 22 | * The `{unit_pressure}` format specifier has been removed. The atmospheric 23 | pressure is always reported in hectopascals (millibars) 24 | * `--timeout` was removed. pyowm uses a default timeout of 2 seconds, and I 25 | plan to additionally do some refactoring to prevent the weather lookup from 26 | blocking i3status output 27 | * OpenWeatherMap uses a different location model than Yahoo's WOEIDs, so you 28 | will need to specify the location differently 29 | * i3-weather now depends on `pyowm` and no longer depends on `requests` or 30 | `bs4` 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | i3-weather 2 | ========== 3 | 4 | Simple Python script for grabbing weather information from OpenWeatherMap and 5 | (optionally) displaying it on your i3 status line. 6 | 7 | ## Installation 8 | 9 | Python 3 is required (I tested this code on Python 3.6). You can install in a 10 | virtualenv or else install the dependencies through your distribution's package 11 | manager. 12 | 13 | 14 | $ python3 -m venv .env 15 | $ .env/bin/pip install -r requirements.txt 16 | 17 | ### OpenWeatherMap 18 | 19 | i3-weather retrieves weather information from [OpenWeatherMap]. In order to use 20 | it, you'll need to register a (free) account and (free) API key. 21 | 22 | [OpenWeatherMap]: https://home.openweathermap.org/users/sign_up 23 | 24 | ## Usage 25 | 26 | ### With i3status 27 | 28 | In your i3 configuration, update the `bar`'s `status_command` to pipe through 29 | i3-weather: 30 | 31 | 32 | bar { 33 | status_command i3status | /path/to/i3-weather/.env/bin/python /path/to/i3-weather/weather.py --wrap-i3-status --api-key ... 34 | } 35 | 36 | ### Standalone 37 | 38 | i3-weather can also be invoked without using i3status to print the weather 39 | report in plain text. This is mostly useful for testing your command line 40 | arguments without having to restart i3 every time, but you could also use it to 41 | check the weather from your terminal. 42 | 43 | 44 | $ cd /path/to/i3-weather 45 | $ .env/bin/python weather.py --api-key ... 46 | 47 | ### Specifying location 48 | 49 | OpenWeatherMap's API supports 3 different ways of providing a location, which 50 | are supported by i3-weather as command-line arguments: 51 | 52 | * `--zip`: look up a city by zip/postal code 53 | - For countries outside the US, you must also specify `--zip-country` 54 | * `--city-id`: look up a city by OpenWeatherMap's "City ID". You can find 55 | this number in the URL when searching for your location on OpenWeatherMap's 56 | website (for example, Seattle is `https://openweathermap.org/city/5809844`, 57 | which corresponds to `--city-id 5809844`). 58 | * `--place`: look up a location by place name (typically `city,country`), e.g. 59 | `Seattle,US` 60 | 61 | See [the API documentation](https://openweathermap.org/current) for more 62 | information about locations. 63 | 64 | ### Customizing output format 65 | 66 | i3-weather retrieves weather information from OpenWeatherMap for the provided 67 | location and formats the result from a user-provided format string (given with 68 | `--format`). The default format is `{city}, {country}: {text}, 69 | {temp_f}°F`, which produces an output similar to `Seattle, US: 70 | light rain, 43°F`. 71 | 72 | The following format specifiers are supported: 73 | 74 | - `{city}` - city name associated with the input location 75 | - `{country}` - country name associated with the input location 76 | - `{wind_direction}` - direction (in degrees) of the wind 77 | - `{wind_direction_fuzzy}` - fuzzy direction of the wind (N, NE, etc.) 78 | - `{wind_direction_arrow}` - arrow direction of the wind (↓, ↙, etc.) 79 | - `{wind_speed_mph}` - speed of the wind in miles per hour 80 | - `{wind_speed_ms}` - speed of the wind in meters per second 81 | - `{humidity}` - relative humidity 82 | - `{pressure}` - atmospheric pressure in hectopascals (millibars) 83 | - `{sunrise}` - sunrise time (in current time zone) 84 | - `{sunset}` - sunset time (in current time zone) 85 | - `{text}` - basic description of condition (e.g "Fair" or "Partly cloudy") 86 | - `{temp_f}` - temperature in Fahrenheit 87 | - `{temp_c}` - temperature in Celsius 88 | - `{temp_k}` - temperature in Kelvin 89 | -------------------------------------------------------------------------------- /weather.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | from datetime import datetime 4 | from functools import partial 5 | import json 6 | from pyowm import OWM 7 | import sys 8 | import time 9 | 10 | def fuzzy_direction(degrees): 11 | if not degrees: 12 | return '?' 13 | directions = 'N NE E SE S SW W NW'.split() 14 | index = round(degrees / 45) % 8 15 | return directions[index] 16 | 17 | def arrow_direction(degrees): 18 | if not degrees: 19 | return '?' 20 | arrows = list('↓↙←↖↑↗→↘') 21 | index = round(degrees / 45) % 8 22 | return arrows[index] 23 | 24 | def unix_to_hhmm(ts): 25 | dt = datetime.fromtimestamp(ts) 26 | return dt.strftime('%H:%M') 27 | 28 | def format_weather(obs, format_str): 29 | data = {} 30 | loc = obs.get_location() 31 | weather = obs.get_weather() 32 | 33 | data['city'] = loc.get_name() 34 | data['country'] = loc.get_country() 35 | data['temp_f'] = round(weather.get_temperature(unit='fahrenheit')['temp']) 36 | data['temp_c'] = round(weather.get_temperature(unit='celsius')['temp']) 37 | data['temp_k'] = round(weather.get_temperature(unit='kelvin')['temp']) 38 | data['text'] = weather.get_detailed_status() 39 | data['humidity'] = weather.get_humidity() 40 | data['pressure'] = weather.get_pressure()['press'] 41 | 42 | wind = weather.get_wind(unit='meters_sec') 43 | # Wind direction is sometimes unset; I'm assuming this occurs when 44 | # different weather stations for the same location report the wind 45 | # blowing in conflicting directions 46 | data['wind_direction'] = round(wind['deg']) if 'deg' in wind else None 47 | data['wind_speed_ms'] = round(wind['speed']) 48 | data['wind_speed_mph'] = round(weather.get_wind(unit='miles_hour')['speed']) 49 | data['wind_direction_fuzzy'] = fuzzy_direction(data['wind_direction']) 50 | data['wind_direction_arrow'] = arrow_direction(data['wind_direction']) 51 | 52 | data['sunrise'] = unix_to_hhmm(weather.get_sunrise_time()) 53 | data['sunset'] = unix_to_hhmm(weather.get_sunset_time()) 54 | return format_str.format(**data) 55 | 56 | if __name__ == '__main__': 57 | p = argparse.ArgumentParser() 58 | p.add_argument('--api-key', type=str, required=True, 59 | help='OpenWeatherMap API key') 60 | p.add_argument('--format', metavar='F', 61 | default='{city}, {country}: {text}, {temp_f}°F', 62 | help="format string for output") 63 | p.add_argument('--position', metavar='P', type=int, default=-2, 64 | help="position of output in JSON when wrapping i3status") 65 | p.add_argument('--update-interval', metavar='I', type=int, default=60*10, 66 | help="update interval in seconds (default: 10 minutes)") 67 | p.add_argument('--wrap-i3-status', action='store_true') 68 | p.add_argument('--zip-country', type=str, default='US', 69 | help='set country for zip code lookup (defaults to US)') 70 | 71 | loc = p.add_mutually_exclusive_group(required=True) 72 | loc.add_argument('--zip', type=str, 73 | help='retrieve weather by postal/zip code') 74 | loc.add_argument('--city-id', type=int, help='retrieve weather by city ID') 75 | loc.add_argument('--place', type=str, 76 | help='retrieve weather by city,country name') 77 | args = p.parse_args() 78 | 79 | owm = OWM(API_key=args.api_key, version='2.5') 80 | 81 | if args.zip: 82 | get_observation = partial(owm.weather_at_zip_code, args.zip, 83 | args.zip_country) 84 | elif args.city_id: 85 | get_observation = partial(owm.weather_at_id, args.city_id) 86 | else: 87 | get_observation = partial(owm.weather_at_place, args.place) 88 | 89 | def _get_weather(): 90 | return format_weather(get_observation(), args.format) 91 | 92 | if args.wrap_i3_status: 93 | stdin = iter(sys.stdin.readline, '') 94 | 95 | header = next(stdin) 96 | if json.loads(header)['version'] != 1: 97 | raise Exception('Unrecognized version of i3status: ' + 98 | header.strip()) 99 | 100 | print(header, end='') 101 | # First line after header is '[' (open JSON array) 102 | print(next(stdin), end='') 103 | 104 | last_update = 0 105 | weather = {'name': 'weather', 'full_text': ''} 106 | try: 107 | for line in stdin: 108 | data = json.loads(line.lstrip(',')) 109 | data.insert(args.position, weather) 110 | print((',' if line.startswith(',') else '') + json.dumps(data)) 111 | sys.stdout.flush() 112 | 113 | if (time.time() > last_update + args.update_interval or 114 | weather['full_text'] == ''): 115 | try: 116 | weather['full_text'] = _get_weather() 117 | except Exception as e: 118 | weather['full_text'] = '' 119 | print('{}: {}'.format(e.__class__.__name__, e), 120 | file=sys.stderr) 121 | last_update = time.time() 122 | except KeyboardInterrupt: 123 | sys.exit() 124 | else: 125 | try: 126 | print(_get_weather()) 127 | except Exception as e: 128 | print('{}: {}'.format(e.__class__.__name__, e), file=sys.stderr) 129 | --------------------------------------------------------------------------------