Skip to content

Commit f51e556

Browse files
committed
Add global geolocator
1 parent 2fac1a0 commit f51e556

File tree

12 files changed

+623
-170
lines changed

12 files changed

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

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, Datelike, 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
@@ -545,82 +538,6 @@ enum UnitSystem {
545538
Imperial,
546539
}
547540

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