Skip to content

Commit 115fed0

Browse files
committed
Add global geolocator
1 parent 4de118a commit 115fed0

File tree

12 files changed

+627
-170
lines changed

12 files changed

+627
-170
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,14 @@ short_error_message | The short error message, if available
5959
- [Blocks](https://docs.rs/i3status-rs/latest/i3status_rs/blocks/index.html)
6060
- [Formatting](https://docs.rs/i3status-rs/latest/i3status_rs/formatting/index.html)
6161
- [Themes and Icons](https://github.com/greshake/i3status-rust/blob/v0.32.0/doc/themes.md)
62+
- [Geolocator](https://docs.rs/i3status-rs/latest/i3status_rs/geolocator/index.html)
6263

6364
#### Master
6465

6566
- [Blocks](https://greshake.github.io/i3status-rust/i3status_rs/blocks/index.html)
6667
- [Formatting](https://greshake.github.io/i3status-rust/i3status_rs/formatting/index.html)
6768
- [Themes and Icons](doc/themes.md)
69+
- [Geolocator](https://greshake.github.io/i3status-rust/i3status_rs/geolocator/index.html)
6870

6971
## Integrate it into i3/sway
7072

src/blocks.rs

+15
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ use std::time::Duration;
4040

4141
use crate::click::MouseButton;
4242
use crate::errors::*;
43+
use crate::geolocator::{Geolocator, IPAddressInfo};
4344
use crate::widget::Widget;
4445
use crate::{BoxedFuture, Request, RequestCmd};
4546

@@ -208,6 +209,7 @@ pub struct CommonApi {
208209
pub(crate) update_request: Arc<Notify>,
209210
pub(crate) request_sender: mpsc::UnboundedSender<Request>,
210211
pub(crate) error_interval: Duration,
212+
pub(crate) geolocator: Arc<Geolocator>,
211213
}
212214

213215
impl CommonApi {
@@ -267,4 +269,17 @@ impl CommonApi {
267269
pub async fn wait_for_update_request(&self) {
268270
self.update_request.notified().await;
269271
}
272+
273+
fn locator_name(&self) -> Cow<'static, str> {
274+
self.geolocator.name()
275+
}
276+
277+
/// No-op if last API call was made in the last `interval` seconds.
278+
pub async fn find_ip_location(
279+
&self,
280+
client: &reqwest::Client,
281+
interval: Duration,
282+
) -> Result<IPAddressInfo> {
283+
self.geolocator.find_ip_location(client, interval).await
284+
}
270285
}

src/blocks/external_ip.rs

+61-79
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,6 @@ use zbus::MatchRule;
6565
use super::prelude::*;
6666
use crate::util::{country_flag_from_iso_code, new_system_dbus_connection};
6767

68-
const API_ENDPOINT: &str = "https://ipapi.co/json/";
69-
7068
#[derive(Deserialize, Debug, SmartDefault)]
7169
#[serde(deny_unknown_fields, default)]
7270
pub struct Config {
@@ -137,40 +135,75 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
137135
};
138136

139137
loop {
140-
let fetch_info = || IPAddressInfo::new(client);
138+
let fetch_info = || api.find_ip_location(client, Duration::from_secs(0));
141139
let info = fetch_info.retry(ExponentialBuilder::default()).await?;
142140

143141
let mut values = map! {
144142
"ip" => Value::text(info.ip),
145-
"version" => Value::text(info.version),
146143
"city" => Value::text(info.city),
147-
"region" => Value::text(info.region),
148-
"region_code" => Value::text(info.region_code),
149-
"country" => Value::text(info.country),
150-
"country_name" => Value::text(info.country_name),
151-
"country_flag" => Value::text(country_flag_from_iso_code(&info.country_code)),
152-
"country_code" => Value::text(info.country_code),
153-
"country_code_iso3" => Value::text(info.country_code_iso3),
154-
"country_capital" => Value::text(info.country_capital),
155-
"country_tld" => Value::text(info.country_tld),
156-
"continent_code" => Value::text(info.continent_code),
157144
"latitude" => Value::number(info.latitude),
158145
"longitude" => Value::number(info.longitude),
159-
"timezone" => Value::text(info.timezone),
160-
"utc_offset" => Value::text(info.utc_offset),
161-
"country_calling_code" => Value::text(info.country_calling_code),
162-
"currency" => Value::text(info.currency),
163-
"currency_name" => Value::text(info.currency_name),
164-
"languages" => Value::text(info.languages),
165-
"country_area" => Value::number(info.country_area),
166-
"country_population" => Value::number(info.country_population),
167-
"asn" => Value::text(info.asn),
168-
"org" => Value::text(info.org),
169146
};
170-
info.postal
171-
.map(|x| values.insert("postal".into(), Value::text(x)));
172-
if info.in_eu {
173-
values.insert("in_eu".into(), Value::flag());
147+
148+
macro_rules! map_push_if_some { ($($key:ident: $type:ident),* $(,)?) => {
149+
$({
150+
let key = stringify!($key);
151+
if let Some(value) = info.$key {
152+
values.insert(key.into(), Value::$type(value));
153+
} else if format.contains_key(key) {
154+
return Err(Error::new(format!(
155+
"The format string contains '{key}', but the {key} field is not provided by {} (an api key may be required)",
156+
api.locator_name()
157+
)));
158+
}
159+
})*
160+
} }
161+
162+
map_push_if_some!(
163+
version: text,
164+
region: text,
165+
region_code: text,
166+
country: text,
167+
country_name: text,
168+
country_code_iso3: text,
169+
country_capital: text,
170+
country_tld: text,
171+
continent_code: text,
172+
postal: text,
173+
timezone: text,
174+
utc_offset: text,
175+
country_calling_code: text,
176+
currency: text,
177+
currency_name: text,
178+
languages: text,
179+
country_area: number,
180+
country_population: number,
181+
asn: text,
182+
org: text,
183+
);
184+
185+
if let Some(country_code) = info.country_code {
186+
values.insert(
187+
"country_flag".into(),
188+
Value::text(country_flag_from_iso_code(&country_code)),
189+
);
190+
values.insert("country_code".into(), Value::text(country_code));
191+
} else if format.contains_key("country_code") || format.contains_key("country_flag") {
192+
return Err(Error::new(format!(
193+
"The format string contains 'country_code' or 'country_flag', but the country_code field is not provided by {}",
194+
api.locator_name()
195+
)));
196+
}
197+
198+
if let Some(in_eu) = info.in_eu {
199+
if in_eu {
200+
values.insert("in_eu".into(), Value::flag());
201+
}
202+
} else if format.contains_key("in_eu") {
203+
return Err(Error::new(format!(
204+
"The format string contains 'in_eu', but the in_eu field is not provided by {}",
205+
api.locator_name()
206+
)));
174207
}
175208

176209
let mut widget = Widget::new().with_format(format.clone());
@@ -184,54 +217,3 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
184217
}
185218
}
186219
}
187-
188-
#[derive(Deserialize, Default)]
189-
#[serde(default)]
190-
struct IPAddressInfo {
191-
error: bool,
192-
reason: String,
193-
ip: String,
194-
version: String,
195-
city: String,
196-
region: String,
197-
region_code: String,
198-
country: String,
199-
country_name: String,
200-
country_code: String,
201-
country_code_iso3: String,
202-
country_capital: String,
203-
country_tld: String,
204-
continent_code: String,
205-
in_eu: bool,
206-
postal: Option<String>,
207-
latitude: f64,
208-
longitude: f64,
209-
timezone: String,
210-
utc_offset: String,
211-
country_calling_code: String,
212-
currency: String,
213-
currency_name: String,
214-
languages: String,
215-
country_area: f64,
216-
country_population: f64,
217-
asn: String,
218-
org: String,
219-
}
220-
221-
impl IPAddressInfo {
222-
async fn new(client: &reqwest::Client) -> Result<Self> {
223-
let info: Self = client
224-
.get(API_ENDPOINT)
225-
.send()
226-
.await
227-
.error("Failed to request current location")?
228-
.json::<Self>()
229-
.await
230-
.error("Failed to parse JSON")?;
231-
if info.error {
232-
Err(Error::new(info.reason))
233-
} else {
234-
Ok(info)
235-
}
236-
}
237-
}

src/blocks/weather.rs

+3-86
Original file line numberDiff line numberDiff line change
@@ -141,25 +141,18 @@
141141
//! - `weather_thunder` (when weather is reported as "Thunderstorm" during the day)
142142
//! - `weather_thunder_night` (when weather is reported as "Thunderstorm" at night)
143143
144-
use std::fmt;
145-
use std::sync::{Arc, Mutex};
146-
use std::time::Instant;
147-
148144
use chrono::{DateTime, Utc};
149145
use sunrise::{SolarDay, SolarEvent};
150146

151147
use crate::formatting::Format;
148+
pub(super) use crate::geolocator::IPAddressInfo;
152149

153150
use super::prelude::*;
154151

155152
pub mod met_no;
156153
pub mod nws;
157154
pub mod open_weather_map;
158155

159-
const IP_API_URL: &str = "https://ipapi.co/json";
160-
161-
static LAST_AUTOLOCATE: Mutex<Option<AutolocateResult>> = Mutex::new(None);
162-
163156
#[derive(Deserialize, Debug)]
164157
#[serde(deny_unknown_fields)]
165158
pub struct Config {
@@ -182,7 +175,7 @@ fn default_interval() -> Seconds {
182175
trait WeatherProvider {
183176
async fn get_weather(
184177
&self,
185-
autolocated_location: Option<&Coordinates>,
178+
autolocated_location: Option<&IPAddressInfo>,
186179
need_forecast: bool,
187180
) -> Result<WeatherResult>;
188181
}
@@ -465,7 +458,7 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
465458

466459
loop {
467460
let location = if config.autolocate {
468-
let fetch = || find_ip_location(autolocate_interval.0);
461+
let fetch = || api.find_ip_location(&REQWEST_CLIENT, autolocate_interval.0);
469462
Some(fetch.retry(ExponentialBuilder::default()).await?)
470463
} else {
471464
None
@@ -543,82 +536,6 @@ enum UnitSystem {
543536
Imperial,
544537
}
545538

546-
#[derive(Deserialize, Clone)]
547-
struct Coordinates {
548-
latitude: f64,
549-
longitude: f64,
550-
city: String,
551-
}
552-
553-
struct AutolocateResult {
554-
location: Coordinates,
555-
timestamp: Instant,
556-
}
557-
558-
// TODO: might be good to allow for different geolocation services to be used, similar to how we have `service` for the weather API
559-
/// No-op if last API call was made in the last `interval` seconds.
560-
async fn find_ip_location(interval: Duration) -> Result<Coordinates> {
561-
{
562-
let guard = LAST_AUTOLOCATE.lock().unwrap();
563-
if let Some(cached) = &*guard {
564-
if cached.timestamp.elapsed() < interval {
565-
return Ok(cached.location.clone());
566-
}
567-
}
568-
}
569-
570-
#[derive(Deserialize)]
571-
struct ApiResponse {
572-
#[serde(flatten)]
573-
location: Option<Coordinates>,
574-
#[serde(default)]
575-
error: bool,
576-
#[serde(default)]
577-
reason: ApiError,
578-
}
579-
580-
#[derive(Deserialize, Default, Debug)]
581-
#[serde(transparent)]
582-
struct ApiError(Option<String>);
583-
584-
impl fmt::Display for ApiError {
585-
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
586-
f.write_str(self.0.as_deref().unwrap_or("Unknown Error"))
587-
}
588-
}
589-
impl StdError for ApiError {}
590-
591-
let response: ApiResponse = REQWEST_CLIENT
592-
.get(IP_API_URL)
593-
.send()
594-
.await
595-
.error("Failed during request for current location")?
596-
.json()
597-
.await
598-
.error("Failed while parsing location API result")?;
599-
600-
let location = if response.error {
601-
return Err(Error {
602-
message: Some("ipapi.co error".into()),
603-
cause: Some(Arc::new(response.reason)),
604-
});
605-
} else {
606-
response
607-
.location
608-
.error("Failed while parsing location API result")?
609-
};
610-
611-
{
612-
let mut guard = LAST_AUTOLOCATE.lock().unwrap();
613-
*guard = Some(AutolocateResult {
614-
location: location.clone(),
615-
timestamp: Instant::now(),
616-
});
617-
}
618-
619-
Ok(location)
620-
}
621-
622539
// Convert wind direction in azimuth degrees to abbreviation names
623540
fn convert_wind_direction(direction_opt: Option<f64>) -> &'static str {
624541
match direction_opt {

src/blocks/weather/met_no.rs

+3-3
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,10 @@ const FORECAST_URL: &str = "https://api.met.no/weatherapi/locationforecast/2.0/c
178178
impl WeatherProvider for Service<'_> {
179179
async fn get_weather(
180180
&self,
181-
location: Option<&Coordinates>,
181+
autolocated: Option<&IPAddressInfo>,
182182
need_forecast: bool,
183183
) -> Result<WeatherResult> {
184-
let (lat, lon) = location
184+
let (lat, lon) = autolocated
185185
.as_ref()
186186
.map(|loc| (loc.latitude.to_string(), loc.longitude.to_string()))
187187
.or_else(|| self.config.coordinates.clone())
@@ -217,7 +217,7 @@ impl WeatherProvider for Service<'_> {
217217
.error("Forecast request failed")?;
218218

219219
let forecast_hours = self.config.forecast_hours;
220-
let location_name = location.map_or("Unknown".to_string(), |c| c.city.clone());
220+
let location_name = autolocated.map_or("Unknown".to_string(), |c| c.city.clone());
221221

222222
let current_weather = data.properties.timeseries.first().unwrap().to_moment(self);
223223

src/blocks/weather/nws.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ impl ApiForecast {
243243
impl WeatherProvider for Service<'_> {
244244
async fn get_weather(
245245
&self,
246-
autolocated: Option<&Coordinates>,
246+
autolocated: Option<&IPAddressInfo>,
247247
need_forecast: bool,
248248
) -> Result<WeatherResult> {
249249
let location = if let Some(coords) = autolocated {

src/blocks/weather/open_weather_map.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ struct CityCoord {
244244
impl WeatherProvider for Service<'_> {
245245
async fn get_weather(
246246
&self,
247-
autolocated: Option<&Coordinates>,
247+
autolocated: Option<&IPAddressInfo>,
248248
need_forecast: bool,
249249
) -> Result<WeatherResult> {
250250
let location_query = autolocated

0 commit comments

Comments
 (0)