Skip to content

Create block to display external IP address information #1366

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

Merged
merged 6 commits into from
Dec 22, 2021
Merged
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
69 changes: 69 additions & 0 deletions doc/blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ You may find that the block you desire is not in the list below. In that case, f
- [Custom DBus](#custom-dbus)
- [Disk Space](#disk-space)
- [Docker](#docker)
- [ExternalIP](#external-ip)
- [Focused Window](#focused-window)
- [GitHub](#github)
- [Hueshift](#hueshift)
Expand Down Expand Up @@ -649,6 +650,74 @@ Key | Value | Type

###### [↥ back to top](#list-of-available-blocks)

## ExternalIP

Creates a block which displays the external IP address and various information about it.

#### Examples

```toml
[[block]]
block = "externalip"
format = "{ip} {country_code}"
```

#### Options

Key | Values | Required | Default
----|--------|----------|--------
`format` | A string to customise the output of this block. See below for available placeholders. Text may need to be escaped, refer to [Escaping Text](#escaping-text). | No | `"{address} {country_flag}"`
`refresh_interval_success` | Interval in seconds for automatic updates when the previous update was successful | No | 300
`refresh_interval_failure` | Interval in seconds for automatic updates when the previous update failed | No | 15
`with_network_manager` | If 'true', listen for NetworkManager events and update the IP immediately if there was a change | No | "true"

#### Available Format Keys

Key | Value | Type
-----|-------|-----
`{ip}` | The external IP address, as seen from a remote server | String
`{version}` | IPv4 or IPv6 | String
`{city}` | City name, such as "San Francisco" | Integer
`{region}` | Region name, such as "California" | String
`{region_code}` | Region code, such as "CA" for California | String
`{country}` | Country code (2 letter, ISO 3166-1 alpha-2) | String
`{country_name}` | Short country name | String
`{country_code}` | Country code (2 letter, ISO 3166-1 alpha-2) | String
`{country_code_iso3}` | Country code (3 letter, ISO 3166-1 alpha-3) | String
`{country_capital}` | Capital of the country | String
`{country_tld}` | Country specific TLD (top-level domain) | String
`{continent_code}` | Continent code | String
`{in_eu}` | Region code, such as "CA" | String
`{postal}` | ZIP / Postal code | String
`{latitude}` | Latitude | Float
`{longitude}` | Longitude | Float
`{timezone}` | City | String
`{utc_offset}` | UTC offset (with daylight saving time) as +HHMM or -HHMM (HH is hours, MM is minutes) | String
`{country_calling_code}` | Country calling code (dial in code, comma separated) | String
`{currency}` | Currency code (ISO 4217) | String
`{currency_name}` | Currency name | String
`{languages}` | Languages spoken (comma separated 2 or 3 letter ISO 639 code with optional hyphen separated country suffix) | String
`{country_area}` | Area of the country (in sq km) | Float
`{country_population}` | Population of the country | Float
`{timezone}` | Time zone | String
`{org}` | Organization | String
`{asn}` | Autonomous system (AS) | String
`{country_flag}` | Flag of the country | String (glyph)

##### Notes
All the information comes from https://ipapi.co/json/
Check their documentation here: https://ipapi.co/api/#complete-location5

The IP is queried, 1) When i3status-rs starts, 2) When a signal is received
on D-Bus about a network configuration change, 3) Every 5 minutes. This
periodic refresh exists to catch IP updates that don't trigger a notification,
for example due to a IP refresh at the router.

Flags: They are not icons but unicode glyphs. You will need a font that
includes them. Tested with: https://www.babelstone.co.uk/Fonts/Flags.html

###### [↥ back to top](#list-of-available-blocks)

## Focused Window

Creates a block which displays the title or the active marks of the currently focused window. Uses push updates from i3 IPC, so no need to worry about resource usage. The block only updates when the focused window changes title or the focus changes. Also works with sway, due to it having compatibility with i3's IPC.
Expand Down
3 changes: 3 additions & 0 deletions src/blocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod custom_dbus;
pub mod disk_space;
pub mod dnf;
pub mod docker;
pub mod external_ip;
pub mod focused_window;
pub mod github;
pub mod hueshift;
Expand Down Expand Up @@ -52,6 +53,7 @@ use self::custom_dbus::*;
use self::disk_space::*;
use self::dnf::*;
use self::docker::*;
use self::external_ip::*;
use self::focused_window::*;
use self::github::*;
use self::hueshift::*;
Expand Down Expand Up @@ -234,6 +236,7 @@ pub fn create_block(
"disk_space" => block!(DiskSpace, id, block_config, shared_config, update_request),
"dnf" => block!(Dnf, id, block_config, shared_config, update_request),
"docker" => block!(Docker, id, block_config, shared_config, update_request), ///////
"external_ip" => block!(ExternalIP, id, block_config, shared_config, update_request),
"focused_window" => block!(
FocusedWindow,
id,
Expand Down
233 changes: 233 additions & 0 deletions src/blocks/external_ip.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
use std::thread;
use std::time::Instant;

use crossbeam_channel::Sender;
use dbus::ffidisp::{BusType, Connection, ConnectionItem};
use serde::{Deserialize as des, Serialize as ser};
use serde_derive::Deserialize;

use crate::blocks::{Block, ConfigBlock, Update};
use crate::config::SharedConfig;
use crate::errors::*;
use crate::formatting::value::Value;
use crate::formatting::FormatTemplate;
use crate::http;
use crate::scheduler::Task;
use crate::util::country_flag_from_iso_code;
use crate::widgets::text::TextWidget;
use crate::widgets::{I3BarWidget, State};
use crate::Duration;

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

#[derive(ser, des, 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,
}

pub struct ExternalIP {
id: usize,
output: TextWidget,
format: FormatTemplate,
refresh_interval_success: u64,
refresh_interval_failure: u64,
}

#[derive(Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields, default)]
pub struct ExternalIPConfig {
/// External IP formatter.
pub format: FormatTemplate,
pub refresh_interval_success: u64,
pub refresh_interval_failure: u64,
pub with_network_manager: bool,
}

impl Default for ExternalIPConfig {
fn default() -> Self {
Self {
format: FormatTemplate::default(),
refresh_interval_success: 300,
refresh_interval_failure: 15,
with_network_manager: true,
}
}
}

impl ConfigBlock for ExternalIP {
type Config = ExternalIPConfig;

fn new(
id: usize,
block_config: Self::Config,
shared_config: SharedConfig,
send: Sender<Task>,
) -> Result<Self> {
if block_config.with_network_manager {
thread::Builder::new()
.name("externalip".into())
.spawn(move || {
let c = Connection::get_private(BusType::System).unwrap();
c.add_match(
"type='signal',\
path='/org/freedesktop/NetworkManager',\
interface='org.freedesktop.DBus.Properties',\
member='PropertiesChanged'",
)
.unwrap();
c.add_match(
"type='signal',\
path_namespace='/org/freedesktop/NetworkManager/ActiveConnection',\
interface='org.freedesktop.DBus.Properties',\
member='PropertiesChanged'",
)
.unwrap();
c.add_match(
"type='signal',\
path_namespace='/org/freedesktop/NetworkManager/IP4Config',\
interface='org.freedesktop.DBus',\
member='PropertiesChanged'",
)
.unwrap();

loop {
let timeout = 300_000;

for event in c.iter(timeout) {
match event {
ConnectionItem::Nothing => (),
_ => {
send.send(Task {
id,
update_time: Instant::now(),
})
.unwrap();
}
}
}
}
})
.unwrap();
}
Ok(ExternalIP {
id,
output: TextWidget::new(id, 0, shared_config),
format: block_config.format.with_default("{ip} {country_flag}")?,
refresh_interval_success: block_config.refresh_interval_success,
refresh_interval_failure: block_config.refresh_interval_failure,
})
}
}

impl Block for ExternalIP {
fn id(&self) -> usize {
self.id
}

fn update(&mut self) -> Result<Option<Update>> {
let (external_ip, success) = {
let ip_info: Result<IPAddressInfo> =
match http::http_get_json(API_ENDPOINT, Some(Duration::from_secs(3)), vec![]) {
Ok(ip_info_json) => serde_json::from_value(ip_info_json.content)
.block_error(BLOCK_NAME, "Failed to decode JSON"),
_ => Err(BlockError(
BLOCK_NAME.to_string(),
"Failed to contact API".to_string(),
)),
};
match ip_info {
Ok(ip_info) => match ip_info.error {
false => {
self.output.set_state(State::Idle);
let flag = country_flag_from_iso_code(ip_info.country_code.as_str());
let values = map!(
"ip" => Value::from_string (ip_info.ip),
"version" => Value::from_string (ip_info.version),
"city" => Value::from_string (ip_info.city),
"region" => Value::from_string (ip_info.region),
"region_code" => Value::from_string (ip_info.region_code),
"country" => Value::from_string (ip_info.country),
"country_name" => Value::from_string (ip_info.country_name),
"country_code" => Value::from_string (ip_info.country_code),
"country_code_iso3" => Value::from_string (ip_info.country_code_iso3),
"country_capital" => Value::from_string (ip_info.country_capital),
"country_tld" => Value::from_string (ip_info.country_tld),
"continent_code" => Value::from_string (ip_info.continent_code),
"in_eu" => Value::from_boolean (ip_info.in_eu),
"postal" => Value::from_string (ip_info.postal.unwrap_or_else(|| "No postal code".to_string())),
"latitude" => Value::from_float (ip_info.latitude),
"longitude" => Value::from_float (ip_info.longitude),
"timezone" => Value::from_string (ip_info.timezone),
"utc_offset" => Value::from_string (ip_info.utc_offset),
"country_calling_code" => Value::from_string (ip_info.country_calling_code),
"currency" => Value::from_string (ip_info.currency),
"currency_name" => Value::from_string (ip_info.currency_name),
"languages" => Value::from_string (ip_info.languages),
"country_area" => Value::from_float (ip_info.country_area),
"country_population" => Value::from_float (ip_info.country_population),
"asn" => Value::from_string (ip_info.asn),
"org" => Value::from_string (ip_info.org),
"country_flag" => Value::from_string(flag),
);
let s = self.format.render(&values)?;
(s.0, true)
}
true => {
self.output.set_state(State::Critical);
(format!("Error: {}", ip_info.reason), false)
}
},
Err(err) => {
self.output.set_state(State::Critical);
(err.to_string(), false)
}
}
};

self.output.set_text(external_ip);
match success {
/* The external IP address can change without triggering a
* notification (for example a refresh between the router and
* the ISP) so check from time to time even on success */
true => Ok(Some(
Duration::from_secs(self.refresh_interval_success).into(),
)),
false => Ok(Some(
Duration::from_secs(self.refresh_interval_failure).into(),
)),
}
}

fn view(&self) -> Vec<&dyn I3BarWidget> {
vec![&self.output]
}
}
5 changes: 5 additions & 0 deletions src/formatting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ impl FormatTemplate {
Self::format_contains(&self.full, var) || Self::format_contains(&self.short, var)
}

pub fn has_tokens(&self) -> bool {
!self.full.as_ref().map(Vec::is_empty).unwrap_or(true)
|| !self.short.as_ref().map(Vec::is_empty).unwrap_or(true)
}

fn format_contains(format: &Option<Vec<Token>>, var: &str) -> bool {
if let Some(tokens) = format {
for token in tokens {
Expand Down
13 changes: 13 additions & 0 deletions src/formatting/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum InternalValue {
Text(String),
Integer(i64),
Float(f64),
Boolean(bool),
}

fn format_number(
Expand Down Expand Up @@ -150,6 +151,14 @@ impl Value {
value: InternalValue::Float(value),
}
}
pub fn from_boolean(value: bool) -> Self {
Self {
icon: None,
min_width: 2,
unit: Unit::None,
value: InternalValue::Boolean(value),
}
}

// Set options
pub fn icon(mut self, icon: String) -> Self {
Expand Down Expand Up @@ -248,6 +257,10 @@ impl Value {
// Apply engineering notation (Float-only)
format_number(value, min_width, var.min_prefix, unit, pad_with)
}
InternalValue::Boolean(value) => match value {
true => String::from("T"),
false => String::from("F"),
},
};

// We prepend the resulting string with the icon if it is set
Expand Down
Loading