Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add global geolocator #2137

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,14 @@ short_error_message | The short error message, if available
- [Blocks](https://docs.rs/i3status-rs/latest/i3status_rs/blocks/index.html)
- [Formatting](https://docs.rs/i3status-rs/latest/i3status_rs/formatting/index.html)
- [Themes and Icons](https://github.com/greshake/i3status-rust/blob/v0.32.0/doc/themes.md)
- [Geolocator](https://docs.rs/i3status-rs/latest/i3status_rs/geolocator/index.html)

#### Master

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

## Integrate it into i3/sway

Expand Down
15 changes: 15 additions & 0 deletions src/blocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ use std::time::Duration;

use crate::click::MouseButton;
use crate::errors::*;
use crate::geolocator::{Geolocator, IPAddressInfo};
use crate::widget::Widget;
use crate::{BoxedFuture, Request, RequestCmd};

Expand Down Expand Up @@ -208,6 +209,7 @@ pub struct CommonApi {
pub(crate) update_request: Arc<Notify>,
pub(crate) request_sender: mpsc::UnboundedSender<Request>,
pub(crate) error_interval: Duration,
pub(crate) geolocator: Arc<Geolocator>,
}

impl CommonApi {
Expand Down Expand Up @@ -267,4 +269,17 @@ impl CommonApi {
pub async fn wait_for_update_request(&self) {
self.update_request.notified().await;
}

fn locator_name(&self) -> Cow<'static, str> {
self.geolocator.name()
}

/// No-op if last API call was made in the last `interval` seconds.
pub async fn find_ip_location(
&self,
client: &reqwest::Client,
interval: Duration,
) -> Result<IPAddressInfo> {
self.geolocator.find_ip_location(client, interval).await
}
}
140 changes: 61 additions & 79 deletions src/blocks/external_ip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,6 @@ use zbus::MatchRule;
use super::prelude::*;
use crate::util::{country_flag_from_iso_code, new_system_dbus_connection};

const API_ENDPOINT: &str = "https://ipapi.co/json/";

#[derive(Deserialize, Debug, SmartDefault)]
#[serde(deny_unknown_fields, default)]
pub struct Config {
Expand Down Expand Up @@ -137,40 +135,75 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
};

loop {
let fetch_info = || IPAddressInfo::new(client);
let fetch_info = || api.find_ip_location(client, Duration::from_secs(0));
let info = fetch_info.retry(ExponentialBuilder::default()).await?;

let mut values = map! {
"ip" => Value::text(info.ip),
"version" => Value::text(info.version),
"city" => Value::text(info.city),
"region" => Value::text(info.region),
"region_code" => Value::text(info.region_code),
"country" => Value::text(info.country),
"country_name" => Value::text(info.country_name),
"country_flag" => Value::text(country_flag_from_iso_code(&info.country_code)),
"country_code" => Value::text(info.country_code),
"country_code_iso3" => Value::text(info.country_code_iso3),
"country_capital" => Value::text(info.country_capital),
"country_tld" => Value::text(info.country_tld),
"continent_code" => Value::text(info.continent_code),
"latitude" => Value::number(info.latitude),
"longitude" => Value::number(info.longitude),
"timezone" => Value::text(info.timezone),
"utc_offset" => Value::text(info.utc_offset),
"country_calling_code" => Value::text(info.country_calling_code),
"currency" => Value::text(info.currency),
"currency_name" => Value::text(info.currency_name),
"languages" => Value::text(info.languages),
"country_area" => Value::number(info.country_area),
"country_population" => Value::number(info.country_population),
"asn" => Value::text(info.asn),
"org" => Value::text(info.org),
};
info.postal
.map(|x| values.insert("postal".into(), Value::text(x)));
if info.in_eu {
values.insert("in_eu".into(), Value::flag());

macro_rules! map_push_if_some { ($($key:ident: $type:ident),* $(,)?) => {
$({
let key = stringify!($key);
if let Some(value) = info.$key {
values.insert(key.into(), Value::$type(value));
} else if format.contains_key(key) {
return Err(Error::new(format!(
"The format string contains '{key}', but the {key} field is not provided by {} (an api key may be required)",
api.locator_name()
)));
}
})*
} }

map_push_if_some!(
version: text,
region: text,
region_code: text,
country: text,
country_name: text,
country_code_iso3: text,
country_capital: text,
country_tld: text,
continent_code: text,
postal: text,
timezone: text,
utc_offset: text,
country_calling_code: text,
currency: text,
currency_name: text,
languages: text,
country_area: number,
country_population: number,
asn: text,
org: text,
);

if let Some(country_code) = info.country_code {
values.insert(
"country_flag".into(),
Value::text(country_flag_from_iso_code(&country_code)),
);
values.insert("country_code".into(), Value::text(country_code));
} else if format.contains_key("country_code") || format.contains_key("country_flag") {
return Err(Error::new(format!(
"The format string contains 'country_code' or 'country_flag', but the country_code field is not provided by {}",
api.locator_name()
)));
}

if let Some(in_eu) = info.in_eu {
if in_eu {
values.insert("in_eu".into(), Value::flag());
}
} else if format.contains_key("in_eu") {
return Err(Error::new(format!(
"The format string contains 'in_eu', but the in_eu field is not provided by {}",
api.locator_name()
)));
}

let mut widget = Widget::new().with_format(format.clone());
Expand All @@ -189,54 +222,3 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
}
}
}

#[derive(Deserialize, Default)]
#[serde(default)]
struct IPAddressInfo {
error: bool,
reason: String,
ip: String,
version: String,
city: String,
region: String,
region_code: String,
country: String,
country_name: String,
country_code: String,
country_code_iso3: String,
country_capital: String,
country_tld: String,
continent_code: String,
in_eu: bool,
postal: Option<String>,
latitude: f64,
longitude: f64,
timezone: String,
utc_offset: String,
country_calling_code: String,
currency: String,
currency_name: String,
languages: String,
country_area: f64,
country_population: f64,
asn: String,
org: String,
}

impl IPAddressInfo {
async fn new(client: &reqwest::Client) -> Result<Self> {
let info: Self = client
.get(API_ENDPOINT)
.send()
.await
.error("Failed to request current location")?
.json::<Self>()
.await
.error("Failed to parse JSON")?;
if info.error {
Err(Error::new(info.reason))
} else {
Ok(info)
}
}
}
89 changes: 3 additions & 86 deletions src/blocks/weather.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,25 +141,18 @@
//! - `weather_thunder` (when weather is reported as "Thunderstorm" during the day)
//! - `weather_thunder_night` (when weather is reported as "Thunderstorm" at night)

use std::fmt;
use std::sync::{Arc, Mutex};
use std::time::Instant;

use chrono::{DateTime, Datelike as _, Utc};
use sunrise::{SolarDay, SolarEvent};

use crate::formatting::Format;
pub(super) use crate::geolocator::IPAddressInfo;

use super::prelude::*;

pub mod met_no;
pub mod nws;
pub mod open_weather_map;

const IP_API_URL: &str = "https://ipapi.co/json";

static LAST_AUTOLOCATE: Mutex<Option<AutolocateResult>> = Mutex::new(None);

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct Config {
Expand All @@ -182,7 +175,7 @@ fn default_interval() -> Seconds {
trait WeatherProvider {
async fn get_weather(
&self,
autolocated_location: Option<&Coordinates>,
autolocated_location: Option<&IPAddressInfo>,
need_forecast: bool,
) -> Result<WeatherResult>;
}
Expand Down Expand Up @@ -465,7 +458,7 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {

loop {
let location = if config.autolocate {
let fetch = || find_ip_location(autolocate_interval.0);
let fetch = || api.find_ip_location(&REQWEST_CLIENT, autolocate_interval.0);
Some(fetch.retry(ExponentialBuilder::default()).await?)
} else {
None
Expand Down Expand Up @@ -545,82 +538,6 @@ enum UnitSystem {
Imperial,
}

#[derive(Deserialize, Clone)]
struct Coordinates {
latitude: f64,
longitude: f64,
city: String,
}

struct AutolocateResult {
location: Coordinates,
timestamp: Instant,
}

// TODO: might be good to allow for different geolocation services to be used, similar to how we have `service` for the weather API
/// No-op if last API call was made in the last `interval` seconds.
async fn find_ip_location(interval: Duration) -> Result<Coordinates> {
{
let guard = LAST_AUTOLOCATE.lock().unwrap();
if let Some(cached) = &*guard {
if cached.timestamp.elapsed() < interval {
return Ok(cached.location.clone());
}
}
}

#[derive(Deserialize)]
struct ApiResponse {
#[serde(flatten)]
location: Option<Coordinates>,
#[serde(default)]
error: bool,
#[serde(default)]
reason: ApiError,
}

#[derive(Deserialize, Default, Debug)]
#[serde(transparent)]
struct ApiError(Option<String>);

impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0.as_deref().unwrap_or("Unknown Error"))
}
}
impl StdError for ApiError {}

let response: ApiResponse = REQWEST_CLIENT
.get(IP_API_URL)
.send()
.await
.error("Failed during request for current location")?
.json()
.await
.error("Failed while parsing location API result")?;

let location = if response.error {
return Err(Error {
message: Some("ipapi.co error".into()),
cause: Some(Arc::new(response.reason)),
});
} else {
response
.location
.error("Failed while parsing location API result")?
};

{
let mut guard = LAST_AUTOLOCATE.lock().unwrap();
*guard = Some(AutolocateResult {
location: location.clone(),
timestamp: Instant::now(),
});
}

Ok(location)
}

// Convert wind direction in azimuth degrees to abbreviation names
fn convert_wind_direction(direction_opt: Option<f64>) -> &'static str {
match direction_opt {
Expand Down
6 changes: 3 additions & 3 deletions src/blocks/weather/met_no.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,10 @@ const FORECAST_URL: &str = "https://api.met.no/weatherapi/locationforecast/2.0/c
impl WeatherProvider for Service<'_> {
async fn get_weather(
&self,
location: Option<&Coordinates>,
autolocated: Option<&IPAddressInfo>,
need_forecast: bool,
) -> Result<WeatherResult> {
let (lat, lon) = location
let (lat, lon) = autolocated
.as_ref()
.map(|loc| (loc.latitude.to_string(), loc.longitude.to_string()))
.or_else(|| self.config.coordinates.clone())
Expand Down Expand Up @@ -217,7 +217,7 @@ impl WeatherProvider for Service<'_> {
.error("Forecast request failed")?;

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

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

Expand Down
2 changes: 1 addition & 1 deletion src/blocks/weather/nws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ impl ApiForecast {
impl WeatherProvider for Service<'_> {
async fn get_weather(
&self,
autolocated: Option<&Coordinates>,
autolocated: Option<&IPAddressInfo>,
need_forecast: bool,
) -> Result<WeatherResult> {
let location = if let Some(coords) = autolocated {
Expand Down
2 changes: 1 addition & 1 deletion src/blocks/weather/open_weather_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ struct CityCoord {
impl WeatherProvider for Service<'_> {
async fn get_weather(
&self,
autolocated: Option<&Coordinates>,
autolocated: Option<&IPAddressInfo>,
need_forecast: bool,
) -> Result<WeatherResult> {
let location_query = autolocated
Expand Down
Loading