diff options
| author | Fuwn <[email protected]> | 2022-12-29 08:42:17 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2022-12-29 08:42:17 +0000 |
| commit | 4742461cc8dcaca3f45a2da914fc66dad3fc2c5f (patch) | |
| tree | 34fa6d1d75253226b884fa278a969f01cccdd7f8 /src | |
| download | elem-4742461cc8dcaca3f45a2da914fc66dad3fc2c5f.tar.xz elem-4742461cc8dcaca3f45a2da914fc66dad3fc2c5f.zip | |
feat: initial releasev0.1.0
Diffstat (limited to 'src')
| -rw-r--r-- | src/ascii_art.rs | 186 | ||||
| -rw-r--r-- | src/logitech.rs | 182 | ||||
| -rw-r--r-- | src/main.rs | 43 | ||||
| -rw-r--r-- | src/tray.rs | 333 |
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); + } + } + } + } + }); + } +} |