Merge pull request #349 from terminalmage/wunderground

Add generic weather module
This commit is contained in:
enkore 2016-03-31 11:05:02 +02:00
commit 11861a500a
9 changed files with 533 additions and 119 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ dist/*
*~ *~
.i3pystatus-* .i3pystatus-*
ci-build ci-build
docs/_build

View File

@ -17,6 +17,7 @@ David Garcia Quintas
David Wahlstrom David Wahlstrom
dubwoc dubwoc
eBrnd eBrnd
Erik Johnson
enkore enkore
facetoe facetoe
Frank Tackitt Frank Tackitt

View File

@ -175,9 +175,9 @@ decimal dot
formatp formatp
~~~~~~~ ~~~~~~~
Some modules use an extended format string syntax (the :py:mod:`.mpd` Some modules use an extended format string syntax (the :py:mod:`.mpd` and
module, for example). Given the format string below the output adapts :py:mod:`.weather` modules, for example). Given the format string below the
itself to the available data. output adapts itself to the available data.
:: ::

View File

@ -52,3 +52,12 @@ Update Backends
.. autogen:: i3pystatus.updates SettingsBase .. autogen:: i3pystatus.updates SettingsBase
.. nothin' .. nothin'
.. _weatherbackends:
Weather Backends
----------------
.. autogen:: i3pystatus.weather SettingsBase
.. nothin'

View File

@ -1,116 +0,0 @@
from i3pystatus import IntervalModule
from i3pystatus.core.util import internet, require
from urllib.request import urlopen
import re
import xml.etree.ElementTree as ElementTree
WEATHER_COM_URL = 'http://wxdata.weather.com/wxdata/weather/local/%s?unit=%s&dayf=1&cc=*'
class Weather(IntervalModule):
"""
This module gets the weather from weather.com.
First, you need to get the code for the location from www.weather.com
.. rubric:: Available formatters
* `{current_temp}` current temperature including unit (and symbol if colorize is true)
* `{min_temp}` today's minimum temperature including unit
* `{max_temp}` today's maximum temperature including unit
* `{current_wind}` current wind direction, speed including unit
* `{humidity}` current humidity excluding percentage symbol
"""
interval = 20
settings = (
("location_code", "Location code from www.weather.com"),
("colorize", "Enable color with temperature and UTF-8 icons."),
("color_icons", "Dictionary mapping weather conditions to tuples "
"containing a UTF-8 code for the icon, and the color "
"to be used."),
("units", "Celsius (metric) or Fahrenheit (imperial)"),
"format",
)
required = ("location_code",)
location_code = None
units = "metric"
format = "{current_temp}"
colorize = False
color_icons = {
"Fair": (u"\u2600", "#FFCC00"),
"Cloudy": (u"\u2601", "#F8F8FF"),
"Partly Cloudy": (u"\u2601", "#F8F8FF"), # \u26c5 is not in many fonts
"Rainy": (u"\u2614", "#CBD2C0"),
"Sunny": (u"\u263C", "#FFFF00"),
"Snow": (u"\u2603", "#FFFFFF"),
"default": ("", None),
}
def fetch_weather(self):
'''Fetches the current weather from wxdata.weather.com service.'''
unit = '' if self.units == 'imperial' or self.units == '' else 'm'
url = WEATHER_COM_URL % (self.location_code, unit)
with urlopen(url) as handler:
try:
content_type = dict(handler.getheaders())['Content-Type']
charset = re.search(r'charset=(.*)', content_type).group(1)
except AttributeError:
charset = 'utf-8'
xml = handler.read().decode(charset)
doc = ElementTree.XML(xml)
return dict(
current_conditions=dict(
text=doc.findtext('cc/t'),
temperature=doc.findtext('cc/tmp'),
humidity=doc.findtext('cc/hmid'),
wind=dict(
text=doc.findtext('cc/wind/t'),
speed=doc.findtext('cc/wind/s'),
),
),
today=dict(
min_temperature=doc.findtext('dayf/day[@d="0"]/low'),
max_temperature=doc.findtext('dayf/day[@d="0"]/hi'),
),
units=dict(
temperature=doc.findtext('head/ut'),
speed=doc.findtext('head/us'),
),
)
@require(internet)
def run(self):
result = self.fetch_weather()
conditions = result["current_conditions"]
temperature = conditions["temperature"]
humidity = conditions["humidity"]
wind = conditions["wind"]
units = result["units"]
color = None
current_temp = "{t}°{d}".format(t=temperature, d=units["temperature"])
min_temp = "{t}°{d}".format(t=result["today"]["min_temperature"], d=units["temperature"])
max_temp = "{t}°{d}".format(t=result["today"]["max_temperature"], d=units["temperature"])
current_wind = "{t} {s}{d}".format(t=wind["text"], s=wind["speed"], d=units["speed"])
if self.colorize:
icon, color = self.color_icons.get(conditions["text"],
self.color_icons["default"])
current_temp = "{t}°{d} {i}".format(t=temperature,
d=units["temperature"],
i=icon)
color = color
self.output = {
"full_text": self.format.format(
current_temp=current_temp,
current_wind=current_wind,
humidity=humidity,
min_temp=min_temp,
max_temp=max_temp,
),
"color": color
}

View File

@ -0,0 +1,141 @@
from i3pystatus import SettingsBase, IntervalModule, formatp
from i3pystatus.core.util import user_open, internet, require
class Backend(SettingsBase):
settings = ()
class Weather(IntervalModule):
'''
This is a generic weather-checker which must use a configured weather
backend. For list of all available backends see :ref:`weatherbackends`.
Left clicking on the module will launch the forecast page for the location
being checked.
.. _weather-formatters:
.. rubric:: Available formatters
* `{city}` Location of weather observation
* `{condition}` Current weather condition (Rain, Snow, Overcast, etc.)
* `{icon}` Icon representing the current weather condition
* `{observation_time}` Time of weather observation (supports strftime format flags)
* `{current_temp}` Current temperature, excluding unit
* `{low_temp}` Forecasted low temperature, excluding unit
* `{high_temp}` Forecasted high temperature, excluding unit (may be
empty in the late afternoon)
* `{temp_unit}` Either ``°C`` or ``°F``, depending on whether metric or
* `{feelslike}` "Feels Like" temperature, excluding unit
* `{dewpoint}` Dewpoint temperature, excluding unit
imperial units are being used
* `{wind_speed}` Wind speed, excluding unit
* `{wind_unit}` Either ``kph`` or ``mph``, depending on whether metric or
imperial units are being used
* `{wind_direction}` Wind direction
* `{wind_gust}` Speed of wind gusts in mph/kph, excluding unit
* `{pressure}` Barometric pressure, excluding unit
* `{pressure_unit}` ``mb`` or ``in``, depending on whether metric or
imperial units are being used
* `{pressure_trend}` ``+`` if rising, ``-`` if falling, or an empty
string if the pressure is steady (neither rising nor falling)
* `{visibility}` Visibility distance, excluding unit
* `{visibility_unit}` Either ``km`` or ``mi``, depending on whether
metric or imperial units are being used
* `{humidity}` Current humidity, excluding percentage symbol
* `{uv_index}` UV Index
This module supports the :ref:`formatp <formatp>` extended string format
syntax. This allows for values to be hidden when they evaluate as False.
This comes in handy for the :py:mod:`weathercom <.weather.weathercom>`
backend, which at a certain point in the afternoon will have a blank
``{high_temp}`` value. Using the following snippet in your format string
will only display the high temperature information if it is not blank:
::
{current_temp}{temp_unit}[ Hi: {high_temp}[{temp_unit}]] Lo: {low_temp}{temp_unit}
Brackets are evaluated from the outside-in, so the fact that the only
formatter in the outer block (``{high_temp}``) is empty would keep the
inner block from being evaluated at all, and entire block would not be
displayed.
See the following links for usage examples for the available weather
backends:
- :ref:`Weather.com <weather-usage-weathercom>`
- :ref:`Weather Underground <weather-usage-wunderground>`
'''
settings = (
('colorize', 'Vary the color depending on the current conditions.'),
('color_icons', 'Dictionary mapping weather conditions to tuples '
'containing a UTF-8 code for the icon, and the color '
'to be used.'),
('color', 'Display color (or fallback color if ``colorize`` is True). '
'If not specified, falls back to default i3bar color.'),
('backend', 'Weather backend instance'),
'interval',
'format',
)
required = ('backend',)
colorize = False
color_icons = {
'Fair': (u'\u2600', '#ffcc00'),
'Cloudy': (u'\u2601', '#f8f8ff'),
'Partly Cloudy': (u'\u2601', '#f8f8ff'), # \u26c5 is not in many fonts
'Rainy': (u'\u26c8', '#cbd2c0'),
'Thunderstorm': (u'\u03de', '#cbd2c0'),
'Sunny': (u'\u263c', '#ffff00'),
'Snow': (u'\u2603', '#ffffff'),
'default': ('', None),
}
color = None
backend = None
interval = 1800
format = '{current_temp}{temp_unit}'
on_leftclick = 'open_forecast_url'
def open_forecast_url(self):
if self.backend.forecast_url and self.backend.forecast_url != 'N/A':
user_open(self.backend.forecast_url)
def init(self):
pass
def get_color_data(self, condition):
'''
Disambiguate similarly-named weather conditions, and return the icon
and color that match.
'''
condition_lc = condition.lower()
if condition_lc == 'clear':
condition = 'Fair'
if 'cloudy' in condition_lc:
condition = 'Cloudy'
elif 'rain' in condition_lc:
condition = 'Rainy'
elif 'thunder' in condition_lc:
condition = 'Thunderstorm'
elif 'snow' in condition_lc:
condition = 'Snow'
return self.color_icons['default'] \
if condition not in self.color_icons \
else self.color_icons[condition]
@require(internet)
def run(self):
data = self.backend.weather_data()
data['icon'], condition_color = self.get_color_data(data['condition'])
color = condition_color if self.colorize else self.color
self.output = {
'full_text': formatp(self.format, **data).strip(),
'color': color,
}

View File

@ -0,0 +1,137 @@
from i3pystatus import IntervalModule
from i3pystatus.core.util import internet, require
from i3pystatus.weather import Backend
from datetime import datetime
from urllib.request import urlopen
import re
import xml.etree.ElementTree as ElementTree
WEATHER_COM_URL = 'http://wxdata.weather.com/wxdata/weather/local/%s?unit=%s&dayf=1&cc=*'
ON_LEFTCLICK_URL = 'https://weather.com/weather/today/l/%s'
class Weathercom(Backend):
'''
This module gets the weather from weather.com. The ``location_code``
parameter should be set to the location code from weather.com. To obtain
this code, search for the location on weather.com, and the location code
will be everything after the last slash (e.g. ``94107:4:US``).
.. _weather-usage-weathercom:
.. rubric:: Usage example
.. code-block:: python
from i3pystatus import Status
from i3pystatus.weather import weathercom
status = Status()
status.register(
'weather',
format='{condition} {current_temp}{temp_unit}{icon}[ Hi: {high_temp}] Lo: {low_temp}',
colorize=True,
backend=weathercom.Weathercom(
location_code='94107:4:US',
units='imperial',
),
)
status.run()
See :ref:`here <weather-formatters>` for a list of formatters which can be
used.
'''
settings = (
('location_code', 'Location code from www.weather.com'),
('units', '\'metric\' or \'imperial\''),
)
required = ('location_code',)
location_code = None
units = 'metric'
# This will be set once weather data has been checked
forecast_url = None
@require(internet)
def weather_data(self):
'''
Fetches the current weather from wxdata.weather.com service.
'''
if self.forecast_url is None and ':' in self.location_code:
# Set the URL so that clicking the weather will launch the
# weather.com forecast page. Only set it though if there is a colon
# in the location_code. Technically, the weather.com API will
# successfully return weather data if a U.S. ZIP code is used as
# the location_code (e.g. 94107), but if substituted in
# ON_LEFTCLICK_URL it may or may not result in a valid URL.
self.forecast_url = ON_LEFTCLICK_URL % self.location_code
unit = '' if self.units == 'imperial' or self.units == '' else 'm'
url = WEATHER_COM_URL % (self.location_code, unit)
with urlopen(url) as handler:
try:
content_type = dict(handler.getheaders())['Content-Type']
charset = re.search(r'charset=(.*)', content_type).group(1)
except AttributeError:
charset = 'utf-8'
xml = handler.read().decode(charset)
doc = ElementTree.XML(xml)
# Cut off the timezone from the end of the string (it's after the last
# space, hence the use of rpartition). International timezones (or ones
# outside the system locale) don't seem to be handled well by
# datetime.datetime.strptime().
observation_time_str = doc.findtext('cc/lsup').rpartition(' ')[0]
try:
observation_time = datetime.strptime(observation_time_str,
'%m/%d/%y %I:%M %p')
except ValueError:
observation_time = datetime.fromtimestamp(0)
pressure_trend_str = doc.findtext('cc/bar/d').lower()
if pressure_trend_str == 'rising':
pressure_trend = '+'
elif pressure_trend_str == 'falling':
pressure_trend = '-'
else:
pressure_trend = ''
if not doc.findtext('dayf/day[@d="0"]/part[@p="d"]/icon').strip():
# If the "d" (day) part of today's forecast's keys are empty, there
# is no high temp anymore (this happens in the afternoon), but
# instead of handling things in a sane way and setting the high
# temp to an empty string or something like that, the API returns
# the current temp as the high temp, which is incorrect. This if
# statement catches it so that we can correctly report that there
# is no high temp at this point of the day.
high_temp = ''
else:
high_temp = doc.findtext('dayf/day[@d="0"]/hi')
return dict(
city=doc.findtext('loc/dnam'),
condition=doc.findtext('cc/t'),
observation_time=observation_time,
current_temp=doc.findtext('cc/tmp'),
low_temp=doc.findtext('dayf/day[@d="0"]/low'),
high_temp=high_temp,
temp_unit='°' + doc.findtext('head/ut').upper(),
feelslike=doc.findtext('cc/flik'),
dewpoint=doc.findtext('cc/dewp'),
wind_speed=doc.findtext('cc/wind/s'),
wind_unit=doc.findtext('head/us'),
wind_direction=doc.findtext('cc/wind/t'),
wind_gust=doc.findtext('cc/wind/gust'),
pressure=doc.findtext('cc/bar/r'),
pressure_unit=doc.findtext('head/up'),
pressure_trend=pressure_trend,
visibility=doc.findtext('cc/vis'),
visibility_unit=doc.findtext('head/ud'),
humidity=doc.findtext('cc/hmid'),
uv_index=doc.findtext('cc/uv/i'),
)

View File

@ -0,0 +1,240 @@
from i3pystatus import IntervalModule
from i3pystatus.core.util import internet, require
from datetime import datetime
from urllib.request import urlopen
import json
import re
GEOLOOKUP_URL = 'http://api.wunderground.com/api/%s/geolookup%s/q/%s.json'
STATION_QUERY_URL = 'http://api.wunderground.com/api/%s/%s/q/%s.json'
class Wunderground(IntervalModule):
'''
This module retrieves weather data using the Weather Underground API.
.. note::
A Weather Underground API key is required to use this module, you can
sign up for a developer API key free at
https://www.wunderground.com/weather/api/
A developer API key is allowed 500 queries per day, and no more than 10
in a given minute. Therefore, it is recommended to be conservative when
setting the update interval.
Valid values for ``location_code`` include:
* **State/City_Name** - CA/San_Francisco
* **Country/City** - France/Paris
* **Geolocation by IP** - autoip
* **Zip or Postal Code** - 60616
* **ICAO Airport Code** - icao:LAX
* **Latitude/Longitude** - 41.8301943,-87.6342619
* **Personal Weather Station (PWS)** - pws:KILCHICA30
When not using a ``pws`` or ``icao`` station ID, the location will be
queried, and the closest station will be used. For a list of PWS
station IDs, visit the following URL:
http://www.wunderground.com/weatherstation/ListStations.asp
.. _weather-usage-wunderground:
.. rubric:: Usage example
.. code-block:: python
from i3pystatus import Status
from i3pystatus.weather import wunderground
status = Status()
status.register(
'weather',
format='{condition} {current_temp}{temp_unit}{icon}[ Hi: {high_temp}] Lo: {low_temp}',
colorize=True,
backend=wunderground.Wunderground(
api_key='dbafe887d56ba4ad',
location_code='pws:MAT645',
units='imperial',
),
)
status.run()
See :ref:`here <weather-formatters>` for a list of formatters which can be
used.
'''
interval = 300
settings = (
('api_key', 'Weather Underground API key'),
('location_code', 'Location code from wunderground.com'),
('units', '\'metric\' or \'imperial\''),
('use_pws', 'Set to False to use only airport stations'),
('forecast', 'Set to ``True`` to check forecast (generates one '
'additional API request per weather update). If set to '
'``False``, then the ``low_temp`` and ``high_temp`` '
'formatters will be set to empty strings.'),
)
required = ('api_key', 'location_code')
api_key = None
location_code = None
units = 'metric'
use_pws = True
forecast = False
# These will be set once weather data has been checked
station_id = None
forecast_url = None
@require(internet)
def api_request(self, url):
'''
Execute an HTTP POST to the specified URL and return the content
'''
with urlopen(url) as content:
try:
content_type = dict(content.getheaders())['Content-Type']
charset = re.search(r'charset=(.*)', content_type).group(1)
except AttributeError:
charset = 'utf-8'
response = json.loads(content.read().decode(charset))
try:
raise Exception(response['response']['error']['description'])
except KeyError:
pass
return response
@require(internet)
def geolookup(self):
'''
Use the location_code to perform a geolookup and find the closest
station. If the location is a pws or icao station ID, no lookup will be
peformed.
'''
if self.station_id is None:
try:
for no_lookup in ('pws', 'icao'):
sid = self.location_code.partition(no_lookup + ':')[-1]
if sid:
self.station_id = self.location_code
return
except AttributeError:
# Numeric or some other type, either way we'll just stringify
# it below and perform a lookup.
pass
extra_opts = '/pws:0' if not self.use_pws else ''
api_url = GEOLOOKUP_URL % (self.api_key,
extra_opts,
self.location_code)
response = self.api_request(api_url)
station_type = 'pws' if self.use_pws else 'airport'
try:
stations = response['location']['nearby_weather_stations']
nearest = stations[station_type]['station'][0]
except (KeyError, IndexError):
raise Exception('No locations matched location_code %s'
% self.location_code)
if self.use_pws:
nearest_pws = nearest.get('id', '')
if not nearest_pws:
raise Exception('No id entry for station')
self.station_id = 'pws:%s' % nearest_pws
else:
nearest_airport = nearest.get('icao', '')
if not nearest_airport:
raise Exception('No icao entry for station')
self.station_id = 'icao:%s' % nearest_airport
@require(internet)
def get_forecast(self):
'''
If configured to do so, make an API request to retrieve the forecast
data for the configured/queried weather station, and return the low and
high temperatures. Otherwise, return two empty strings.
'''
if self.forecast:
query_url = STATION_QUERY_URL % (self.api_key,
'forecast',
self.station_id)
try:
response = self.api_request(query_url)['forecast']
response = response['simpleforecast']['forecastday'][0]
except (KeyError, IndexError, TypeError):
raise Exception('No forecast data found for %s' % self.station_id)
unit = 'celsius' if self.units == 'metric' else 'fahrenheit'
low_temp = response.get('low', {}).get(unit, '')
high_temp = response.get('high', {}).get(unit, '')
return low_temp, high_temp
else:
return '', ''
@require(internet)
def weather_data(self):
'''
Query the configured/queried station and return the weather data
'''
# If necessary, do a geolookup to set the station_id
self.geolookup()
query_url = STATION_QUERY_URL % (self.api_key,
'conditions',
self.station_id)
try:
response = self.api_request(query_url)['current_observation']
self.forecast_url = response.pop('ob_url', None)
except KeyError:
raise Exception('No weather data found for %s' % self.station_id)
low_temp, high_temp = self.get_forecast()
if self.units == 'metric':
temp_unit = 'c'
speed_unit = 'kph'
distance_unit = 'km'
pressure_unit = 'mb'
else:
temp_unit = 'f'
speed_unit = 'mph'
distance_unit = 'mi'
pressure_unit = 'in'
def _find(key, data=None):
data = data or response
return data.get(key, 'N/A')
try:
observation_time = int(_find('observation_epoch'))
except TypeError:
observation_time = 0
return dict(
city=_find('city', response['observation_location']),
condition=_find('weather'),
observation_time=datetime.fromtimestamp(observation_time),
current_temp=_find('temp_' + temp_unit),
low_temp=low_temp,
high_temp=high_temp,
temp_unit='°' + temp_unit.upper(),
feelslike=_find('feelslike_' + temp_unit),
dewpoint=_find('dewpoint_' + temp_unit),
wind_speed=_find('wind_' + speed_unit),
wind_unit=speed_unit,
wind_direction=_find('wind_dir'),
wind_gust=_find('wind_gust_' + speed_unit),
pressure=_find('pressure_' + pressure_unit),
pressure_unit=pressure_unit,
pressure_trend=_find('pressure_trend'),
visibility=_find('visibility_' + distance_unit),
visibility_unit=distance_unit,
humidity=_find('relative_humidity').rstrip('%'),
uv_index=_find('uv'),
)

View File

@ -22,6 +22,7 @@ setup(name="i3pystatus",
"i3pystatus.mail", "i3pystatus.mail",
"i3pystatus.pulseaudio", "i3pystatus.pulseaudio",
"i3pystatus.updates", "i3pystatus.updates",
"i3pystatus.weather",
], ],
entry_points={ entry_points={
"console_scripts": [ "console_scripts": [