Skip to content

Commit 2274e61

Browse files
RustyBowerclaude
andcommitted
Add Tomorrow.io weather provider and improve documentation
- Add Tomorrow.io as a new weather provider with full weather code mapping - Update README with provider comparison table and detailed setup instructions - Modernize tests to work with Sopel 8 and add provider-specific tests - All 14 tests passing for Open-Meteo, Pirate Weather, and Tomorrow.io Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 512f95b commit 2274e61

File tree

4 files changed

+669
-366
lines changed

4 files changed

+669
-366
lines changed

README.md

Lines changed: 146 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ A weather lookup plugin for Sopel IRC bots.
1111
pip install sopel-weather
1212
```
1313

14-
## Configuration
14+
## Quick Start
1515

16-
You can automatically configure this plugin using `sopel configure --plugins`.
16+
Configure the plugin using:
1717

18-
Or configure manually in `~/.sopel/default.cfg`:
18+
```bash
19+
sopel configure --plugins
20+
```
21+
22+
Or manually edit `~/.sopel/default.cfg`:
1923

2024
```ini
2125
[weather]
@@ -25,49 +29,168 @@ weather_provider = openmeteo
2529
weather_api_key = dummy
2630
```
2731

28-
## Usage
32+
## Weather Providers
33+
34+
| Provider | API Key Required | Free Tier | UV Index | Features |
35+
|----------|-----------------|-----------|----------|----------|
36+
| **Open-Meteo** | No (use "dummy") | Unlimited | No | Best for basic weather, no signup needed |
37+
| **Tomorrow.io** | Yes | 500 calls/day | Yes | Modern API, good accuracy |
38+
| **Pirate Weather** | Yes | 20,000 calls/month | Yes | Dark Sky replacement |
39+
| **OpenWeatherMap** | Yes | 1,000 calls/day | Yes | Popular, well-documented |
40+
41+
### Open-Meteo (Recommended for simplicity)
42+
43+
No API key required. Use any string (e.g., "dummy") for the weather_api_key.
44+
45+
```ini
46+
[weather]
47+
weather_provider = openmeteo
48+
weather_api_key = dummy
49+
```
50+
51+
- Website: https://open-meteo.com/
52+
- Free for non-commercial use
53+
- No signup required
54+
55+
### Tomorrow.io
56+
57+
Sign up at https://www.tomorrow.io/ for a free API key.
58+
59+
```ini
60+
[weather]
61+
weather_provider = tomorrow
62+
weather_api_key = YOUR_TOMORROW_API_KEY
63+
```
64+
65+
- Free tier: 500 API calls/day
66+
- Includes UV index
67+
- Modern REST API
68+
69+
### Pirate Weather
70+
71+
Sign up at https://pirateweather.net/ for a free API key.
72+
73+
```ini
74+
[weather]
75+
weather_provider = pirateweather
76+
weather_api_key = YOUR_PIRATEWEATHER_API_KEY
77+
```
78+
79+
- Free tier: 20,000 API calls/month
80+
- Drop-in Dark Sky API replacement
81+
- Includes UV index
82+
83+
### OpenWeatherMap
84+
85+
Sign up at https://openweathermap.org/ for a free API key.
86+
87+
```ini
88+
[weather]
89+
weather_provider = openweathermap
90+
weather_api_key = YOUR_OPENWEATHERMAP_API_KEY
91+
```
92+
93+
- Free tier: 1,000 API calls/day
94+
- Includes UV index
95+
96+
## Geocoding Provider
97+
98+
A geocoding provider is required to convert location names (like "Seattle" or "90210") to coordinates.
99+
100+
### LocationIQ (Required)
101+
102+
Sign up at https://locationiq.com/ for a free API key.
103+
104+
```ini
105+
[weather]
106+
geocoords_provider = locationiq_us
107+
geocoords_api_key = YOUR_LOCATIONIQ_API_KEY
108+
```
109+
110+
Options:
111+
- `locationiq_us` - US-based servers (recommended for North America)
112+
- `locationiq_eu` - EU-based servers (recommended for Europe)
113+
114+
Free tier: 5,000 requests/day
115+
116+
## Configuration Options
117+
118+
| Option | Description | Default |
119+
|--------|-------------|---------|
120+
| `geocoords_provider` | Geocoding provider (`locationiq_us`, `locationiq_eu`) | `locationiq_us` |
121+
| `geocoords_api_key` | API key for geocoding provider | Required |
122+
| `weather_provider` | Weather provider (`openmeteo`, `tomorrow`, `pirateweather`, `openweathermap`) | Required |
123+
| `weather_api_key` | API key for weather provider | Required |
124+
| `sunrise_sunset` | Show sunrise/sunset times | `False` |
125+
| `nick_lookup` | Allow looking up weather by IRC nickname | `True` |
126+
127+
### Example Configuration
128+
129+
```ini
130+
[weather]
131+
geocoords_provider = locationiq_us
132+
geocoords_api_key = pk.abc123...
133+
weather_provider = tomorrow
134+
weather_api_key = abc123xyz...
135+
sunrise_sunset = True
136+
nick_lookup = True
137+
```
138+
139+
## Commands
29140

30141
### Current Weather
31142

32143
```
33144
.weather # Uses your saved location
34-
.weather seattle, us
35-
.weather london
145+
.weather seattle
146+
.weather Seattle, US
147+
.weather 90210
36148
```
37149

38150
Example output:
39151
```
40-
Paris, Ile-de-France, FR: 6°C (42°F), Clear, Humidity: 83%, UV Index: 0, Gentle breeze 4.0m/s (↗)
152+
Seattle, Washington, US: 12°C (54°F), Partly Cloudy, Humidity: 75%, UV Index: 2, Gentle breeze: 19km/h (12mph) (↑)
41153
```
42154

43155
### Forecast
44156

45157
```
46-
.forecast # Uses your saved location
47-
.forecast seattle, us
48-
.forecast london
158+
.forecast # Uses your saved location
159+
.forecast seattle
160+
.forecast London, UK
161+
```
162+
163+
Example output:
164+
```
165+
Seattle, WA, US :: Monday - Partly Cloudy - 12°C (54°F) / 5°C (41°F) :: Tuesday - Rain - 14°C (57°F) / 6°C (43°F) ...
49166
```
50167

51168
### Set Your Location
52169

53170
```
54-
.setlocation london # Set by city name
55-
.setlocation 98101 # Set by US zip code
171+
.setlocation seattle
172+
.setlocation 98101
173+
.setlocation London, UK
56174
```
57175

58-
## API Keys Required
176+
## Troubleshooting
177+
178+
### "Weather API key missing"
179+
Set `weather_api_key` in your configuration. For Open-Meteo, use any string like "dummy".
180+
181+
### "GeoCoords API key missing"
182+
Sign up at https://locationiq.com/ and add your API key to `geocoords_api_key`.
59183

60-
### GeoCoords Provider
61-
- [LocationIQ](https://locationiq.com/) - Free tier available
184+
### "Could not geocode location"
185+
- Check your LocationIQ API key is valid
186+
- Try a more specific location (e.g., "Seattle, WA" instead of "Seattle")
187+
- Check LocationIQ rate limits (5,000/day on free tier)
62188

63-
### Weather Providers
189+
### "Error: Invalid API key"
190+
Verify your weather provider API key is correct and active.
64191

65-
- **Open-Meteo** - No API key required (use any string)
66-
- https://open-meteo.com/
67-
- **OpenWeatherMap** - Free tier available
68-
- https://openweathermap.org/
69-
- **Pirate Weather** - Drop-in Dark Sky replacement
70-
- https://pirateweather.net/
192+
### UV Index not showing
193+
UV Index is only available with Tomorrow.io, Pirate Weather, and OpenWeatherMap providers.
71194

72195
## Requirements
73196

@@ -76,4 +199,4 @@ Paris, Ile-de-France, FR: 6°C (42°F), Clear, Humidity: 83%, UV Index: 0, Gentl
76199

77200
## License
78201

79-
MIT License
202+
Eiffel Forum License 2

sopel_weather/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@
2323
from .providers.weather.openmeteo import openmeteo_forecast, openmeteo_weather
2424
from .providers.weather.openweathermap import openweathermap_forecast, openweathermap_weather
2525
from .providers.weather.pirateweather import pirateweather_forecast, pirateweather_weather
26+
from .providers.weather.tomorrow import tomorrow_forecast, tomorrow_weather
2627

2728
WEATHER_PROVIDERS = [
2829
'darksky',
2930
'openmeteo',
3031
'openweathermap',
3132
'pirateweather',
33+
'tomorrow',
3234
]
3335

3436
GEOCOORDS_PROVIDERS = {
@@ -252,6 +254,9 @@ def get_forecast(bot, trigger):
252254
# Pirate Weather
253255
elif bot.config.weather.weather_provider == 'pirateweather':
254256
return pirateweather_forecast(bot, latitude, longitude, location)
257+
# Tomorrow.io
258+
elif bot.config.weather.weather_provider == 'tomorrow':
259+
return tomorrow_forecast(bot, latitude, longitude, location)
255260
# Unsupported Provider
256261
else:
257262
raise Exception('Error: Unsupported Provider')
@@ -273,6 +278,9 @@ def get_weather(bot, trigger):
273278
# Pirate Weather
274279
elif bot.config.weather.weather_provider == 'pirateweather':
275280
return pirateweather_weather(bot, latitude, longitude, location)
281+
# Tomorrow.io
282+
elif bot.config.weather.weather_provider == 'tomorrow':
283+
return tomorrow_weather(bot, latitude, longitude, location)
276284
# Unsupported Provider
277285
else:
278286
raise Exception('Error: Unsupported Provider')
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# coding=utf-8
2+
import requests
3+
4+
from datetime import datetime
5+
6+
7+
API_ENDPOINT = 'https://api.tomorrow.io/v4/weather/forecast'
8+
9+
# Tomorrow.io weather codes
10+
# https://docs.tomorrow.io/reference/data-layers-weather-codes
11+
WEATHER_CODES = {
12+
0: "Unknown",
13+
1000: "Clear",
14+
1001: "Cloudy",
15+
1100: "Mostly Clear",
16+
1101: "Partly Cloudy",
17+
1102: "Mostly Cloudy",
18+
2000: "Fog",
19+
2100: "Light Fog",
20+
4000: "Drizzle",
21+
4001: "Rain",
22+
4200: "Light Rain",
23+
4201: "Heavy Rain",
24+
5000: "Snow",
25+
5001: "Flurries",
26+
5100: "Light Snow",
27+
5101: "Heavy Snow",
28+
6000: "Freezing Drizzle",
29+
6001: "Freezing Rain",
30+
6200: "Light Freezing Rain",
31+
6201: "Heavy Freezing Rain",
32+
7000: "Ice Pellets",
33+
7101: "Heavy Ice Pellets",
34+
7102: "Light Ice Pellets",
35+
8000: "Thunderstorm",
36+
}
37+
38+
39+
def tomorrow_forecast(bot, latitude, longitude, location):
40+
params = {
41+
'location': '{},{}'.format(latitude, longitude),
42+
'apikey': bot.config.weather.weather_api_key,
43+
'units': 'metric',
44+
'timesteps': 'daily',
45+
}
46+
47+
try:
48+
r = requests.get(API_ENDPOINT, params=params)
49+
except:
50+
raise Exception("An Error Occurred. Check Logs For More Information.")
51+
52+
data = r.json()
53+
if r.status_code != 200:
54+
error_msg = data.get('message', data.get('error', 'Unknown error'))
55+
raise Exception('Error: {}'.format(error_msg))
56+
57+
weather_data = {'location': location, 'data': []}
58+
59+
daily_data = data['timelines']['daily']
60+
for day in daily_data[:4]:
61+
weather_code = day['values'].get('weatherCodeMax', 0)
62+
condition = WEATHER_CODES.get(weather_code, 'Unknown')
63+
64+
weather_data['data'].append({
65+
'dow': datetime.fromisoformat(day['time'].replace('Z', '+00:00')).strftime('%A'),
66+
'summary': condition,
67+
'high_temp': day['values']['temperatureMax'],
68+
'low_temp': day['values']['temperatureMin'],
69+
})
70+
71+
return weather_data
72+
73+
74+
def tomorrow_weather(bot, latitude, longitude, location):
75+
params = {
76+
'location': '{},{}'.format(latitude, longitude),
77+
'apikey': bot.config.weather.weather_api_key,
78+
'units': 'metric',
79+
'timesteps': 'current,daily',
80+
}
81+
82+
try:
83+
r = requests.get(API_ENDPOINT, params=params)
84+
except:
85+
raise Exception("An Error Occurred. Check Logs For More Information.")
86+
87+
data = r.json()
88+
if r.status_code != 200:
89+
error_msg = data.get('message', data.get('error', 'Unknown error'))
90+
raise Exception('Error: {}'.format(error_msg))
91+
92+
current = data['timelines']['minutely'][0]['values']
93+
weather_code = current.get('weatherCode', 0)
94+
condition = WEATHER_CODES.get(weather_code, 'Unknown')
95+
96+
# Wind speed from Tomorrow.io is in m/s when using metric units
97+
weather_data = {
98+
'location': location,
99+
'temp': current['temperature'],
100+
'condition': condition,
101+
'humidity': current['humidity'] / 100.0, # normalize to decimal percentage
102+
'wind': {
103+
'speed': current['windSpeed'],
104+
'bearing': current['windDirection'],
105+
},
106+
'uvindex': current.get('uvIndex'),
107+
'timezone': data['location'].get('timezone', 'UTC'),
108+
}
109+
110+
if bot.config.weather.sunrise_sunset:
111+
daily = data['timelines']['daily'][0]['values']
112+
# Parse ISO format timestamps and convert to Unix timestamps
113+
sunrise_str = daily.get('sunriseTime', '')
114+
sunset_str = daily.get('sunsetTime', '')
115+
if sunrise_str:
116+
weather_data['sunrise'] = int(datetime.fromisoformat(
117+
sunrise_str.replace('Z', '+00:00')).timestamp())
118+
if sunset_str:
119+
weather_data['sunset'] = int(datetime.fromisoformat(
120+
sunset_str.replace('Z', '+00:00')).timestamp())
121+
122+
return weather_data

0 commit comments

Comments
 (0)