aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/ascii_art.rs186
-rw-r--r--src/logitech.rs182
-rw-r--r--src/main.rs43
-rw-r--r--src/tray.rs333
4 files changed, 744 insertions, 0 deletions
diff --git a/src/ascii_art.rs b/src/ascii_art.rs
new file mode 100644
index 0000000..b9a9fad
--- /dev/null
+++ b/src/ascii_art.rs
@@ -0,0 +1,186 @@
+// This file is part of elem <https://github.com/Fuwn/elem>.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+//
+// Copyright (C) 2022-2022 Fuwn <[email protected]>
+// SPDX-License-Identifier: GPL-3.0-only
+
+/// ASCII lettering from <http://www.patorjk.com/software/taag/#p=display&f=ANSI%20Regular&t=Type%20Something%20>
+
+pub const HEIGHT: usize = 5;
+const ONE: &str = r#" ██
+███
+ ██
+ ██
+ ██ "#;
+const TWO: &str = r#"██████
+ ██
+ █████
+██
+███████ "#;
+const THREE: &str = r#"██████
+ ██
+ █████
+ ██
+██████ "#;
+const FOUR: &str = r#"██ ██
+██ ██
+███████
+ ██
+ ██ "#;
+const FIVE: &str = r#"███████
+██
+███████
+ ██
+███████ "#;
+const SIX: &str = r#" ██████
+██
+███████
+██ ██
+ ██████ "#;
+const SEVEN: &str = r#"███████
+ ██
+ ██
+ ██
+ ██ "#;
+const EIGHT: &str = r#" █████
+██ ██
+ █████
+██ ██
+ █████ "#;
+const NINE: &str = r#" █████
+██ ██
+ ██████
+ ██
+ █████ "#;
+const ZERO: &str = r#" ██████
+██ ████
+██ ██ ██
+████ ██
+ ██████ "#;
+const QUESTION_MARK: &str = r#"██████
+ ██
+ ▄███
+ ▀▀
+ ██ "#;
+const ELLIPSIS: &str = r#"
+
+
+
+██ ██ ██ "#;
+const SMILEY_FACE: &str = r#" ██
+██ ██
+ ██
+██ ██
+ ██ "#;
+
+/// Convert a number to ASCII art
+fn number_to_art(number: u64) -> String {
+ // Used for when an error occurs
+ if number == 1337 {
+ return QUESTION_MARK.to_string();
+ } else if number == 80085 {
+ // Used for when a background process is running
+ return ELLIPSIS.to_string();
+ } else if number == 43770 {
+ // The battery level display for the dummy process
+ return SMILEY_FACE.to_string();
+ }
+
+ let to_art_digit = |number: u64| match number {
+ 0 => ZERO,
+ 1 => ONE,
+ 2 => TWO,
+ 3 => THREE,
+ 4 => FOUR,
+ 5 => FIVE,
+ 6 => SIX,
+ 7 => SEVEN,
+ 8 => EIGHT,
+ 9 => NINE,
+ _ => unreachable!(),
+ };
+ let mut art = String::new();
+
+ // Splitting the number into its individual digits, then convert each digit to
+ // it's ASCII art representation
+ for i in 0..HEIGHT {
+ for digit in number
+ .to_string()
+ .chars()
+ .map(|c| {
+ u64::from(c.to_digit(10).expect(
+ "couldn't convert character to digit, this should never happen",
+ ))
+ })
+ .collect::<Vec<_>>()
+ {
+ art.push_str(
+ to_art_digit(digit)
+ .lines()
+ .nth(i)
+ .expect("invalid line from digit art, this should never happen"),
+ );
+ }
+
+ art.push('\n');
+ }
+
+ // Removing the last newline to get rid of the last empty line
+ art.pop();
+
+ art
+}
+
+pub fn number_to_image(number: u64) -> Vec<u8> {
+ let art = number_to_art(number);
+ let mut image = vec![];
+
+ // Iterating over each character in the ASCII art, and converting it into a
+ // transparent or filled pixel
+ for pixel in art
+ // Replacing these characters with different characters isn't at all needed
+ // (except for the newline character), but it makes the following
+ // process a little more readable.
+ .replace(' ', "a")
+ .replace('█', "b")
+ .replace('\n', "")
+ .as_bytes()
+ {
+ if pixel == &97 {
+ // A transparent pixel
+ image.push(0);
+ image.push(0);
+ image.push(0);
+ image.push(0);
+ } else if pixel == &98 {
+ // A solid white pixel
+ image.push(255u8);
+ image.push(255u8);
+ image.push(255u8);
+ image.push(255u8);
+ } else {
+ unreachable!();
+ }
+ }
+
+ // Create an image from the pixel data
+ lodepng::encode_memory(
+ &image,
+ art.lines().next().unwrap().chars().count(),
+ HEIGHT,
+ lodepng::ColorType::RGBA,
+ 8,
+ )
+ .unwrap_or_else(|_| panic!("unable to encode digit {number}"))
+}
diff --git a/src/logitech.rs b/src/logitech.rs
new file mode 100644
index 0000000..28ff45c
--- /dev/null
+++ b/src/logitech.rs
@@ -0,0 +1,182 @@
+// This file is part of elem <https://github.com/Fuwn/elem>.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+//
+// Copyright (C) 2022-2022 Fuwn <[email protected]>
+// SPDX-License-Identifier: GPL-3.0-only
+
+use std::collections::HashMap;
+
+use serde_derive::{Deserialize, Serialize};
+use tungstenite::{client::IntoClientRequest, Message};
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct DeviceInfo {
+ pub id: String,
+ #[serde(rename = "connectionType")]
+ connection_type: String,
+ #[serde(rename = "deviceType")]
+ device_type: String,
+ #[serde(rename = "displayName")]
+ pub display_name: String,
+}
+
+impl DeviceInfo {
+ pub fn new(
+ id: &str,
+ connection_type: &str,
+ device_type: &str,
+ display_name: &str,
+ ) -> Self {
+ Self {
+ id: id.to_string(),
+ connection_type: connection_type.to_string(),
+ device_type: device_type.to_string(),
+ display_name: display_name.to_string(),
+ }
+ }
+
+ pub fn from_device_info(device_info: &Self) -> Self {
+ Self {
+ id: device_info.id.clone(),
+ connection_type: device_info.connection_type.clone(),
+ device_type: device_info.device_type.clone(),
+ display_name: device_info.display_name.clone(),
+ }
+ }
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct DeviceListPayload {
+ #[serde(rename = "deviceInfos")]
+ device_infos: Vec<DeviceInfo>,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct DeviceList {
+ payload: DeviceListPayload,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct DevicePayload {
+ percentage: u64,
+}
+
+impl DevicePayload {
+ pub const fn percentage(&self) -> u64 { self.percentage }
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct Device {
+ payload: DevicePayload,
+}
+
+impl Device {
+ pub const fn payload(&self) -> &DevicePayload { &self.payload }
+}
+
+/// Create a connection to the Logitech G HUB `WebSocket` (backtick-ed because
+/// rustfmt is forcing me to)
+fn connection() -> tungstenite::WebSocket<
+ tungstenite::stream::MaybeTlsStream<std::net::TcpStream>,
+> {
+ let url = url::Url::parse("ws://localhost:9010").unwrap();
+
+ let (mut ws_stream, _) = tungstenite::connect({
+ let mut request = url.into_client_request().unwrap();
+
+ // https://github.com/snapview/tungstenite-rs/issues/279
+ // https://github.com/snapview/tungstenite-rs/issues/145#issuecomment-713581499
+ request
+ .headers_mut()
+ .insert("Sec-WebSocket-Protocol", "json".parse().unwrap());
+
+ request
+ })
+ .unwrap();
+
+ ws_stream.read_message().unwrap();
+
+ ws_stream
+}
+
+/// Get a list of only wireless devices from the Logitech G HUB `WebSocket`
+pub fn wireless_devices() -> HashMap<String, DeviceInfo> {
+ let mut stream = connection();
+
+ stream
+ .write_message(Message::binary(
+ serde_json::json!({
+ "path": "/devices/list",
+ "verb": "GET"
+ })
+ .to_string(),
+ ))
+ .unwrap();
+
+ let devices = serde_json::from_value::<DeviceList>(
+ serde_json::from_str(&stream.read_message().unwrap().into_text().unwrap())
+ .unwrap(),
+ )
+ .unwrap();
+ let wireless = devices
+ .payload
+ .device_infos
+ .iter()
+ .filter(|device_info| device_info.connection_type == "WIRELESS")
+ .map(DeviceInfo::from_device_info)
+ .collect::<Vec<DeviceInfo>>();
+ let mut mapped = HashMap::new();
+
+ for device in wireless {
+ mapped.insert(device.display_name.clone(), device);
+ }
+
+ // Adding a dummy device to the device list for testing purposes.
+ //
+ // I'm also going to keep this in because it's a nice way for the user to make
+ // sure everything is working properly.
+ mapped.insert(
+ "Dummy (Debug)".to_string(),
+ DeviceInfo::new("dummy_debug", "WIRELESS", "MOUSE", "Dummy (Debug)"),
+ );
+
+ mapped
+}
+
+/// Get the battery percentage of a specific wireless device
+pub fn device(display_name: &str) -> Device {
+ if display_name == "Dummy (Debug)" {
+ return Device {
+ payload: DevicePayload { percentage: 100 },
+ };
+ }
+
+ let mut stream = connection();
+
+ stream
+ .write_message(Message::binary(
+ serde_json::json!({
+ "path": format!("/battery/{}/state", wireless_devices().get(display_name).unwrap().id),
+ "verb": "GET"
+ })
+ .to_string(),
+ ))
+ .unwrap();
+
+ serde_json::from_value::<Device>(
+ serde_json::from_str(&stream.read_message().unwrap().into_text().unwrap())
+ .unwrap(),
+ )
+ .unwrap()
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..307ece4
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,43 @@
+// This file is part of elem <https://github.com/Fuwn/elem>.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+//
+// Copyright (C) 2022-2022 Fuwn <[email protected]>
+// SPDX-License-Identifier: GPL-3.0-only
+
+#![deny(
+ warnings,
+ nonstandard_style,
+ unused,
+ future_incompatible,
+ rust_2018_idioms,
+ unsafe_code,
+ clippy::all,
+ clippy::nursery,
+ clippy::pedantic
+)]
+#![recursion_limit = "128"]
+
+mod ascii_art;
+mod logitech;
+mod tray;
+
+#[macro_use]
+extern crate log;
+
+fn main() {
+ std::env::set_var("RUST_LOG", "elem=trace");
+ pretty_env_logger::init();
+ info!("starting elem");
+ tray::Tray::new(std::env::args().nth(1)).run();
+}
diff --git a/src/tray.rs b/src/tray.rs
new file mode 100644
index 0000000..5bd8ecc
--- /dev/null
+++ b/src/tray.rs
@@ -0,0 +1,333 @@
+// This file is part of elem <https://github.com/Fuwn/elem>.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+//
+// Copyright (C) 2022-2022 Fuwn <[email protected]>
+// SPDX-License-Identifier: GPL-3.0-only
+
+use std::sync::{Arc, Mutex};
+
+use tao::{
+ event::Event, event_loop::ControlFlow, menu, menu::CustomMenuItem,
+ system_tray, system_tray::Icon,
+};
+
+const DEFAULT_UPDATE_FREQUENCY: u64 = 60000;
+
+struct TrayInner {
+ devices: Vec<CustomMenuItem>,
+ selected_device_display_name: Option<String>,
+ update_frequency: u64,
+}
+
+pub struct Tray {
+ inner: Arc<Mutex<TrayInner>>,
+}
+
+impl Tray {
+ pub fn new(update_frequency: Option<String>) -> Self {
+ Self {
+ inner: Arc::new(Mutex::new(TrayInner {
+ devices: vec![],
+ selected_device_display_name: None,
+ update_frequency: {
+ update_frequency.map_or_else(
+ || {
+ debug!(
+ "using default update frequency of {}ms",
+ DEFAULT_UPDATE_FREQUENCY
+ );
+
+ DEFAULT_UPDATE_FREQUENCY
+ },
+ |update_frequency| match update_frequency.parse() {
+ Ok(update_frequency) => {
+ debug!(
+ "using custom update frequency of {}ms",
+ update_frequency
+ );
+
+ update_frequency
+ }
+ Err(e) => {
+ warn!(
+ "invalid update frequency, using default of {}ms: {}",
+ DEFAULT_UPDATE_FREQUENCY, e
+ );
+
+ DEFAULT_UPDATE_FREQUENCY
+ }
+ },
+ )
+ },
+ })),
+ }
+ }
+
+ /// Force an icon by bypassing the device state check.
+ ///
+ /// Useful for displaying informational icons
+ fn force_icon(code: &str) -> Icon {
+ trace!("building forced icon: {}", code);
+
+ let image = image::load_from_memory(&crate::ascii_art::number_to_image(
+ code.parse().unwrap(),
+ ))
+ .unwrap()
+ .into_rgba8();
+ let (width, height) = image.dimensions();
+ let icon = Icon::from_rgba(image.into_raw(), width, height).unwrap();
+
+ trace!("built forced icon: {}", code);
+
+ icon
+ }
+
+ /// Create a tray icon compatible icon from a devices battery level
+ fn icon(selected_device_display_name: &Option<String>) -> Icon {
+ trace!(
+ "building icon for display name: {:?}",
+ selected_device_display_name
+ );
+
+ let image = image::load_from_memory(&if selected_device_display_name
+ == &Some("43770".to_string())
+ || selected_device_display_name == &Some("Dummy (Debug)".to_string())
+ {
+ crate::ascii_art::number_to_image(43770)
+ } else {
+ crate::ascii_art::number_to_image(
+ crate::logitech::device(
+ &selected_device_display_name
+ .clone()
+ .unwrap_or_else(|| "1337".to_string()),
+ )
+ .payload()
+ .percentage(),
+ )
+ })
+ .unwrap()
+ .into_rgba8();
+ let (width, height) = image.dimensions();
+ let icon = Icon::from_rgba(image.into_raw(), width, height).unwrap();
+
+ trace!(
+ "built icon for display name: {:?}",
+ selected_device_display_name
+ );
+
+ icon
+ }
+
+ /// Checks and update the battery level of non-dummy devices
+ fn watchman(
+ icon_self: &Arc<Mutex<TrayInner>>,
+ system_tray_updater: &Arc<Mutex<system_tray::SystemTray>>,
+ ) {
+ loop {
+ std::thread::sleep(std::time::Duration::from_millis(
+ icon_self.lock().unwrap().update_frequency,
+ ));
+
+ trace!("checking for system tray icon update");
+
+ // Only refresh the tray icon (battery level) if the device is not a dummy
+ // device
+ if icon_self.lock().unwrap().selected_device_display_name
+ != Some("Dummy (Debug)".to_string())
+ {
+ // "80085" is the internal code for ellipsis. An ellipsis is displayed
+ // while the battery level is being fetched.
+ system_tray_updater
+ .lock()
+ .unwrap()
+ .set_icon(Self::force_icon("80085"));
+ system_tray_updater.lock().unwrap().set_tooltip(&format!(
+ "elem (updating {} from watchman)",
+ &icon_self
+ .lock()
+ .unwrap()
+ .selected_device_display_name
+ .clone()
+ .unwrap_or_else(|| "Dummy (Debug)".to_string())
+ ));
+
+ trace!("updating system tray icon from watchman");
+
+ let icon = Self::icon(&Some(
+ icon_self
+ .lock()
+ .unwrap()
+ .selected_device_display_name
+ .clone()
+ .unwrap_or_else(|| "Dummy (Debug)".to_string()),
+ ));
+
+ system_tray_updater.lock().unwrap().set_tooltip(&format!(
+ "elem ({})",
+ icon_self
+ .lock()
+ .unwrap()
+ .selected_device_display_name
+ .clone()
+ .unwrap_or_else(|| "Dummy (Debug)".to_string()),
+ ));
+ system_tray_updater.lock().unwrap().set_icon(icon);
+ trace!("updated system tray icon",);
+ }
+ }
+ }
+
+ /// Run the tray icon and event loop
+ #[allow(clippy::too_many_lines)]
+ pub fn run(&mut self) {
+ let local_self = self.inner.clone();
+ // Grab all wireless devices
+ let devices = crate::logitech::wireless_devices();
+ // Set up the event loop and tray icon-related stuff
+ let event_loop = tao::event_loop::EventLoop::new();
+ let main_tray_id = tao::TrayId::new("main-tray");
+ let mut tray_menu = menu::ContextMenu::new();
+
+ tray_menu.add_item(
+ menu::MenuItemAttributes::new(&format!(
+ "Update frequency: {}ms",
+ local_self.lock().unwrap().update_frequency
+ ))
+ .with_enabled(false),
+ );
+
+ // Adding all wireless devices to the tray icons devices menu
+ tray_menu.add_submenu("Devices", true, {
+ let mut menu = menu::ContextMenu::new();
+ let mut devices = devices
+ .values()
+ .collect::<Vec<&crate::logitech::DeviceInfo>>();
+
+ // Making sure that the last device, the default device, is never the
+ // dummy device
+ if devices.last().unwrap().display_name == "Dummy (Debug)" {
+ let last = devices.pop().unwrap();
+
+ devices.insert(0, last);
+ }
+
+ local_self.lock().unwrap().devices.clear();
+
+ for (i, device_info) in devices.iter().enumerate() {
+ let mut id = menu
+ .add_item(menu::MenuItemAttributes::new(&device_info.display_name));
+
+ if i == devices.len() - 1 {
+ id.set_selected(true);
+
+ local_self.lock().unwrap().selected_device_display_name =
+ Some(device_info.display_name.to_string());
+ }
+
+ local_self.lock().unwrap().devices.push(id);
+ }
+
+ menu
+ });
+
+ let quit = tray_menu.add_item(menu::MenuItemAttributes::new("Quit"));
+ let system_tray = Arc::new(Mutex::new(
+ system_tray::SystemTrayBuilder::new(
+ Self::icon(&local_self.lock().unwrap().selected_device_display_name),
+ Some(tray_menu),
+ )
+ .with_id(main_tray_id)
+ .with_tooltip("elem")
+ .build(&event_loop)
+ .unwrap(),
+ ));
+ let mut devices = local_self.lock().unwrap().devices.clone();
+ let icon_self = self.inner.clone();
+ let system_tray_updater = system_tray.clone();
+
+ // An thread which updates the tray icon (battery level) every minute
+ std::thread::spawn(move || {
+ Self::watchman(&icon_self, &system_tray_updater);
+ });
+
+ // The event loop which takes care of switching devices, handling menu
+ // events, and updating the device icon (battery level)
+ event_loop.run(move |event, _event_loop, control_flow| {
+ *control_flow = ControlFlow::Wait;
+
+ if let Event::MenuEvent {
+ menu_id,
+ origin: menu::MenuType::ContextMenu,
+ ..
+ } = event
+ {
+ if menu_id == quit.clone().id() {
+ info!("quitting");
+
+ *control_flow = ControlFlow::Exit;
+ }
+
+ // Checking to see if a new device was selected
+ //
+ // If a new device was selected, update the icon and update the menu
+ // accordingly.
+ if devices.iter().any(|d| d.clone().id() == menu_id) {
+ for device in &mut devices {
+ if menu_id == device.clone().id() {
+ debug!("selected device: {}", device.clone().title());
+ device.set_selected(true);
+ // Ellipsis icon to indicate background process
+ system_tray
+ .lock()
+ .unwrap()
+ .set_icon(Self::force_icon("80085"));
+ trace!("updating system tray icon from intent");
+
+ // If the selected device is the dummy device, set a dummy icon
+ if device.0.title() == "Dummy (Debug)" {
+ system_tray
+ .lock()
+ .unwrap()
+ .set_icon(Self::force_icon("43770"));
+ } else {
+ system_tray
+ .lock()
+ .unwrap()
+ .set_icon(Self::icon(&Some(device.0.title())));
+ }
+
+ trace!("updated system tray icon from intent");
+ system_tray.lock().unwrap().set_tooltip(&format!(
+ "elem (updating {} from intent)",
+ device.0.title()
+ ));
+ local_self.lock().unwrap().selected_device_display_name =
+ Some(device.0.title());
+ system_tray
+ .lock()
+ .unwrap()
+ .set_tooltip(&format!("elem ({})", device.0.title()));
+ info!(
+ "completed device selection ({}) and associated tasks",
+ device.0.title()
+ );
+ } else {
+ device.set_selected(false);
+ }
+ }
+ }
+ }
+ });
+ }
+}