├── .gitignore ├── .vscode └── settings.json ├── CREDITS.md ├── Cargo.toml ├── LICENSE ├── README.md ├── apex-ctl ├── Cargo.toml └── src │ └── main.rs ├── apex-engine ├── Cargo.toml └── src │ ├── engine.rs │ └── lib.rs ├── apex-hardware ├── Cargo.toml ├── README.md └── src │ ├── device.rs │ ├── lib.rs │ └── usb.rs ├── apex-input ├── Cargo.toml └── src │ ├── hotkey.rs │ ├── input.rs │ └── lib.rs ├── apex-mpris2 ├── Cargo.toml └── src │ ├── generated.rs │ ├── lib.rs │ └── player.rs ├── apex-music ├── Cargo.toml └── src │ ├── lib.rs │ └── player.rs ├── apex-simulator ├── Cargo.toml └── src │ ├── lib.rs │ └── simulator.rs ├── apex-windows ├── Cargo.toml └── src │ ├── lib.rs │ └── music.rs ├── assets ├── btc.bmp ├── discord.bmp ├── gif_missing.gif ├── note.bmp └── pause.bmp ├── images └── sample_1.gif ├── resources ├── btc.png ├── music.png ├── simulator-btc.png ├── simulator-clock.png ├── simulator-music.png └── system-metrics.png ├── rust-toolchain.toml ├── rustfmt.toml ├── settings.toml └── src ├── dbus ├── mod.rs └── notifications.rs ├── main.rs ├── providers ├── clock.rs ├── coindesk.rs ├── image.rs ├── mod.rs ├── music.rs └── sysinfo.rs └── render ├── debug.rs ├── display.rs ├── image.rs ├── mod.rs ├── notifications.rs ├── scheduler.rs ├── stream.rs ├── text.rs └── util.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | .idea/ 16 | .env -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.cargo.features": ["default", "sysinfo"] 3 | } 4 | -------------------------------------------------------------------------------- /CREDITS.md: -------------------------------------------------------------------------------- 1 | - ./assets/note.bmp : Based on an [icon](https://freeicons.io/business-and-online-icons/music-icon-icon) by [Raj Dev](https://freeicons.io/profile/714) on [freeicons.io](https://freeicons.io) 2 | - ./assets/pause.bmp : Based on an [icon](https://freeicons.io/business-and-online-icons/pause-icon-icon-3) by [Raj Dev](https://freeicons.io/profile/714) on [freeicons.io](https://freeicons.io) 3 | 4 | 5 | https://freeicons.io/icon-list/business-and-online-icons -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "apex-tux" 3 | version = "1.0.3" 4 | edition = "2021" 5 | 6 | [workspace] 7 | 8 | members = [ 9 | "apex-ctl", 10 | "apex-hardware", 11 | "apex-mpris2", 12 | "apex-music", 13 | "apex-simulator", 14 | "apex-input", 15 | "apex-engine", 16 | "apex-windows" 17 | ] 18 | 19 | 20 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 21 | 22 | [dependencies] 23 | apex-hardware = { path = "./apex-hardware", features = ["async"] } 24 | 25 | 26 | anyhow = "1.0.45" 27 | tokio = { version = "1", features = ["time", "net", "macros", "rt-multi-thread", "sync"] } 28 | num_enum = "0.5" 29 | embedded-graphics = "0.7.1" 30 | tinybmp = "0.3.1" 31 | config = { version = "0.11.0", features = ["toml"] } 32 | futures-core = "0.3" 33 | async-stream = "0.3" 34 | futures = "0.3" 35 | linkme = "0.2" 36 | log = "0.4.14" 37 | 38 | ctrlc = "3.2.0" 39 | simplelog = "0.10.0" 40 | pin-project-lite = "0.2.7" 41 | itertools = "0.10.1" 42 | async-rwlock = "1.3.0" 43 | serde = { version = "1.0", optional = true, features = ["derive"] } 44 | 45 | serde_json = { version = "1.0", optional = true } 46 | reqwest = { version = "0.11.4", optional = true, features = ["json", "brotli", "stream", "gzip", "deflate"] } 47 | chrono = "0.4.19" 48 | toml = "0.5.8" 49 | num-traits = "0.2.14" 50 | apex-input = {path = "./apex-input" } 51 | apex-music = { path = "./apex-music" } 52 | apex-simulator = { path = "./apex-simulator", optional = true } 53 | apex-engine = { path = "./apex-engine", optional = true } 54 | sysinfo = { version = "0.27.7", optional = true } 55 | lazy_static = "1.4.0" 56 | image = { version = "0.24.6", optional = true } 57 | dirs = "5.0.1" 58 | 59 | 60 | [target.'cfg(target_os = "windows")'.dependencies] 61 | apex-windows = {path = "./apex-windows"} 62 | 63 | 64 | [target.'cfg(target_os = "linux")'.dependencies] 65 | apex-mpris2 = { path = "./apex-mpris2", optional = true } 66 | dbus = { version = "0.9", optional = true } 67 | dbus-tokio = { version = "0.7.4", optional = true } 68 | 69 | [features] 70 | default = ["dbus-support", "crypto", "usb"] 71 | dbus-support = ["dbus", "dbus-tokio", "apex-mpris2"] 72 | http = ["serde", "serde_json", "reqwest"] 73 | crypto = ["http"] 74 | simulator = ["apex-simulator"] 75 | usb = ["apex-hardware/usb"] 76 | hotkeys = ["apex-input/hotkeys"] 77 | engine = ["apex-engine"] 78 | sysinfo = ["dep:sysinfo"] 79 | image = ["dep:image"] 80 | debug = [] 81 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # apex-tux - Linux support for the Apex series OLED screens 2 | 3 | Make use of your OLED screen instead of letting the SteelSeries logo burn itself in :-) 4 | 5 | ## Screenshots 6 | 7 | ![Music Player in Simulator](./resources/simulator-music.png) 8 | ![Clock in Simulator](./resources/simulator-clock.png) 9 | ![Bitcoing exchange in Simulator](./resources/simulator-btc.png) 10 | 11 | ![Music Player in device OLED screen](./resources/music.png) 12 | ![Bitcoin exchange in device OLED screen](./resources/btc.png) 13 | ![Sysinfo in device OLED screen](./resources/system-metrics.png) 14 | 15 | ## Features 16 | 17 | - Music player integration (requires DBus) 18 | - Discord notifications (requires DBus) 19 | - Bitcoin price 20 | - Clock 21 | - System metrics 22 | - Scrolling text 23 | - No burn-in from constantly displaying a static image 24 | 25 | ## Supported media players 26 | 27 | - [Lollypop](https://gitlab.gnome.org/World/lollypop) (tested) 28 | - Firefox (Results may vary) 29 | - Chromium / Chrome (Results may vary) 30 | - mpv 31 | - Telegram 32 | - VLC 33 | - Spotify 34 | 35 | Source: [Arch Wiki](https://wiki.archlinux.org/title/MPRIS#Supported_clients) 36 | 37 | ## Supported devices 38 | 39 | This currently supports the following devices: 40 | 41 | - Apex Pro 42 | - Apex 5 43 | - Apex 7 44 | 45 | Other devices may be compatible and all that is needed is to add the ID to apex-hardware/src/usb.rs. 46 | 47 | ## Installation 48 | 49 | For installing this software, follow these steps: 50 | 51 | ### UDev 52 | 53 | 1. Get the device id: `lsusb | grep "SteelSeries Apex"`: 54 | 55 | ```shell 56 | $ lsusb | grep "SteelSeries Apex" 57 | Bus 001 Device 002: ID 1038:1610 SteelSeries ApS SteelSeries Apex Pro 58 | ``` 59 | 60 | The **id** is the right part of the ID. 61 | 62 | 2. Enter the following data from [here](https://gist.github.com/ToadKing/d26f8f046a3b707e9e4b9821be5c9efc) (Shoutout [to @ToadKing](https://github.com/ToadKing)). 63 | 64 | If those don't work and lead to an "Access denied" error please try the following rules and save the rules as `97-steelseries.rules`: 65 | 66 | ```shell 67 | cat /etc/udev/rules.d/97-steelseries.rules 68 | SUBSYSTEM=="input", GROUP="input", MODE="0666" 69 | 70 | SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="", MODE="0666", GROUP="plugdev" 71 | KERNEL=="hidraw*", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="", MODE="0666", GROUP="plugdev" 72 | ``` 73 | 74 | 1. Replace the `ATTRS{idProduct}==` value with the device **id**. 75 | 76 | 2. Save all files to `/etc/udev/rules.d/97-steelseries.rules`. 77 | 78 | 3. Finally, reload the `udev` rules: `sudo udevadm control --reload && sudo udevadm trigger` 79 | 80 | ### Rust 81 | 82 | - Install Rust **nightly** using [rustup](https://rustup.rs/) 83 | - Install required dependencies 84 | - For Ubuntu: `sudo apt install libssl-dev libdbus-1-dev libusb-1.0-0-dev` 85 | - Clone the repository: `git clone git@github.com:not-jan/apex-tux.git` 86 | - Change the directory into the repository: `cd apex-tux` 87 | - Compile the app using the features you want 88 | - If you **don't** run DBus you have to disable the dbus feature: `cargo build --release --no-default-features --features crypto,usb` 89 | - Otherwise just run `cargo build --release --features sysinfo,hotkeys,image` 90 | - If you **don't** have an Apex device around at the moment or want to develop more easily you can enable the simulator: `cargo build --release --no-default-features --features crypto,clock,dbus-support,simulator` 91 | 92 | ## Configuration 93 | 94 | The default configuration is in `settings.toml`. 95 | This repository ships with a default configuration that covers most parts and contains documentation for the important keys. 96 | The program will look for configuration first in the platform-specific `$USER_CONFIG_DIR/apex-tux/`, then in the current working directory. 97 | You can also override specific settings with `APEX_*` environment variables. 98 | 99 | You can also run the software to find errors on configuration and to decide what is the right setup you need: 100 | 101 | ```shell 102 | $ target/release/apex-tux 103 | 23:43:05 [INFO] Registering MPRIS2 display source. 104 | 23:43:05 [INFO] Registering Sysinfo display source. 105 | 23:43:05 [WARN] Couldn't find network interface `eth0` 106 | 23:43:05 [INFO] Instead, found those interfaces: 107 | 23:43:05 [INFO] lo 108 | 23:43:05 [INFO] wlp3s0 109 | 23:43:05 [INFO] enp2s0 110 | 23:43:05 [INFO] docker0 111 | 23:43:05 [WARN] Couldn't find sensor `hwmon0 CPU Temperature` 112 | 23:43:05 [INFO] Instead, found those sensors: 113 | 23:43:05 [INFO] acpitz temp1: 67°C (max: 67°C / critical: 120°C) 114 | 23:43:05 [INFO] amdgpu edge: 47°C (max: 47°C) 115 | 23:43:05 [INFO] iwlwifi_1 temp1: 39°C (max: 39°C) 116 | 23:43:05 [INFO] k10temp Tctl: 66.5°C (max: 66.5°C) 117 | 23:43:05 [INFO] nvme Composite HFM001TD3JX013N temp1: 36.85°C (max: 36.85°C / critical: 84.85°C) 118 | 23:43:05 [INFO] nvme Composite Samsung SSD 980 PRO 1TB temp1: 32.85°C (max: 32.85°C / critical: 84.85°C) 119 | 23:43:05 [INFO] nvme Sensor 1 HFM001TD3JX013N temp2: 36.85°C (max: 36.85°C) 120 | 23:43:05 [INFO] nvme Sensor 1 Samsung SSD 980 PRO 1TB temp2: 32.85°C (max: 32.85°C) 121 | 23:43:05 [INFO] nvme Sensor 2 HFM001TD3JX013N temp3: 43.85°C (max: 43.85°C) 122 | 23:43:05 [INFO] nvme Sensor 2 Samsung SSD 980 PRO 1TB temp3: 38.85°C (max: 38.85°C) 123 | 23:43:05 [INFO] Registering Clock display source. 124 | 23:43:05 [INFO] Registering Gif display source. 125 | 23:43:05 [INFO] Registering Coindesk display source. 126 | 23:43:05 [INFO] Registering DBUS notification source. 127 | 23:43:05 [INFO] Found 5 registered providers 128 | 23:43:05 [INFO] Trying to connect to DBUS with player preference: Some("spotify") 129 | 23:43:05 [INFO] Trying to connect to DBUS with player preference: Some("spotify") 130 | 23:43:05 [INFO] Connected to music player: "org.mpris.MediaPlayer2.spotify" 131 | ``` 132 | 133 | In our case we need to set a right value for the sensor(`acpitz temp1`, critical temperatured one, i.e., cpu) and the network interface(`wlp3s0`, wifi) in the `[sysinfo]` section. 134 | 135 | You can set your default media player on the `[mpris2]` section. 136 | 137 | 138 | ## Usage 139 | 140 | Simply run the binary under `target/release/apex-tux` and make sure the settings.toml is in your current directory. 141 | The output should look something like this: 142 | 143 | ```shell 144 | 23:18:14 [INFO] Registering Coindesk display source. 145 | 23:18:14 [INFO] Registering Clock display source. 146 | 23:18:14 [INFO] Registering MPRIS2 display source. 147 | 23:18:14 [INFO] Registering DBUS notification source. 148 | 23:18:14 [INFO] Found 3 registered providers 149 | 23:18:14 [INFO] Trying to connect to DBUS with player preference: Some("Lollypop") 150 | 23:18:18 [INFO] Trying to connect to DBUS with player preference: Some("Lollypop") 151 | 23:18:18 [INFO] Connected to music player: "org.mpris.MediaPlayer2.Lollypop" 152 | 23:34:01 [INFO] Ctrl + C received, shutting down! 153 | 23:34:01 [INFO] unregister hotkey ALT+SHIFT+A 154 | 23:34:01 [INFO] unregister hotkey ALT+SHIFT+D 155 | ``` 156 | 157 | You may change sources by pressing **Alt+Shift+A** or **Alt+Shift+D** (This might not work on Wayland). The simulator uses the arrow keys. 158 | 159 | ## Autostarting 160 | 161 | To start on boot the binary must be started under an interactive daemon, i.e. by your Desktop Environment. A systemd service will fail unless compiled without hotkey support. Most DEs support the following method/path but you may have to find your equivalent. 162 | 163 | -Create `apex-tux.desktop` in `~/.config/autostart` 164 | -Edit `apex-tux.desktop` to contain: 165 | ```shell 166 | [Desktop Entry] 167 | Exec=/path/to/apex-tux/apex-tux 168 | Name=apex-tux 169 | Path=/path/to/apex-tux 170 | Terminal=true 171 | Type=Application 172 | ``` 173 | 174 | ## Development 175 | 176 | If you have a feature to add or a bug to fix please feel free to open an issue or submit a pull request. 177 | 178 | ## TODO 179 | 180 | - Windows support 181 | - Test this on more than one Desktop Environment on X11 182 | - More providers 183 | - Games? 184 | - GIFs? 185 | - Change the USB crate to something async instead 186 | - Add documentation on how to add custom providers 187 | - Switch from GATs to async traits once there here 188 | - Add support for more notifications 189 | - Package this up for Debian/Arch/Flatpak etc. 190 | 191 | ## Windows support ETA, when? 192 | 193 | I've written a stub for SteelSeries Engine support on Windows, there is an [API for mediaplayer metadata](https://microsoft.github.io/windows-docs-rs/doc/windows/Media/Control/struct.GlobalSystemMediaTransportControlsSessionManager.html) but my time is kind of limited and I don't run Windows all that often. 194 | It will happen eventually but it's not a priority. 195 | 196 | ## Why nightly Rust? 197 | 198 | Way too many cool features to pass up on :D 199 | -------------------------------------------------------------------------------- /apex-ctl/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "apex-ctl" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | anyhow = "1.0.44" 10 | clap = { version = "4.0.26", features = ["derive"] } 11 | log = "0.4.14" 12 | simplelog = "0.10.2" 13 | apex-hardware = { path = "../apex-hardware", features= ["usb"] } -------------------------------------------------------------------------------- /apex-ctl/src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use apex_hardware::{Device, USBDevice}; 3 | use clap::{ArgAction, Parser, Subcommand}; 4 | use log::{info, LevelFilter}; 5 | use simplelog::{Config as LoggerConfig, SimpleLogger}; 6 | 7 | #[derive(Parser)] 8 | #[clap(version = "1.0", author = "not-jan")] 9 | struct Opts { 10 | /// A level of verbosity, and can be used multiple times 11 | #[arg(short, long, action = ArgAction::Count)] 12 | verbose: u8, 13 | #[command(subcommand)] 14 | subcmd: SubCommand, 15 | } 16 | 17 | #[derive(Subcommand)] 18 | enum SubCommand { 19 | /// Clear the OLED screen 20 | Clear, 21 | /// Fill the OLED screen 22 | Fill, 23 | } 24 | 25 | fn main() -> Result<()> { 26 | let opts: Opts = Opts::parse(); 27 | 28 | let filter = match opts.verbose { 29 | 0 => LevelFilter::Info, 30 | 1 => LevelFilter::Debug, 31 | _ => LevelFilter::Trace, 32 | }; 33 | 34 | SimpleLogger::init(filter, LoggerConfig::default())?; 35 | 36 | info!("Connecting to the USB device"); 37 | 38 | let mut device = USBDevice::try_connect()?; 39 | 40 | match opts.subcmd { 41 | SubCommand::Clear => device.clear()?, 42 | SubCommand::Fill => device.fill()?, 43 | }; 44 | 45 | Ok(()) 46 | } 47 | -------------------------------------------------------------------------------- /apex-engine/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "apex-engine" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | gamesense = { git = "https://github.com/not-jan/gamesense" } 10 | anyhow = "1" 11 | apex-hardware = { path = "../apex-hardware", features = ["async"] } 12 | tokio = {version = "1", features=["time", "net", "macros", "rt-multi-thread", "sync"]} 13 | log = "0.4.14" 14 | -------------------------------------------------------------------------------- /apex-engine/src/engine.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use apex_hardware::{AsyncDevice, FrameBuffer}; 3 | use gamesense::raw_client::{ 4 | BindGameEvent, FrameContainer, GameEvent, Heartbeat, RawGameSenseClient, RegisterGame, 5 | RemoveEvent, RemoveGame, Screen, ScreenFrameData, ScreenHandler, Sendable, 6 | }; 7 | use std::future::Future; 8 | 9 | use log::info; 10 | const GAME: &str = "APEXTUX"; 11 | const EVENT: &str = "SCREEN"; 12 | 13 | const REGISTER_GAME: RegisterGame = RegisterGame { 14 | game: GAME, 15 | display_name: Some("apex-tux"), 16 | developer: Some("not-jan"), 17 | timeout: None, 18 | }; 19 | 20 | pub const REMOVE_EVENT: RemoveEvent = RemoveEvent { 21 | game: GAME, 22 | event: EVENT, 23 | }; 24 | 25 | pub const REMOVE_GAME: RemoveGame = RemoveGame { game: GAME }; 26 | 27 | pub const HEARTBEAT: Heartbeat = Heartbeat { game: GAME }; 28 | 29 | #[derive(Debug, Clone)] 30 | pub struct Engine { 31 | client: RawGameSenseClient, 32 | } 33 | 34 | impl Engine { 35 | pub async fn new() -> Result { 36 | let client = RawGameSenseClient::new()?; 37 | 38 | info!("{}", REGISTER_GAME.send(&client).await?); 39 | 40 | let x = BindGameEvent { 41 | game: GAME, 42 | event: EVENT, 43 | min_value: None, 44 | max_value: None, 45 | icon_id: None, 46 | value_optional: Some(true), 47 | handlers: vec![ScreenHandler { 48 | device: "screened-128x40", 49 | mode: "screen", 50 | zone: "one", 51 | datas: vec![Screen { 52 | has_text: false, 53 | image_data: vec![0u8; 640], 54 | }], 55 | }], 56 | } 57 | .send(&client) 58 | .await?; 59 | info!("{}", x); 60 | 61 | Ok(Self { client }) 62 | } 63 | 64 | pub async fn heartbeat(&self) -> Result<()> { 65 | info!("{}", HEARTBEAT.send(&self.client).await?); 66 | Ok(()) 67 | } 68 | } 69 | 70 | impl AsyncDevice for Engine { 71 | type ClearResult<'a> = impl Future> + 'a; 72 | type DrawResult<'a> = impl Future> + 'a; 73 | type ShutdownResult<'a> = impl Future> + 'a; 74 | 75 | #[allow(clippy::needless_lifetimes)] 76 | fn draw<'this>(&'this mut self, display: &'this FrameBuffer) -> Self::DrawResult<'this> { 77 | async { 78 | let screen = display.framebuffer.as_raw_slice(); 79 | 80 | let event = GameEvent { 81 | game: GAME, 82 | event: EVENT, 83 | data: FrameContainer { 84 | frame: ScreenFrameData { 85 | image_128x40: Some(<&[u8; 640]>::try_from(&screen[1..641])?), 86 | ..Default::default() 87 | }, 88 | }, 89 | }; 90 | 91 | event.send(&self.client).await?; 92 | 93 | Ok(()) 94 | } 95 | } 96 | 97 | #[allow(clippy::needless_lifetimes)] 98 | fn clear<'this>(&'this mut self) -> Self::ClearResult<'this> { 99 | async { 100 | let empty = FrameBuffer::new(); 101 | self.draw(&empty).await?; 102 | Ok(()) 103 | } 104 | } 105 | 106 | #[allow(clippy::needless_lifetimes)] 107 | fn shutdown<'this>(&'this mut self) -> Self::ShutdownResult<'this> { 108 | async { 109 | info!("{}", REMOVE_EVENT.send(&self.client).await?); 110 | info!("{}", REMOVE_GAME.send(&self.client).await?); 111 | Ok(()) 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /apex-engine/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(type_alias_impl_trait, impl_trait_in_assoc_type)] 2 | mod engine; 3 | pub use engine::{Engine, HEARTBEAT, REMOVE_EVENT, REMOVE_GAME}; 4 | -------------------------------------------------------------------------------- /apex-hardware/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "apex-hardware" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [features] 9 | default = [] 10 | usb = ["hidapi"] 11 | async = [] 12 | 13 | [dependencies] 14 | anyhow = "1.0.44" 15 | bitvec = "1.0.1" 16 | embedded-graphics = "0.7.1" 17 | hidapi = { version = "1.2.6", optional = true } 18 | num_enum = "0.5.4" 19 | -------------------------------------------------------------------------------- /apex-hardware/README.md: -------------------------------------------------------------------------------- 1 | # apex-hardware 2 | 3 | This crate provides the hardware abstraction layer for the OLED screen of the Apex devices. 4 | It also exposes a trait so that different devices such as a simulator can be added. 5 | 6 | ## Adding support for more devices 7 | 8 | Currently, the following devices are supported: 9 | - Apex Pro 10 | - Apex 7 11 | 12 | If you want to add your own device you have to edit `src/usb.rs` and add the product id of your device to the `SupportedDevices` enum. 13 | 14 | ## The `async` story 15 | The building blocks to move the `Device` trait into the async world are here but none of the current implementations of Device support async (yet). 16 | There are efforts to move at least the USB crate we're using to be async as it'd also make a lot of sense from a technical standpoint. 17 | You may read about the progress [here](https://github.com/ruabmbua/hidapi-rs/issues/51). 18 | Replacing the dependency on `hidapi-rs` is also an option, but I have yet to explore it thoroughly. -------------------------------------------------------------------------------- /apex-hardware/src/device.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use bitvec::prelude::*; 3 | use embedded_graphics::{pixelcolor::BinaryColor, prelude::*}; 4 | #[cfg(feature = "async")] 5 | use std::future::Future; 6 | 7 | const FB_SIZE: usize = 40 * 128 / 8 + 2; 8 | 9 | #[derive(Copy, Clone, Debug)] 10 | pub struct FrameBuffer { 11 | /// The framebuffer with one bit value per pixel. 12 | /// Two extra bytes are added, one for the header byte `0x61` and one for a 13 | /// trailing null byte. This is done to prevent superfluous copies when 14 | /// sending the image to a display device. The implementations of 15 | /// `Drawable` and `DrawTarget` take this quirk into account. 16 | pub framebuffer: BitArray<[u8; FB_SIZE], Msb0>, 17 | } 18 | 19 | impl Default for FrameBuffer { 20 | fn default() -> Self { 21 | let mut framebuffer = BitArray::<[u8; FB_SIZE], Msb0>::ZERO; 22 | framebuffer.as_raw_mut_slice()[0] = 0x61; 23 | FrameBuffer { framebuffer } 24 | } 25 | } 26 | 27 | impl FrameBuffer { 28 | /// Initializes a new `FrameBuffer` with all pixels set to 29 | /// `BinaryColor::Off ` 30 | pub fn new() -> Self { 31 | Self::default() 32 | } 33 | } 34 | 35 | /// This trait represents a device that can receive new images to be displayed. 36 | pub trait Device { 37 | /// Sends a `FrameBuffer` to the device. 38 | fn draw(&mut self, display: &FrameBuffer) -> Result<()>; 39 | /// Convenience method for clearing the whole screen. 40 | /// Most implementations will send an empty `FrameBuffer` to `Device::draw` 41 | /// but there may be more efficient ways for some devices to implement here. 42 | fn clear(&mut self) -> Result<()>; 43 | 44 | fn shutdown(&mut self) -> Result<()>; 45 | } 46 | 47 | impl Drawable for FrameBuffer { 48 | type Color = BinaryColor; 49 | type Output = (); 50 | 51 | fn draw(&self, target: &mut D) -> Result::Error> 52 | where 53 | D: DrawTarget, 54 | { 55 | let iter = (0..5120).map(|i| { 56 | let pos = Point::new(i % 128, i / 128); 57 | 58 | Pixel( 59 | pos, 60 | if *self.framebuffer.get(i as usize + 8_usize).unwrap() { 61 | BinaryColor::On 62 | } else { 63 | BinaryColor::Off 64 | }, 65 | ) 66 | }); 67 | 68 | target.draw_iter(iter)?; 69 | 70 | Ok::::Error>(()) 71 | } 72 | } 73 | 74 | impl OriginDimensions for FrameBuffer { 75 | fn size(&self) -> Size { 76 | Size::new(128, 40) 77 | } 78 | } 79 | 80 | impl DrawTarget for FrameBuffer { 81 | type Color = BinaryColor; 82 | type Error = anyhow::Error; 83 | 84 | fn draw_iter(&mut self, pixels: I) -> Result<(), Self::Error> 85 | where 86 | I: IntoIterator>, 87 | { 88 | for Pixel(coord, color) in pixels { 89 | if let (x @ 0..=127, y @ 0..=39) = (coord.x, coord.y) { 90 | // Calculate the index in the framebuffer. 91 | let index: i32 = x + y * 128 + 8; 92 | self.framebuffer.set(index as u32 as usize, color.is_on()); 93 | } 94 | } 95 | 96 | Ok(()) 97 | } 98 | } 99 | 100 | #[cfg(feature = "async")] 101 | pub trait AsyncDevice { 102 | type DrawResult<'a>: Future> + 'a 103 | where 104 | Self: 'a; 105 | type ClearResult<'a>: Future> + 'a 106 | where 107 | Self: 'a; 108 | 109 | type ShutdownResult<'a>: Future> + 'a 110 | where 111 | Self: 'a; 112 | 113 | #[allow(clippy::needless_lifetimes)] 114 | fn draw<'this>(&'this mut self, display: &'this FrameBuffer) -> Self::DrawResult<'this>; 115 | #[allow(clippy::needless_lifetimes)] 116 | fn clear<'this>(&'this mut self) -> Self::ClearResult<'this>; 117 | #[allow(clippy::needless_lifetimes)] 118 | fn shutdown<'this>(&'this mut self) -> Self::ShutdownResult<'this>; 119 | } 120 | 121 | #[cfg(feature = "async")] 122 | impl AsyncDevice for T 123 | where 124 | T: 'static, 125 | { 126 | type ClearResult<'a> = impl Future> + 'a 127 | where 128 | Self: 'a; 129 | type DrawResult<'a> = impl Future> + 'a 130 | where 131 | Self: 'a; 132 | type ShutdownResult<'a> = impl Future> + 'a 133 | where 134 | Self: 'a; 135 | 136 | #[allow(clippy::needless_lifetimes)] 137 | fn draw<'this>(&'this mut self, display: &'this FrameBuffer) -> Self::DrawResult<'this> { 138 | let x = ::draw(self, display); 139 | async { x } 140 | } 141 | 142 | #[allow(clippy::needless_lifetimes)] 143 | fn clear<'this>(&'this mut self) -> Self::ClearResult<'this> { 144 | let x = ::clear(self); 145 | async { x } 146 | } 147 | 148 | #[allow(clippy::needless_lifetimes)] 149 | fn shutdown<'this>(&'this mut self) -> Self::ShutdownResult<'this> { 150 | let x = ::shutdown(self); 151 | async { x } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /apex-hardware/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(type_alias_impl_trait)] 2 | #![feature(impl_trait_in_assoc_type)] 3 | mod device; 4 | #[cfg(feature = "usb")] 5 | mod usb; 6 | pub use bitvec::prelude::BitVec; 7 | #[cfg(feature = "async")] 8 | pub use device::AsyncDevice; 9 | pub use device::Device; 10 | #[cfg(feature = "usb")] 11 | pub use usb::USBDevice; 12 | 13 | pub use device::FrameBuffer; 14 | -------------------------------------------------------------------------------- /apex-hardware/src/usb.rs: -------------------------------------------------------------------------------- 1 | use crate::{device::FrameBuffer, Device}; 2 | use anyhow::{anyhow, Result}; 3 | use embedded_graphics::{ 4 | pixelcolor::BinaryColor, 5 | prelude::*, 6 | primitives::{PrimitiveStyleBuilder, Rectangle, StyledDrawable}, 7 | }; 8 | use hidapi::{HidApi, HidDevice}; 9 | use num_enum::TryFromPrimitive; 10 | 11 | /// The SteelSeries vendor ID used to identify the USB devices 12 | pub static STEELSERIES_VENDOR_ID: u16 = 0x1038; 13 | 14 | #[repr(u16)] 15 | #[derive(Debug, Eq, PartialEq, TryFromPrimitive)] 16 | /// This enum contains the product IDs of currently supported devices 17 | /// If your device is not in this enum it doesn't mean that it won't work, it 18 | /// just means that no one has tried it or bothered to add it yet. 19 | enum SupportedDevice { 20 | ApexProTKL = 0x1614, 21 | // Never tested 22 | Apex7 = 0x1612, 23 | ApexPro = 0x1610, 24 | Apex7TKL = 0x1618, 25 | Apex5 = 0x161C, 26 | } 27 | 28 | pub struct USBDevice { 29 | /// An exclusive handle to the Keyboard. 30 | handle: HidDevice, 31 | } 32 | 33 | impl USBDevice { 34 | pub fn try_connect() -> Result { 35 | let api = HidApi::new()?; 36 | 37 | // Get all supported devices by SteelSeries 38 | let device = api 39 | .device_list() 40 | .find(|device| { 41 | device.vendor_id() == STEELSERIES_VENDOR_ID && 42 | SupportedDevice::try_from(device.product_id()).is_ok() && 43 | // We only care for the first interface 44 | device.interface_number() == 1 45 | }) 46 | .ok_or_else(|| anyhow!("No supported SteelSeries device found!"))?; 47 | 48 | // This requires udev rules to be setup properly. 49 | let handle = device.open_device(&api)?; 50 | 51 | Ok(Self { handle }) 52 | } 53 | 54 | pub fn fill(&mut self) -> Result<()> { 55 | let mut buffer = FrameBuffer::new(); 56 | let style = PrimitiveStyleBuilder::new() 57 | .fill_color(BinaryColor::On) 58 | .build(); 59 | Rectangle::new(Point::new(0, 0), buffer.size()).draw_styled(&style, &mut buffer)?; 60 | self.draw(&buffer)?; 61 | Ok(()) 62 | } 63 | } 64 | 65 | impl Device for USBDevice { 66 | fn draw(&mut self, display: &FrameBuffer) -> Result<()> { 67 | Ok(self 68 | .handle 69 | .send_feature_report(display.framebuffer.as_raw_slice())?) 70 | } 71 | 72 | fn clear(&mut self) -> Result<()> { 73 | let display = FrameBuffer::new(); 74 | ::draw(self, &display) 75 | } 76 | 77 | fn shutdown(&mut self) -> Result<()> { 78 | Ok(()) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /apex-input/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "apex-input" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | anyhow = { version = "1.0.45", optional = true } 10 | global-hotkey = { version = "0.2.0", optional = true } 11 | tokio = { version = "1.13.0", features = ["sync"], optional = true } 12 | 13 | [features] 14 | default = [] 15 | hotkeys = ["global-hotkey", "anyhow", "tokio"] 16 | -------------------------------------------------------------------------------- /apex-input/src/hotkey.rs: -------------------------------------------------------------------------------- 1 | use crate::Command; 2 | use anyhow::Result; 3 | use global_hotkey::{ 4 | hotkey::{Code, HotKey, Modifiers}, 5 | GlobalHotKeyEvent, GlobalHotKeyManager, 6 | }; 7 | use tokio::sync::broadcast; 8 | 9 | pub struct InputManager { 10 | _hkm: GlobalHotKeyManager, 11 | } 12 | 13 | impl InputManager { 14 | pub fn new(sender: broadcast::Sender) -> Result { 15 | let hkm = GlobalHotKeyManager::new().unwrap(); 16 | 17 | let modifiers = Some(Modifiers::ALT | Modifiers::SHIFT); 18 | 19 | let hotkey_previous = HotKey::new(modifiers, Code::KeyA); 20 | let hotkey_next = HotKey::new(modifiers, Code::KeyD); 21 | 22 | hkm.register(hotkey_previous).unwrap(); 23 | hkm.register(hotkey_next).unwrap(); 24 | 25 | let hotkey_handler = move |event: GlobalHotKeyEvent| { 26 | if event.id == hotkey_previous.id() { 27 | sender 28 | .send(Command::PreviousSource) 29 | .expect("Failed to send command!"); 30 | } else { 31 | sender 32 | .send(Command::NextSource) 33 | .expect("Failed to send command!"); 34 | } 35 | }; 36 | 37 | GlobalHotKeyEvent::set_event_handler(Some(hotkey_handler)); 38 | 39 | Ok(Self { _hkm: hkm }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apex-input/src/input.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Copy, Clone)] 2 | pub enum Command { 3 | PreviousSource, 4 | NextSource, 5 | Shutdown, 6 | } 7 | -------------------------------------------------------------------------------- /apex-input/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "hotkeys")] 2 | mod hotkey; 3 | mod input; 4 | #[cfg(feature = "hotkeys")] 5 | pub use hotkey::InputManager; 6 | pub use input::Command; 7 | -------------------------------------------------------------------------------- /apex-mpris2/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "apex-mpris2" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | dbus = "0.9" 10 | dbus-tokio = { version = "0.7" } 11 | apex-music = { path= "../apex-music" } 12 | anyhow = "1.0.44" 13 | tokio = {version = "1", features=["time", "net", "macros", "rt-multi-thread", "sync"]} 14 | async-stream = "0.3.2" 15 | futures-util = "0.3.17" 16 | futures-core = "0.3.17" 17 | -------------------------------------------------------------------------------- /apex-mpris2/src/generated.rs: -------------------------------------------------------------------------------- 1 | // This code was autogenerated with `dbus-codegen-rust -i org.mpris. -m None -c nonblock`, see https://github.com/diwic/dbus-rs 2 | #![allow( 3 | unused_imports, 4 | clippy::needless_borrow, 5 | clippy::needless_borrowed_reference 6 | )] 7 | use dbus::{self, arg, nonblock}; 8 | 9 | pub trait MediaPlayer2Player { 10 | fn next(&self) -> nonblock::MethodReply<()>; 11 | fn previous(&self) -> nonblock::MethodReply<()>; 12 | fn pause(&self) -> nonblock::MethodReply<()>; 13 | fn play_pause(&self) -> nonblock::MethodReply<()>; 14 | fn stop(&self) -> nonblock::MethodReply<()>; 15 | fn play(&self) -> nonblock::MethodReply<()>; 16 | fn seek(&self, offset: i64) -> nonblock::MethodReply<()>; 17 | fn set_position(&self, track_id: dbus::Path, position: i64) -> nonblock::MethodReply<()>; 18 | fn open_uri(&self, uri: &str) -> nonblock::MethodReply<()>; 19 | fn playback_status(&self) -> nonblock::MethodReply; 20 | fn loop_status(&self) -> nonblock::MethodReply; 21 | fn set_loop_status(&self, value: String) -> nonblock::MethodReply<()>; 22 | fn rate(&self) -> nonblock::MethodReply; 23 | fn set_rate(&self, value: f64) -> nonblock::MethodReply<()>; 24 | fn shuffle(&self) -> nonblock::MethodReply; 25 | fn set_shuffle(&self, value: bool) -> nonblock::MethodReply<()>; 26 | fn metadata(&self) -> nonblock::MethodReply; 27 | fn volume(&self) -> nonblock::MethodReply; 28 | fn set_volume(&self, value: f64) -> nonblock::MethodReply<()>; 29 | fn position(&self) -> nonblock::MethodReply; 30 | fn minimum_rate(&self) -> nonblock::MethodReply; 31 | fn maximum_rate(&self) -> nonblock::MethodReply; 32 | fn can_go_next(&self) -> nonblock::MethodReply; 33 | fn can_go_previous(&self) -> nonblock::MethodReply; 34 | fn can_play(&self) -> nonblock::MethodReply; 35 | fn can_pause(&self) -> nonblock::MethodReply; 36 | fn can_seek(&self) -> nonblock::MethodReply; 37 | fn can_control(&self) -> nonblock::MethodReply; 38 | } 39 | 40 | impl<'a, T: nonblock::NonblockReply, C: ::std::ops::Deref> MediaPlayer2Player 41 | for nonblock::Proxy<'a, C> 42 | { 43 | fn next(&self) -> nonblock::MethodReply<()> { 44 | self.method_call("org.mpris.MediaPlayer2.Player", "Next", ()) 45 | } 46 | 47 | fn previous(&self) -> nonblock::MethodReply<()> { 48 | self.method_call("org.mpris.MediaPlayer2.Player", "Previous", ()) 49 | } 50 | 51 | fn pause(&self) -> nonblock::MethodReply<()> { 52 | self.method_call("org.mpris.MediaPlayer2.Player", "Pause", ()) 53 | } 54 | 55 | fn play_pause(&self) -> nonblock::MethodReply<()> { 56 | self.method_call("org.mpris.MediaPlayer2.Player", "PlayPause", ()) 57 | } 58 | 59 | fn stop(&self) -> nonblock::MethodReply<()> { 60 | self.method_call("org.mpris.MediaPlayer2.Player", "Stop", ()) 61 | } 62 | 63 | fn play(&self) -> nonblock::MethodReply<()> { 64 | self.method_call("org.mpris.MediaPlayer2.Player", "Play", ()) 65 | } 66 | 67 | fn seek(&self, offset: i64) -> nonblock::MethodReply<()> { 68 | self.method_call("org.mpris.MediaPlayer2.Player", "Seek", (offset,)) 69 | } 70 | 71 | fn set_position(&self, track_id: dbus::Path, position: i64) -> nonblock::MethodReply<()> { 72 | self.method_call( 73 | "org.mpris.MediaPlayer2.Player", 74 | "SetPosition", 75 | (track_id, position), 76 | ) 77 | } 78 | 79 | fn open_uri(&self, uri: &str) -> nonblock::MethodReply<()> { 80 | self.method_call("org.mpris.MediaPlayer2.Player", "OpenUri", (uri,)) 81 | } 82 | 83 | fn playback_status(&self) -> nonblock::MethodReply { 84 | ::get( 85 | &self, 86 | "org.mpris.MediaPlayer2.Player", 87 | "PlaybackStatus", 88 | ) 89 | } 90 | 91 | fn loop_status(&self) -> nonblock::MethodReply { 92 | ::get( 93 | &self, 94 | "org.mpris.MediaPlayer2.Player", 95 | "LoopStatus", 96 | ) 97 | } 98 | 99 | fn set_loop_status(&self, value: String) -> nonblock::MethodReply<()> { 100 | ::set( 101 | &self, 102 | "org.mpris.MediaPlayer2.Player", 103 | "LoopStatus", 104 | value, 105 | ) 106 | } 107 | 108 | fn rate(&self) -> nonblock::MethodReply { 109 | ::get( 110 | &self, 111 | "org.mpris.MediaPlayer2.Player", 112 | "Rate", 113 | ) 114 | } 115 | 116 | fn set_rate(&self, value: f64) -> nonblock::MethodReply<()> { 117 | ::set( 118 | &self, 119 | "org.mpris.MediaPlayer2.Player", 120 | "Rate", 121 | value, 122 | ) 123 | } 124 | 125 | fn shuffle(&self) -> nonblock::MethodReply { 126 | ::get( 127 | &self, 128 | "org.mpris.MediaPlayer2.Player", 129 | "Shuffle", 130 | ) 131 | } 132 | 133 | fn set_shuffle(&self, value: bool) -> nonblock::MethodReply<()> { 134 | ::set( 135 | &self, 136 | "org.mpris.MediaPlayer2.Player", 137 | "Shuffle", 138 | value, 139 | ) 140 | } 141 | 142 | fn metadata(&self) -> nonblock::MethodReply { 143 | ::get( 144 | &self, 145 | "org.mpris.MediaPlayer2.Player", 146 | "Metadata", 147 | ) 148 | } 149 | 150 | fn volume(&self) -> nonblock::MethodReply { 151 | ::get( 152 | &self, 153 | "org.mpris.MediaPlayer2.Player", 154 | "Volume", 155 | ) 156 | } 157 | 158 | fn set_volume(&self, value: f64) -> nonblock::MethodReply<()> { 159 | ::set( 160 | &self, 161 | "org.mpris.MediaPlayer2.Player", 162 | "Volume", 163 | value, 164 | ) 165 | } 166 | 167 | fn position(&self) -> nonblock::MethodReply { 168 | ::get( 169 | &self, 170 | "org.mpris.MediaPlayer2.Player", 171 | "Position", 172 | ) 173 | } 174 | 175 | fn minimum_rate(&self) -> nonblock::MethodReply { 176 | ::get( 177 | &self, 178 | "org.mpris.MediaPlayer2.Player", 179 | "MinimumRate", 180 | ) 181 | } 182 | 183 | fn maximum_rate(&self) -> nonblock::MethodReply { 184 | ::get( 185 | &self, 186 | "org.mpris.MediaPlayer2.Player", 187 | "MaximumRate", 188 | ) 189 | } 190 | 191 | fn can_go_next(&self) -> nonblock::MethodReply { 192 | ::get( 193 | &self, 194 | "org.mpris.MediaPlayer2.Player", 195 | "CanGoNext", 196 | ) 197 | } 198 | 199 | fn can_go_previous(&self) -> nonblock::MethodReply { 200 | ::get( 201 | &self, 202 | "org.mpris.MediaPlayer2.Player", 203 | "CanGoPrevious", 204 | ) 205 | } 206 | 207 | fn can_play(&self) -> nonblock::MethodReply { 208 | ::get( 209 | &self, 210 | "org.mpris.MediaPlayer2.Player", 211 | "CanPlay", 212 | ) 213 | } 214 | 215 | fn can_pause(&self) -> nonblock::MethodReply { 216 | ::get( 217 | &self, 218 | "org.mpris.MediaPlayer2.Player", 219 | "CanPause", 220 | ) 221 | } 222 | 223 | fn can_seek(&self) -> nonblock::MethodReply { 224 | ::get( 225 | &self, 226 | "org.mpris.MediaPlayer2.Player", 227 | "CanSeek", 228 | ) 229 | } 230 | 231 | fn can_control(&self) -> nonblock::MethodReply { 232 | ::get( 233 | &self, 234 | "org.mpris.MediaPlayer2.Player", 235 | "CanControl", 236 | ) 237 | } 238 | } 239 | 240 | #[derive(Debug)] 241 | pub struct MediaPlayer2PlayerSeeked { 242 | pub position: i64, 243 | } 244 | 245 | impl arg::AppendAll for MediaPlayer2PlayerSeeked { 246 | fn append(&self, i: &mut arg::IterAppend) { 247 | arg::RefArg::append(&self.position, i); 248 | } 249 | } 250 | 251 | impl arg::ReadAll for MediaPlayer2PlayerSeeked { 252 | fn read(i: &mut arg::Iter) -> Result { 253 | Ok(MediaPlayer2PlayerSeeked { 254 | position: i.read()?, 255 | }) 256 | } 257 | } 258 | 259 | impl dbus::message::SignalArgs for MediaPlayer2PlayerSeeked { 260 | const INTERFACE: &'static str = "org.mpris.MediaPlayer2.Player"; 261 | const NAME: &'static str = "Seeked"; 262 | } 263 | -------------------------------------------------------------------------------- /apex-mpris2/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(type_alias_impl_trait, async_iterator)] 2 | #![feature(impl_trait_in_assoc_type)] 3 | mod generated; 4 | mod player; 5 | pub use player::{Metadata, Player, MPRIS2}; 6 | -------------------------------------------------------------------------------- /apex-mpris2/src/player.rs: -------------------------------------------------------------------------------- 1 | use crate::generated::MediaPlayer2Player; 2 | use anyhow::{anyhow, Result}; 3 | use apex_music::{AsyncPlayer, Metadata as MetadataTrait, PlaybackStatus, PlayerEvent, Progress}; 4 | use async_stream::stream; 5 | use dbus::{ 6 | arg::PropMap, 7 | message::MatchRule, 8 | nonblock::{Proxy, SyncConnection}, 9 | strings::BusName, 10 | }; 11 | use dbus_tokio::connection; 12 | use futures_core::stream::Stream; 13 | use futures_util::StreamExt; 14 | use std::{future::Future, sync::Arc, time::Duration}; 15 | use tokio::{task::JoinHandle, time, time::MissedTickBehavior}; 16 | 17 | #[derive(Clone)] 18 | pub struct Player<'a>(Proxy<'a, Arc>); 19 | 20 | #[derive(Debug)] 21 | pub struct Metadata(PropMap); 22 | 23 | impl Metadata { 24 | fn length_(&self) -> Result { 25 | ::dbus::arg::prop_cast::(&self.0, "mpris:length") 26 | .copied() 27 | .ok_or_else(|| anyhow!("Couldn't get length!")) 28 | } 29 | } 30 | 31 | impl MetadataTrait for Metadata { 32 | fn title(&self) -> Result { 33 | ::dbus::arg::prop_cast::(&self.0, "xesam:title") 34 | .cloned() 35 | .ok_or_else(|| anyhow!("Couldn't get title!")) 36 | } 37 | 38 | fn artists(&self) -> Result { 39 | Ok( 40 | ::dbus::arg::prop_cast::>(&self.0, "xesam:artist") 41 | .ok_or_else(|| anyhow!("Couldn't get artist!"))? 42 | .join(", "), 43 | ) 44 | } 45 | 46 | fn length(&self) -> Result { 47 | match (self.length_::(), self.length_::()) { 48 | (_, Ok(val)) => Ok(val), 49 | (Ok(val), _) => Ok(val as u64), 50 | (_, _) => Err(anyhow!("Couldn't get length!")), 51 | } 52 | } 53 | } 54 | 55 | pub struct MPRIS2 { 56 | handle: JoinHandle<()>, 57 | conn: Arc, 58 | } 59 | 60 | impl MPRIS2 { 61 | pub async fn new() -> Result { 62 | let (resource, conn) = connection::new_session_sync()?; 63 | 64 | let handle = tokio::spawn(async { 65 | let err = resource.await; 66 | panic!("Lost connection to D-Bus: {}", err); 67 | }); 68 | 69 | Ok(Self { handle, conn }) 70 | } 71 | 72 | #[allow(unreachable_code, unused_variables)] 73 | pub async fn stream(&self) -> Result> { 74 | let mr = MatchRule::new() 75 | .with_path("/org/mpris/MediaPlayer2") 76 | .with_interface("org.freedesktop.DBus.Properties") 77 | .with_member("PropertiesChanged"); 78 | 79 | let (meta_match, mut meta_stream) = self.conn.add_match(mr).await?.msg_stream(); 80 | 81 | let mr = MatchRule::new() 82 | .with_interface("org.mpris.MediaPlayer2.Player") 83 | .with_path("/org/mpris/MediaPlayer2") 84 | .with_member("Seeked"); 85 | 86 | let (seek_match, mut seek_stream) = self.conn.add_match(mr).await?.msg_stream(); 87 | 88 | Ok(stream! { 89 | loop { 90 | let mut timer = time::interval(time::Duration::from_millis(100)); 91 | timer.set_missed_tick_behavior(MissedTickBehavior::Skip); 92 | // First timer tick elapses instantaneously 93 | timer.tick().await; 94 | 95 | tokio::select! { 96 | msg = seek_stream.next() => { 97 | if let Some(_) = msg { 98 | yield PlayerEvent::Seeked; 99 | } 100 | }, 101 | msg = meta_stream.next() => { 102 | if let Some(_) = msg { 103 | yield PlayerEvent::Properties; 104 | } 105 | }, 106 | _ = timer.tick() => { 107 | yield PlayerEvent::Timer; 108 | } 109 | } 110 | } 111 | // The signal handler will unregister if those two are dropped so we never drop them ;) 112 | drop(seek_match); 113 | drop(meta_match); 114 | }) 115 | } 116 | 117 | pub async fn list_names(&self) -> Result> { 118 | let proxy = Proxy::new( 119 | "org.freedesktop.DBus", 120 | "/", 121 | Duration::from_secs(2), 122 | self.conn.clone(), 123 | ); 124 | 125 | let (result,): (Vec,) = proxy 126 | .method_call("org.freedesktop.DBus", "ListNames", ()) 127 | .await?; 128 | 129 | let result = result 130 | .iter() 131 | .filter(|name| name.starts_with("org.mpris.MediaPlayer2.")) 132 | .cloned() 133 | .collect::>(); 134 | 135 | Ok(result) 136 | } 137 | 138 | pub async fn wait_for_player(&self, name: Option>) -> Result> { 139 | let mut interval = time::interval(Duration::from_secs(5)); 140 | interval.set_missed_tick_behavior(MissedTickBehavior::Skip); 141 | 142 | let name = name.map(|n| n.to_string()); 143 | 144 | // TODO: Instead of having a hard delay we might be able to wait on a 145 | // notification from DBus instead? 146 | 147 | loop { 148 | let names = self.list_names().await?; 149 | 150 | if let Some(name) = &name { 151 | // We have a player preference, let's check if it exists 152 | if let Some(player) = names.into_iter().find(|p| p.contains(name)) { 153 | // Hell yeah, we found a player 154 | return Ok(Player::new(player, self.conn.clone())); 155 | } 156 | } else { 157 | // Let's try to find a player that's either playing or paused 158 | for name in names { 159 | let player = Player::new(name, self.conn.clone()); 160 | 161 | match player.playback_status().await { 162 | // Something is playing or paused right now, let's use that 163 | Ok(PlaybackStatus::Playing | PlaybackStatus::Paused) => { 164 | return Ok(player); 165 | } 166 | // Stopped players could be remnants of browser tabs that were playing in 167 | // the past but are dead now and we'd just get stuck here. 168 | _ => { 169 | continue; 170 | } 171 | } 172 | } 173 | } 174 | 175 | interval.tick().await; 176 | } 177 | } 178 | } 179 | 180 | impl Drop for MPRIS2 { 181 | fn drop(&mut self) { 182 | self.handle.abort(); 183 | } 184 | } 185 | 186 | impl<'a> Player<'a> { 187 | pub fn new(path: impl Into>, conn: Arc) -> Self { 188 | Self(Proxy::new( 189 | path.into(), 190 | "/org/mpris/MediaPlayer2", 191 | Duration::from_secs(2), 192 | conn, 193 | )) 194 | } 195 | 196 | pub async fn progress(&self) -> Result> { 197 | Ok(Progress { 198 | metadata: self.metadata().await?, 199 | position: self.position().await?, 200 | status: self.playback_status().await?, 201 | }) 202 | } 203 | } 204 | 205 | impl<'a> AsyncPlayer for Player<'a> { 206 | type Metadata = Metadata; 207 | 208 | type MetadataFuture<'b> = impl Future> + 'b 209 | where 210 | Self: 'b; 211 | type NameFuture<'b> = impl Future + 'b 212 | where 213 | Self: 'b; 214 | type PlaybackStatusFuture<'b> = impl Future> + 'b 215 | where 216 | Self: 'b; 217 | type PositionFuture<'b> = impl Future> + 'b 218 | where 219 | Self: 'b; 220 | 221 | #[allow(clippy::needless_lifetimes)] 222 | fn metadata<'this>(&'this self) -> Self::MetadataFuture<'this> { 223 | async { Ok(Metadata(self.0.metadata().await?)) } 224 | } 225 | 226 | #[allow(clippy::needless_lifetimes)] 227 | fn playback_status<'this>(&'this self) -> Self::PlaybackStatusFuture<'this> { 228 | async { 229 | let status = self.0.playback_status().await?; 230 | 231 | match status.as_str() { 232 | "Playing" => Ok(PlaybackStatus::Playing), 233 | "Paused" => Ok(PlaybackStatus::Paused), 234 | "Stopped" => Ok(PlaybackStatus::Stopped), 235 | _ => Err(anyhow!("Bad playback status!")), 236 | } 237 | } 238 | } 239 | 240 | #[allow(clippy::needless_lifetimes)] 241 | fn name<'this>(&'this self) -> Self::NameFuture<'this> { 242 | async { self.0.destination.to_string() } 243 | } 244 | 245 | #[allow(clippy::needless_lifetimes)] 246 | fn position<'this>(&'this self) -> Self::PositionFuture<'this> { 247 | async { Ok(self.0.position().await?) } 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /apex-music/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "apex-music" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | anyhow = "1.0.44" 10 | -------------------------------------------------------------------------------- /apex-music/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(type_alias_impl_trait)] 2 | #![feature(impl_trait_in_assoc_type)] 3 | mod player; 4 | pub use player::{ 5 | AsyncMetadata, AsyncPlayer, Metadata, PlaybackStatus, Player, PlayerEvent, Progress, 6 | }; 7 | -------------------------------------------------------------------------------- /apex-music/src/player.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::future::Future; 3 | 4 | #[derive(Copy, Clone, Debug)] 5 | #[allow(dead_code)] 6 | pub enum PlaybackStatus { 7 | Stopped, 8 | Paused, 9 | Playing, 10 | } 11 | 12 | #[derive(Clone, Debug)] 13 | pub enum PlayerEvent { 14 | Seeked, 15 | Properties, 16 | Timer, 17 | } 18 | 19 | pub trait Metadata { 20 | fn title(&self) -> Result; 21 | fn artists(&self) -> Result; 22 | fn length(&self) -> Result; 23 | } 24 | 25 | pub trait Player { 26 | type Metadata: Metadata; 27 | fn metadata(&self) -> Result; 28 | fn position(&self) -> Result; 29 | fn name(&self) -> String; 30 | fn playback_status(&self) -> Result; 31 | } 32 | 33 | pub struct Progress { 34 | pub metadata: T, 35 | pub position: i64, 36 | pub status: PlaybackStatus, 37 | } 38 | 39 | pub trait AsyncPlayer { 40 | type Metadata: Metadata; 41 | 42 | type MetadataFuture<'a>: Future> + 'a 43 | where 44 | Self: 'a; 45 | 46 | type PlaybackStatusFuture<'a>: Future> + 'a 47 | where 48 | Self: 'a; 49 | 50 | type NameFuture<'a>: Future + 'a 51 | where 52 | Self: 'a; 53 | 54 | type PositionFuture<'a>: Future> + 'a 55 | where 56 | Self: 'a; 57 | 58 | #[allow(clippy::needless_lifetimes)] 59 | fn metadata<'this>(&'this self) -> Self::MetadataFuture<'this>; 60 | 61 | #[allow(clippy::needless_lifetimes)] 62 | fn playback_status<'this>(&'this self) -> Self::PlaybackStatusFuture<'this>; 63 | 64 | #[allow(clippy::needless_lifetimes)] 65 | fn name<'this>(&'this self) -> Self::NameFuture<'this>; 66 | 67 | #[allow(clippy::needless_lifetimes)] 68 | fn position<'this>(&'this self) -> Self::PositionFuture<'this>; 69 | } 70 | 71 | impl AsyncPlayer for T { 72 | type Metadata = ::Metadata; 73 | 74 | type MetadataFuture<'a> = impl Future> + 'a 75 | where 76 | T: 'a; 77 | type NameFuture<'a> = impl Future 78 | where 79 | T: 'a; 80 | type PlaybackStatusFuture<'a> = impl Future> 81 | where 82 | T: 'a; 83 | type PositionFuture<'a> = impl Future> 84 | where 85 | T: 'a; 86 | 87 | #[allow(clippy::needless_lifetimes)] 88 | fn metadata<'this>(&'this self) -> Self::MetadataFuture<'this> { 89 | let metadata = ::metadata(self); 90 | async { metadata } 91 | } 92 | 93 | #[allow(clippy::needless_lifetimes)] 94 | fn playback_status<'this>(&'this self) -> Self::PlaybackStatusFuture<'this> { 95 | let status = ::playback_status(self); 96 | async { status } 97 | } 98 | 99 | #[allow(clippy::needless_lifetimes)] 100 | fn name<'this>(&'this self) -> Self::NameFuture<'this> { 101 | let name = ::name(self); 102 | async { name } 103 | } 104 | 105 | #[allow(clippy::needless_lifetimes)] 106 | fn position<'this>(&'this self) -> Self::PositionFuture<'this> { 107 | let position = ::position(self); 108 | async { position } 109 | } 110 | } 111 | 112 | pub trait AsyncMetadata { 113 | type TitleFuture<'a>: Future> + 'a 114 | where 115 | Self: 'a; 116 | type ArtistsFuture<'a>: Future> + 'a 117 | where 118 | Self: 'a; 119 | type LengthFuture<'a>: Future> + 'a 120 | where 121 | Self: 'a; 122 | 123 | #[allow(clippy::needless_lifetimes)] 124 | fn title<'this>(&'this self) -> Self::TitleFuture<'this>; 125 | 126 | #[allow(clippy::needless_lifetimes)] 127 | fn artists<'this>(&'this self) -> Self::ArtistsFuture<'this>; 128 | 129 | #[allow(clippy::needless_lifetimes)] 130 | fn length<'this>(&'this self) -> Self::LengthFuture<'this>; 131 | } 132 | 133 | /// Blanket implementation for non-async Metadata sources 134 | impl AsyncMetadata for T { 135 | type ArtistsFuture<'a> = impl Future> + 'a 136 | where 137 | T: 'a; 138 | type LengthFuture<'a> = impl Future> + 'a 139 | where 140 | T: 'a; 141 | type TitleFuture<'a> = impl Future> + 'a 142 | where 143 | T: 'a; 144 | 145 | #[allow(clippy::needless_lifetimes)] 146 | fn title<'this>(&'this self) -> Self::TitleFuture<'this> { 147 | let title = ::title(self); 148 | async { title } 149 | } 150 | 151 | #[allow(clippy::needless_lifetimes)] 152 | fn artists<'this>(&'this self) -> Self::ArtistsFuture<'this> { 153 | let artists = ::artists(self); 154 | async { artists } 155 | } 156 | 157 | #[allow(clippy::needless_lifetimes)] 158 | fn length<'this>(&'this self) -> Self::LengthFuture<'this> { 159 | let length = ::length(self); 160 | async { length } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /apex-simulator/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "apex-simulator" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | anyhow = "1.0.45" 10 | apex-hardware = { path = "../apex-hardware" } 11 | embedded-graphics = "0.7.1" 12 | embedded-graphics-simulator = "0.3.0" 13 | log = "0.4.14" 14 | tokio = {version = "1", features=["time", "net", "macros", "rt-multi-thread", "sync"]} 15 | apex-input = { path = "../apex-input"} -------------------------------------------------------------------------------- /apex-simulator/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod simulator; 2 | pub use simulator::Simulator; 3 | -------------------------------------------------------------------------------- /apex-simulator/src/simulator.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use apex_hardware::{Device, FrameBuffer}; 3 | use apex_input::Command; 4 | use embedded_graphics::{geometry::Size, pixelcolor::BinaryColor, Drawable}; 5 | use embedded_graphics_simulator::{ 6 | sdl2::Keycode, OutputSettingsBuilder, SimulatorDisplay, SimulatorEvent, Window, 7 | }; 8 | use std::{sync::mpsc, thread, thread::JoinHandle, time::Duration}; 9 | 10 | static WINDOW_TITLE: &str = concat!( 11 | env!("CARGO_PKG_NAME"), 12 | " v", 13 | env!("CARGO_PKG_VERSION"), 14 | " simulator" 15 | ); 16 | 17 | pub struct Simulator { 18 | _handle: JoinHandle>, 19 | sender: mpsc::Sender, 20 | } 21 | 22 | impl Simulator { 23 | pub fn connect(sender: tokio::sync::broadcast::Sender) -> Self { 24 | let (tx, rx) = mpsc::channel::(); 25 | let handle = thread::spawn(move || { 26 | let mut display = SimulatorDisplay::::new(Size::new(128, 40)); 27 | 28 | let output_settings = OutputSettingsBuilder::new().scale(4).build(); 29 | let mut window = Window::new(WINDOW_TITLE, &output_settings); 30 | 31 | 'outer: loop { 32 | if let Ok(image) = rx.recv_timeout(Duration::from_millis(10)) { 33 | image.draw(&mut display)?; 34 | } 35 | 36 | window.update(&display); 37 | 38 | for x in window.events() { 39 | match x { 40 | SimulatorEvent::KeyUp { keycode, .. } => { 41 | if keycode == Keycode::Left { 42 | sender.send(Command::PreviousSource)?; 43 | } else if keycode == Keycode::Right { 44 | sender.send(Command::NextSource)?; 45 | } 46 | Ok::<(), anyhow::Error>(()) 47 | } 48 | SimulatorEvent::Quit => { 49 | sender.send(Command::Shutdown)?; 50 | break 'outer; 51 | } 52 | _ => Ok(()), 53 | }?; 54 | } 55 | } 56 | 57 | Ok(()) 58 | }); 59 | 60 | Simulator { 61 | _handle: handle, 62 | sender: tx, 63 | } 64 | } 65 | } 66 | 67 | impl Device for Simulator { 68 | fn draw(&mut self, display: &FrameBuffer) -> Result<()> { 69 | self.sender.send(*display)?; 70 | Ok(()) 71 | } 72 | 73 | fn clear(&mut self) -> Result<()> { 74 | let new = FrameBuffer::new(); 75 | self.draw(&new)?; 76 | Ok(()) 77 | } 78 | 79 | fn shutdown(&mut self) -> Result<()> { 80 | Ok(()) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /apex-windows/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "apex-windows" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | 9 | 10 | [dependencies] 11 | anyhow = "1.0.47" 12 | async-stream = "0.3.2" 13 | futures-core = "0.3.17" 14 | futures-util = "0.3.17" 15 | tokio = { version = "1.14.0", features = ["time"] } 16 | [target.'cfg(target_os = "windows")'.dependencies] 17 | windows = { version = "0.43", features = ["Media_Control", "Foundation"] } 18 | apex-music = { path = "../apex-music" } 19 | -------------------------------------------------------------------------------- /apex-windows/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(type_alias_impl_trait, async_iterator, impl_trait_in_assoc_type)] 2 | mod music; 3 | pub use music::{Metadata, Player}; 4 | -------------------------------------------------------------------------------- /apex-windows/src/music.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use apex_music::{AsyncPlayer, Metadata as MetadataTrait, PlaybackStatus, PlayerEvent, Progress}; 3 | use futures_core::stream::Stream; 4 | use std::future::Future; 5 | 6 | use async_stream::stream; 7 | use std::time::Duration; 8 | use tokio::time::MissedTickBehavior; 9 | use windows::Media::{ 10 | Control, 11 | Control::{ 12 | GlobalSystemMediaTransportControlsSession, 13 | GlobalSystemMediaTransportControlsSessionManager, 14 | GlobalSystemMediaTransportControlsSessionMediaProperties, 15 | GlobalSystemMediaTransportControlsSessionPlaybackInfo, 16 | GlobalSystemMediaTransportControlsSessionPlaybackStatus, 17 | }, 18 | }; 19 | 20 | #[derive(Debug, Clone, Default)] 21 | pub struct Metadata { 22 | title: String, 23 | artists: String, 24 | } 25 | 26 | impl MetadataTrait for Metadata { 27 | fn title(&self) -> Result { 28 | Ok(self.title.clone()) 29 | } 30 | 31 | fn artists(&self) -> Result { 32 | Ok(self.artists.clone()) 33 | } 34 | 35 | fn length(&self) -> Result { 36 | Ok(0) 37 | } 38 | } 39 | 40 | pub struct Player { 41 | session_manager: GlobalSystemMediaTransportControlsSessionManager, 42 | } 43 | 44 | impl Player { 45 | pub fn new() -> Result { 46 | let session_manager = 47 | Control::GlobalSystemMediaTransportControlsSessionManager::RequestAsync() 48 | .map_err(|_| anyhow!("Windows"))? 49 | .get() 50 | .map_err(|_| anyhow!("Windows"))?; 51 | 52 | Ok(Self { session_manager }) 53 | } 54 | 55 | pub fn current_session(&self) -> Result { 56 | self.session_manager 57 | .GetCurrentSession() 58 | .map_err(|e| anyhow!("Couldn't get current session: {}", e)) 59 | } 60 | 61 | pub async fn media_properties( 62 | &self, 63 | ) -> Result { 64 | let session = self.current_session()?; 65 | let x = session 66 | .TryGetMediaPropertiesAsync() 67 | .map_err(|e| anyhow!("Couldn't get media properties: {}", e))? 68 | .await?; 69 | 70 | Ok(x) 71 | } 72 | 73 | pub async fn progress(&self) -> Result> { 74 | Ok(Progress { 75 | metadata: self.metadata().await?, 76 | position: self.position().await?, 77 | status: self.playback_status().await?, 78 | }) 79 | } 80 | 81 | #[allow(unreachable_code, unused_variables)] 82 | pub async fn stream(&self) -> Result> { 83 | let mut timer = tokio::time::interval(Duration::from_millis(100)); 84 | timer.set_missed_tick_behavior(MissedTickBehavior::Skip); 85 | Ok(stream! { 86 | loop { 87 | timer.tick().await; 88 | yield PlayerEvent::Timer; 89 | } 90 | }) 91 | } 92 | } 93 | impl AsyncPlayer for Player { 94 | type Metadata = Metadata; 95 | 96 | type MetadataFuture<'b> = impl Future> + 'b 97 | where 98 | Self: 'b; 99 | type NameFuture<'b> = impl Future + 'b 100 | where 101 | Self: 'b; 102 | type PlaybackStatusFuture<'b> = impl Future> + 'b 103 | where 104 | Self: 'b; 105 | type PositionFuture<'b> = impl Future> + 'b 106 | where 107 | Self: 'b; 108 | 109 | #[allow(clippy::needless_lifetimes)] 110 | fn metadata<'this>(&'this self) -> Self::MetadataFuture<'this> { 111 | async { 112 | let session = self.media_properties().await?; 113 | let title = session.Title()?.to_string_lossy(); 114 | let artists = session.Artist()?.to_string_lossy(); 115 | Ok(Metadata { title, artists }) 116 | } 117 | } 118 | 119 | #[allow(clippy::needless_lifetimes)] 120 | fn playback_status<'this>(&'this self) -> Self::PlaybackStatusFuture<'this> { 121 | async { 122 | let session = self.current_session(); 123 | let session = match session { 124 | Ok(session) => session, 125 | Err(_) => return Ok(PlaybackStatus::Stopped), 126 | }; 127 | 128 | let playback: GlobalSystemMediaTransportControlsSessionPlaybackInfo = 129 | session.GetPlaybackInfo().map_err(|_| anyhow!("Windows"))?; 130 | 131 | let status = playback.PlaybackStatus().map_err(|_| anyhow!("Windows"))?; 132 | 133 | Ok(match status { 134 | GlobalSystemMediaTransportControlsSessionPlaybackStatus::Playing => { 135 | PlaybackStatus::Playing 136 | } 137 | GlobalSystemMediaTransportControlsSessionPlaybackStatus::Paused => { 138 | PlaybackStatus::Paused 139 | } 140 | _ => PlaybackStatus::Stopped, 141 | }) 142 | } 143 | } 144 | 145 | #[allow(clippy::needless_lifetimes)] 146 | fn name<'this>(&'this self) -> Self::NameFuture<'this> { 147 | // There might be a Windows API to find the name of the player but the user most 148 | // likely will never see this anyway 149 | async { String::from("windows-api") } 150 | } 151 | 152 | #[allow(clippy::needless_lifetimes)] 153 | fn position<'this>(&'this self) -> Self::PositionFuture<'this> { 154 | // TODO: Find the API for this? 155 | async { Ok(0) } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /assets/btc.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/not-jan/apex-tux/bff1a6dda5e530b22a5c9a105b6e64a38c426a7a/assets/btc.bmp -------------------------------------------------------------------------------- /assets/discord.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/not-jan/apex-tux/bff1a6dda5e530b22a5c9a105b6e64a38c426a7a/assets/discord.bmp -------------------------------------------------------------------------------- /assets/gif_missing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/not-jan/apex-tux/bff1a6dda5e530b22a5c9a105b6e64a38c426a7a/assets/gif_missing.gif -------------------------------------------------------------------------------- /assets/note.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/not-jan/apex-tux/bff1a6dda5e530b22a5c9a105b6e64a38c426a7a/assets/note.bmp -------------------------------------------------------------------------------- /assets/pause.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/not-jan/apex-tux/bff1a6dda5e530b22a5c9a105b6e64a38c426a7a/assets/pause.bmp -------------------------------------------------------------------------------- /images/sample_1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/not-jan/apex-tux/bff1a6dda5e530b22a5c9a105b6e64a38c426a7a/images/sample_1.gif -------------------------------------------------------------------------------- /resources/btc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/not-jan/apex-tux/bff1a6dda5e530b22a5c9a105b6e64a38c426a7a/resources/btc.png -------------------------------------------------------------------------------- /resources/music.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/not-jan/apex-tux/bff1a6dda5e530b22a5c9a105b6e64a38c426a7a/resources/music.png -------------------------------------------------------------------------------- /resources/simulator-btc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/not-jan/apex-tux/bff1a6dda5e530b22a5c9a105b6e64a38c426a7a/resources/simulator-btc.png -------------------------------------------------------------------------------- /resources/simulator-clock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/not-jan/apex-tux/bff1a6dda5e530b22a5c9a105b6e64a38c426a7a/resources/simulator-clock.png -------------------------------------------------------------------------------- /resources/simulator-music.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/not-jan/apex-tux/bff1a6dda5e530b22a5c9a105b6e64a38c426a7a/resources/simulator-music.png -------------------------------------------------------------------------------- /resources/system-metrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/not-jan/apex-tux/bff1a6dda5e530b22a5c9a105b6e64a38c426a7a/resources/system-metrics.png -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2018" 2 | format_macro_bodies = true 3 | format_strings = true 4 | imports_granularity = "Crate" 5 | reorder_impl_items = true 6 | reorder_imports = true 7 | reorder_modules = true 8 | wrap_comments = true -------------------------------------------------------------------------------- /settings.toml: -------------------------------------------------------------------------------- 1 | [interval] 2 | # The interval at which the screen should automatically change 3 | # set to 0 if you don't want it to change automatically 4 | refresh=45 5 | 6 | [clock] 7 | enabled = true 8 | # Set this to the highest priority so it will start with the clock 9 | # priority = 1 10 | # Enables a twelve hour clock instead of the 24hr one 11 | # Defaults to your local format if unset 12 | # twelve_hour = false 13 | 14 | [mpris2] 15 | enabled = true 16 | # Set this so web browsers like Firefox or Chrome don't steal the focus of your real music player 17 | # You can check what to put here by using tools like D-Feet 18 | # preferred_player = "Lollypop" 19 | 20 | [coindesk] 21 | enabled = true 22 | # Valid choices are "gbp", "usd" and "eur" 23 | # Default is USD 24 | currency = "eur" 25 | 26 | [sysinfo] 27 | enabled = true 28 | # The polling interval for system stats in milliseconds. 29 | polling_interval = 1500 30 | # The maximum value for the net I/O stat bar (in MiB), used for scaling its fill 31 | # net_load_max = 100 32 | # The maximum value for the cpu frequency stat bar (in GHz), used for scaling its fill 33 | # cpu_frequency_max = 7 34 | # The maximum value for the temperature stat bar (in degC), used for scaling its fill 35 | # temperature_max = 100 36 | # Network interface name used in network I/O stat bar 37 | # To find values for this config in Linux, use the `ip link` command 38 | # net_interface_name = "eth0" 39 | # sensor name used in temperature stat bar 40 | # To find values for this config in Linux, use the `sensors` command 41 | # sensor_name = "asus_wmi_sensors CPU Temperature" 42 | 43 | [image] 44 | enabled = true 45 | # /!\ 46 | # Please note that it is a relative path, so once compiled, please 47 | # copy the images folder to the current directory 48 | path = "images/sample_1.gif" 49 | # This only works if the image feature is passed in the build instructions 50 | # It supports all those formats : https://github.com/image-rs/image/tree/8824ab3375ddab0fd3429fe3915334523d50c532#supported-image-formats 51 | # (even in color, but it will only display in black and white) -------------------------------------------------------------------------------- /src/dbus/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "dbus-support")] 2 | pub(crate) mod notifications; 3 | -------------------------------------------------------------------------------- /src/dbus/notifications.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | render::{ 3 | notifications::{Icon, Notification, NotificationBuilder, NotificationProvider}, 4 | scheduler::NotificationWrapper, 5 | }, 6 | scheduler::NOTIFICATION_PROVIDERS, 7 | }; 8 | use anyhow::{anyhow, Result}; 9 | use async_stream::try_stream; 10 | use dbus::{ 11 | arg::messageitem::MessageItem, 12 | channel::MatchingReceiver, 13 | message::MatchRule, 14 | nonblock, 15 | strings::{Interface, Member}, 16 | Message, 17 | }; 18 | use dbus_tokio::connection; 19 | use embedded_graphics::pixelcolor::BinaryColor; 20 | use futures::{channel::mpsc, StreamExt}; 21 | use futures_core::Stream; 22 | use lazy_static::lazy_static; 23 | use linkme::distributed_slice; 24 | use log::{debug, info}; 25 | use std::{convert::TryFrom, time::Duration}; 26 | use tinybmp::Bmp; 27 | 28 | #[distributed_slice(NOTIFICATION_PROVIDERS)] 29 | static PROVIDER_INIT: fn() -> Result> = register_callback; 30 | 31 | #[allow(clippy::unnecessary_wraps)] 32 | fn register_callback() -> Result> { 33 | info!("Registering DBUS notification source."); 34 | let dbus = Box::new(Dbus {}); 35 | Ok(dbus) 36 | } 37 | 38 | static DISCORD_ICON: &[u8] = include_bytes!("./../../assets/discord.bmp"); 39 | lazy_static! { 40 | static ref DISCORD_ICON_BMP: Bmp<'static, BinaryColor> = 41 | Bmp::::from_slice(DISCORD_ICON).expect("Failed to parse BMP"); 42 | } 43 | 44 | pub struct Dbus {} 45 | 46 | enum NotificationType { 47 | Discord { title: String, content: String }, 48 | Unsupported, 49 | } 50 | 51 | impl NotificationType { 52 | pub fn render(&self) -> Result { 53 | let builder = NotificationBuilder::new(); 54 | 55 | match self { 56 | NotificationType::Discord { title, content } => { 57 | let icon = Icon::new(*DISCORD_ICON_BMP); 58 | builder 59 | .with_icon(icon) 60 | .with_content(content) 61 | .with_title(title) 62 | .build() 63 | } 64 | NotificationType::Unsupported => Err(anyhow!("Unsupported notification type!")), 65 | } 66 | } 67 | } 68 | 69 | impl TryFrom for NotificationType { 70 | type Error = anyhow::Error; 71 | 72 | fn try_from(value: Message) -> Result { 73 | let source = value.get_source()?; 74 | 75 | Ok(match source.as_str() { 76 | "discord" => { 77 | let (_, _, _, title, content) = 78 | value.read5::()?; 79 | if let Some(MessageItem::Dict(dict)) = value.get_items().get(6) { 80 | if let Some((MessageItem::Str(key), _)) = dict.last() { 81 | if key != "sender-pid" { 82 | return Ok(NotificationType::Unsupported); 83 | } 84 | } 85 | } 86 | 87 | NotificationType::Discord { title, content } 88 | } 89 | _ => NotificationType::Unsupported, 90 | }) 91 | } 92 | } 93 | 94 | trait MessageExt { 95 | fn get_source(&self) -> Result; 96 | } 97 | 98 | impl MessageExt for Message { 99 | fn get_source(&self) -> Result { 100 | self.get1::() 101 | .ok_or_else(|| anyhow!("Couldn't get source")) 102 | } 103 | } 104 | 105 | impl NotificationProvider for Dbus { 106 | type NotificationStream<'a> = impl Stream> + 'a; 107 | 108 | // This needs to be enabled until full GAT support is here 109 | #[allow(clippy::needless_lifetimes)] 110 | fn stream<'this>(&'this mut self) -> Result> { 111 | let mut rule = MatchRule::new(); 112 | rule.interface = Some(Interface::from("org.freedesktop.Notifications")); 113 | rule.member = Some(Member::from("Notify")); 114 | 115 | let (resource, conn) = connection::new_session_sync()?; 116 | 117 | tokio::spawn(async { 118 | let err = resource.await; 119 | panic!("Lost connection to D-Bus: {}", err); 120 | }); 121 | 122 | let (mut tx, mut rx) = mpsc::channel(10); 123 | 124 | tokio::spawn(async move { 125 | let conn2 = conn.clone(); 126 | 127 | let proxy = nonblock::Proxy::new( 128 | "org.freedesktop.DBus", 129 | "/org/freedesktop/DBus", 130 | Duration::from_millis(5000), 131 | conn, 132 | ); 133 | 134 | // `BecomeMonitor` is the modern approach to monitoring messages on the bus 135 | // There used to be `eavesdrop` but it's since been deprecated and seeing as the 136 | // change happened back in 2017 I've elected for not supporting that 137 | // here. 138 | proxy 139 | .method_call( 140 | "org.freedesktop.DBus.Monitoring", 141 | "BecomeMonitor", 142 | (vec![rule.match_str()], 0_u32), 143 | ) 144 | .await?; 145 | 146 | conn2.start_receive( 147 | rule, 148 | Box::new(move |msg, _| { 149 | debug!("DBus event from {:?}", msg.sender()); 150 | tx.try_send(msg).is_ok() 151 | }), 152 | ); 153 | 154 | Ok::<(), anyhow::Error>(()) 155 | }); 156 | 157 | Ok(try_stream! { 158 | while let Some(msg) = rx.next().await { 159 | let ty = NotificationType::try_from(msg)?; 160 | 161 | if let NotificationType::Unsupported = &ty { 162 | continue; 163 | } else { 164 | if let Ok(notif) = ty.render() { 165 | yield notif; 166 | } 167 | } 168 | } 169 | println!("WTF?"); 170 | }) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(incomplete_features)] 2 | #![feature( 3 | type_alias_impl_trait, 4 | try_blocks, 5 | const_fn_floating_point_arithmetic, 6 | inherent_associated_types, 7 | async_closure, 8 | async_iterator, 9 | decl_macro, 10 | impl_trait_in_assoc_type 11 | )] 12 | #![warn(clippy::pedantic)] 13 | // `clippy::mut_mut` is disabled because `futures::stream::select!` causes the lint to fire 14 | // The other lints are just awfully tedious to implement especially when dealing with pixel 15 | // coordinates. I'll fix them if I'm ever that bored. 16 | #![allow( 17 | clippy::cast_possible_wrap, 18 | clippy::cast_precision_loss, 19 | clippy::cast_possible_truncation, 20 | clippy::cast_sign_loss 21 | )] 22 | #![deny( 23 | missing_debug_implementations, 24 | nonstandard_style, 25 | missing_copy_implementations, 26 | unused_qualifications 27 | )] 28 | 29 | extern crate embedded_graphics; 30 | 31 | use anyhow::Result; 32 | use log::warn; 33 | 34 | // This is kind of pointless on non-Linux platforms 35 | #[cfg(all(feature = "dbus-support", target_os = "linux"))] 36 | mod dbus; 37 | 38 | mod providers; 39 | mod render; 40 | 41 | #[cfg(all(feature = "simulator", feature = "usb"))] 42 | compile_error!( 43 | "The features `simulator` and `usb` are mutually exclusive. Use --no-default-features!" 44 | ); 45 | 46 | #[cfg(feature = "simulator")] 47 | use apex_simulator::Simulator; 48 | 49 | use crate::render::{scheduler, scheduler::Scheduler}; 50 | #[cfg(feature = "engine")] 51 | use apex_engine::Engine; 52 | use apex_hardware::AsyncDevice; 53 | #[cfg(all(feature = "usb", target_os = "linux", not(feature = "engine")))] 54 | use apex_hardware::USBDevice; 55 | use log::{info, LevelFilter}; 56 | use simplelog::{Config as LoggerConfig, SimpleLogger}; 57 | use tokio::sync::broadcast; 58 | 59 | use apex_input::Command; 60 | 61 | #[tokio::main] 62 | #[allow(clippy::missing_errors_doc)] 63 | pub async fn main() -> Result<()> { 64 | SimpleLogger::init(LevelFilter::Info, LoggerConfig::default())?; 65 | 66 | // This channel is used to send commands to the scheduler 67 | let (tx, rx) = broadcast::channel::(100); 68 | #[cfg(all(feature = "usb", target_family = "unix", not(feature = "engine")))] 69 | let mut device = USBDevice::try_connect()?; 70 | 71 | #[cfg(feature = "hotkeys")] 72 | let hkm = apex_input::InputManager::new(tx.clone()); 73 | 74 | #[cfg(feature = "engine")] 75 | let mut device = Engine::new().await?; 76 | 77 | let mut settings = config::Config::default(); 78 | // Add in `$USER_CONFIG_DIR/apex-tux/settings.toml` 79 | if let Some(user_config_dir) = dirs::config_dir() { 80 | settings.merge( 81 | config::File::with_name(&user_config_dir.join("apex-tux/settings").to_string_lossy()) 82 | .required(false), 83 | )?; 84 | }; 85 | settings 86 | // Add in `./settings.toml` 87 | .merge(config::File::with_name("settings").required(false))? 88 | // Add in settings from the environment (with a prefix of APEX) 89 | // Eg.. `APEX_DEBUG=1 ./target/app` would set the `debug` key 90 | .merge(config::Environment::with_prefix("APEX_"))?; 91 | 92 | #[cfg(feature = "simulator")] 93 | let mut device = Simulator::connect(tx.clone()); 94 | 95 | device.clear().await?; 96 | 97 | let mut scheduler = Scheduler::new(device); 98 | scheduler.start(tx.clone(), rx, settings).await?; 99 | 100 | ctrlc::set_handler(move || { 101 | info!("Ctrl + C received, shutting down!"); 102 | tx.send(Command::Shutdown) 103 | .expect("Failed to send shutdown signal!"); 104 | })?; 105 | 106 | #[cfg(feature = "hotkeys")] 107 | drop(hkm); 108 | 109 | Ok(()) 110 | } 111 | -------------------------------------------------------------------------------- /src/providers/clock.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | render::{display::ContentProvider, scheduler::ContentWrapper}, 3 | scheduler::CONTENT_PROVIDERS, 4 | }; 5 | use anyhow::Result; 6 | use apex_hardware::FrameBuffer; 7 | use async_stream::try_stream; 8 | use chrono::{DateTime, Local}; 9 | use config::Config; 10 | use embedded_graphics::{ 11 | geometry::Point, 12 | mono_font::{iso_8859_15, MonoTextStyle}, 13 | pixelcolor::BinaryColor, 14 | text::{renderer::TextRenderer, Baseline, Text}, 15 | Drawable, 16 | }; 17 | use futures::Stream; 18 | use linkme::distributed_slice; 19 | use log::info; 20 | use tokio::{ 21 | time, 22 | time::{Duration, MissedTickBehavior}, 23 | }; 24 | 25 | #[doc(hidden)] 26 | #[distributed_slice(CONTENT_PROVIDERS)] 27 | pub static PROVIDER_INIT: fn(&Config) -> Result> = register_callback; 28 | 29 | #[derive(Debug, Copy, Clone)] 30 | /// Represents the options a user can choose for the clock format 31 | enum ClockFormat { 32 | /// 12hr clock format with AM / PM 33 | Twelve, 34 | /// 24hr clock format (military time) 35 | TwentyFour, 36 | /// This setting will use the current locales clock format instead 37 | Locale, 38 | } 39 | 40 | #[doc(hidden)] 41 | #[allow(clippy::unnecessary_wraps)] 42 | fn register_callback(config: &Config) -> Result> { 43 | info!("Registering Clock display source."); 44 | 45 | let clock_format = match config.get_bool("clock.twelve_hour") { 46 | Ok(true) => ClockFormat::Twelve, 47 | Ok(false) => ClockFormat::TwentyFour, 48 | _ => ClockFormat::Locale, 49 | }; 50 | 51 | Ok(Box::new(Clock { clock_format })) 52 | } 53 | 54 | pub struct Clock { 55 | clock_format: ClockFormat, 56 | } 57 | 58 | impl Clock { 59 | pub fn render(&self) -> Result { 60 | let local: DateTime = Local::now(); 61 | let format_string = match self.clock_format { 62 | ClockFormat::Twelve => "%I:%M:%S %p", 63 | ClockFormat::TwentyFour => "%H:%M:%S", 64 | ClockFormat::Locale => "%X", 65 | }; 66 | 67 | let text = local.format(format_string).to_string(); 68 | let mut buffer = FrameBuffer::new(); 69 | let style = MonoTextStyle::new(&iso_8859_15::FONT_8X13_BOLD, BinaryColor::On); 70 | let metrics = style.measure_string(&text, Point::zero(), Baseline::Top); 71 | let height: i32 = (metrics.bounding_box.size.height / 2) as i32; 72 | let width: i32 = (metrics.bounding_box.size.width / 2) as i32; 73 | 74 | Text::with_baseline( 75 | &text, 76 | Point::new(128 / 2 - width, 40 / 2 - height), 77 | style, 78 | Baseline::Top, 79 | ) 80 | .draw(&mut buffer)?; 81 | 82 | Ok(buffer) 83 | } 84 | } 85 | 86 | impl ContentProvider for Clock { 87 | type ContentStream<'a> = impl Stream> + 'a; 88 | 89 | // This needs to be enabled until full GAT support is here 90 | #[allow(clippy::needless_lifetimes)] 91 | fn stream<'this>(&'this mut self) -> Result> { 92 | let mut interval = time::interval(Duration::from_millis(50)); 93 | interval.set_missed_tick_behavior(MissedTickBehavior::Skip); 94 | Ok(try_stream! { 95 | loop { 96 | if let Ok(image) = self.render() { 97 | yield image; 98 | } 99 | interval.tick().await; 100 | } 101 | }) 102 | } 103 | 104 | fn name(&self) -> &'static str { 105 | "clock" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/providers/coindesk.rs: -------------------------------------------------------------------------------- 1 | use crate::render::{ 2 | display::ContentProvider, 3 | scheduler::{ContentWrapper, CONTENT_PROVIDERS}, 4 | }; 5 | use anyhow::{anyhow, Result}; 6 | use apex_hardware::FrameBuffer; 7 | use async_rwlock::RwLock; 8 | use async_stream::try_stream; 9 | use config::Config; 10 | use embedded_graphics::{ 11 | geometry::{OriginDimensions, Point}, 12 | image::Image, 13 | mono_font::{iso_8859_15, MonoTextStyle}, 14 | pixelcolor::BinaryColor, 15 | text::{renderer::TextRenderer, Baseline, Text}, 16 | Drawable, 17 | }; 18 | use futures::Stream; 19 | use lazy_static::lazy_static; 20 | use linkme::distributed_slice; 21 | use log::info; 22 | use reqwest::{header, Client, ClientBuilder}; 23 | use serde::{Deserialize, Serialize}; 24 | use std::{convert::TryFrom, time::Duration}; 25 | use tinybmp::Bmp; 26 | use tokio::{time, time::MissedTickBehavior}; 27 | 28 | static BTC_ICON: &[u8] = include_bytes!("./../../assets/btc.bmp"); 29 | 30 | lazy_static! { 31 | static ref BTC_BMP: Bmp<'static, BinaryColor> = 32 | Bmp::::from_slice(BTC_ICON).expect("Failed to parse BMP for BTC icon!"); 33 | } 34 | 35 | #[distributed_slice(CONTENT_PROVIDERS)] 36 | pub static PROVIDER_INIT: fn(&Config) -> Result> = register_callback; 37 | 38 | #[derive(Debug, Copy, Clone)] 39 | pub enum Target { 40 | Eur, 41 | Usd, 42 | Gbp, 43 | } 44 | 45 | impl Default for Target { 46 | fn default() -> Self { 47 | Target::Usd 48 | } 49 | } 50 | 51 | impl TryFrom for Target { 52 | type Error = anyhow::Error; 53 | 54 | fn try_from(value: String) -> std::prelude::rust_2015::Result { 55 | match value.as_str() { 56 | "USD" | "usd" | "dollar" => Ok(Target::Usd), 57 | "eur" | "EUR" | "euro" | "Euro" => Ok(Target::Eur), 58 | "gbp" | "GBP" => Ok(Target::Gbp), 59 | _ => Err(anyhow!("Unknown target currency!")), 60 | } 61 | } 62 | } 63 | 64 | impl Target { 65 | pub fn format(self, price: &BitcoinPrice) -> String { 66 | match self { 67 | Target::Eur => format!("{}\u{20ac}", price.eur.rate), 68 | Target::Usd => format!("${}", price.usd.rate), 69 | Target::Gbp => format!("\u{a3}{}", price.gbp.rate), 70 | } 71 | } 72 | } 73 | 74 | #[allow(clippy::unnecessary_wraps)] 75 | fn register_callback(config: &Config) -> Result> { 76 | info!("Registering Coindesk display source."); 77 | let currency = config 78 | .get_str("crypto.currency") 79 | .unwrap_or_else(|_| String::from("USD")); 80 | let currency = Target::try_from(currency).unwrap_or_default(); 81 | Ok(Box::new(Coindesk::new(currency)?)) 82 | } 83 | 84 | const COINDESK_URL: &str = "https://api.coindesk.com/v1/bpi/currentprice.json"; 85 | 86 | static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); 87 | 88 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 89 | pub struct Currency { 90 | code: String, 91 | symbol: String, 92 | rate: String, 93 | description: String, 94 | rate_float: f64, 95 | } 96 | 97 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 98 | pub struct Time { 99 | updated: String, 100 | #[serde(rename(serialize = "updatedISO", deserialize = "updatedISO"))] 101 | updated_iso: String, 102 | updateduk: String, 103 | } 104 | 105 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 106 | pub struct BitcoinPrice { 107 | #[serde(rename(serialize = "USD", deserialize = "USD"))] 108 | usd: Currency, 109 | #[serde(rename(serialize = "GBP", deserialize = "GBP"))] 110 | gbp: Currency, 111 | #[serde(rename(serialize = "EUR", deserialize = "EUR"))] 112 | eur: Currency, 113 | } 114 | 115 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 116 | pub struct Status { 117 | time: Time, 118 | disclaimer: String, 119 | #[serde(rename(serialize = "chartName", deserialize = "chartName"))] 120 | chart_name: String, 121 | bpi: BitcoinPrice, 122 | } 123 | 124 | impl Status { 125 | pub fn render(&self, target: Target) -> Result { 126 | let mut buffer = FrameBuffer::new(); 127 | 128 | // TODO: Add support for EUR and GBP since we're fetching them anyway 129 | let text = target.format(&self.bpi); 130 | let style = MonoTextStyle::new(&iso_8859_15::FONT_6X13_BOLD, BinaryColor::On); 131 | Image::new( 132 | &*BTC_BMP, 133 | Point::new(0, 40 / 2 - (BTC_BMP.size().height / 2) as i32), 134 | ) 135 | .draw(&mut buffer)?; 136 | 137 | let metrics = style.measure_string(&text, Point::zero(), Baseline::Top); 138 | let height: i32 = (metrics.bounding_box.size.height / 2) as i32; 139 | Text::with_baseline(&text, Point::new(24, 40 / 2 - height), style, Baseline::Top) 140 | .draw(&mut buffer)?; 141 | Ok(buffer) 142 | } 143 | } 144 | 145 | #[derive(Debug, Clone, Default)] 146 | struct Coindesk { 147 | client: Client, 148 | target: Target, 149 | } 150 | 151 | impl Coindesk { 152 | pub fn new(target: Target) -> Result { 153 | let mut headers = header::HeaderMap::new(); 154 | headers.insert( 155 | header::CONTENT_TYPE, 156 | header::HeaderValue::from_static("application/json"), 157 | ); 158 | Ok(Coindesk { 159 | client: ClientBuilder::new() 160 | .user_agent(APP_USER_AGENT) 161 | .default_headers(headers) 162 | .build()?, 163 | target, 164 | }) 165 | } 166 | 167 | pub async fn fetch(&self) -> Result { 168 | let status = self 169 | .client 170 | .get(COINDESK_URL) 171 | .send() 172 | .await? 173 | .json::() 174 | .await?; 175 | 176 | Ok(status) 177 | } 178 | } 179 | 180 | impl ContentProvider for Coindesk { 181 | type ContentStream<'a> = impl Stream> + 'a; 182 | 183 | #[allow(clippy::needless_lifetimes)] 184 | fn stream<'this>(&'this mut self) -> Result> { 185 | // Coindesk updates its data every minute so we only need to fetch every minute 186 | let mut refetch = time::interval(Duration::from_secs(60)); 187 | refetch.set_missed_tick_behavior(MissedTickBehavior::Skip); 188 | 189 | // The scheduler expect a new image every so often so if no image is delivered 190 | // it'll just display a black image until the refetch timer ran. 191 | let mut render = time::interval(Duration::from_millis(50)); 192 | render.set_missed_tick_behavior(MissedTickBehavior::Skip); 193 | 194 | // We need some sort of synchronization between the task that displays the data 195 | // and the task that fetches it 196 | let status = RwLock::new(FrameBuffer::new()); 197 | 198 | Ok(try_stream! { 199 | loop { 200 | tokio::select! { 201 | _ = render.tick() => { 202 | let buffer = status.read().await; 203 | yield *buffer; 204 | }, 205 | _ = refetch.tick() => { 206 | let data = self.fetch().await.and_then(|d| d.render(self.target)); 207 | let mut buffer = status.write().await; 208 | if let Ok(data) = data { 209 | *buffer = data; 210 | } 211 | } 212 | } 213 | } 214 | }) 215 | } 216 | 217 | fn name(&self) -> &'static str { 218 | "coindesk" 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/providers/image.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | render::{display::ContentProvider, image, scheduler::ContentWrapper}, 3 | scheduler::CONTENT_PROVIDERS, 4 | }; 5 | use anyhow::Result; 6 | use apex_hardware::FrameBuffer; 7 | use async_stream::try_stream; 8 | use config::Config; 9 | use embedded_graphics::geometry::Point; 10 | use futures::Stream; 11 | use linkme::distributed_slice; 12 | use log::info; 13 | use std::fs::File; 14 | use tokio::{ 15 | time, 16 | time::{Duration, MissedTickBehavior}, 17 | }; 18 | 19 | #[doc(hidden)] 20 | #[distributed_slice(CONTENT_PROVIDERS)] 21 | pub static PROVIDER_INIT: fn(&Config) -> Result> = register_callback; 22 | 23 | #[doc(hidden)] 24 | #[allow(clippy::unnecessary_wraps)] 25 | fn register_callback(config: &Config) -> Result> { 26 | info!("Registering Image display source."); 27 | 28 | let image_path = config 29 | .get_str("image.path") 30 | .unwrap_or_else(|_| String::from("images/sample_1.gif")); 31 | let image_file = File::open(&image_path); 32 | 33 | let image = match image_file { 34 | Ok(file) => image::ImageRenderer::new(Point::new(0, 0), Point::new(128, 40), file), 35 | Err(err) => { 36 | log::error!("Failed to open the image '{}': {}", image_path, err); 37 | 38 | // Use the `new_error` function to create an error GIF 39 | image::ImageRenderer::new_error(Point::new(0, 0), Point::new(128, 40)) 40 | } 41 | }; 42 | 43 | Ok(Box::new(Image { image })) 44 | } 45 | 46 | pub struct Image { 47 | image: image::ImageRenderer, 48 | } 49 | 50 | impl Image { 51 | pub fn render(&self) -> Result { 52 | let mut buffer = FrameBuffer::new(); 53 | 54 | self.image.draw(&mut buffer); 55 | 56 | Ok(buffer) 57 | } 58 | } 59 | 60 | impl ContentProvider for Image { 61 | type ContentStream<'a> = impl Stream> + 'a; 62 | 63 | // This needs to be enabled until full GAT support is here 64 | #[allow(clippy::needless_lifetimes)] 65 | fn stream<'this>(&'this mut self) -> Result> { 66 | let mut interval = time::interval(Duration::from_millis(10)); 67 | //the delays in gifs are in increments of 10 ms 68 | // from wikipedia (in the table, look for the byte 324) 69 | // https://en.wikipedia.org/w/index.php?title=GIF&oldid=1157626024#Animated_GIF 70 | interval.set_missed_tick_behavior(MissedTickBehavior::Skip); 71 | Ok(try_stream! { 72 | loop { 73 | if let Ok(image) = self.render() { 74 | yield image; 75 | } 76 | interval.tick().await; 77 | } 78 | }) 79 | } 80 | 81 | fn name(&self) -> &'static str { 82 | "image" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/providers/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod clock; 2 | #[cfg(feature = "crypto")] 3 | pub(crate) mod coindesk; 4 | #[cfg(feature = "image")] 5 | pub(crate) mod image; 6 | #[cfg(any(feature = "dbus-support", target_os = "windows"))] 7 | pub(crate) mod music; 8 | #[cfg(feature = "sysinfo")] 9 | pub(crate) mod sysinfo; 10 | -------------------------------------------------------------------------------- /src/providers/music.rs: -------------------------------------------------------------------------------- 1 | use crate::render::display::ContentProvider; 2 | #[cfg(not(target_os = "windows"))] 3 | use anyhow::anyhow; 4 | use anyhow::Result; 5 | use async_stream::try_stream; 6 | #[cfg(not(target_os = "windows"))] 7 | use embedded_graphics::prelude::Primitive; 8 | #[cfg(not(target_os = "windows"))] 9 | use embedded_graphics::primitives::{Line, PrimitiveStyle}; 10 | use embedded_graphics::{ 11 | geometry::Size, image::Image, pixelcolor::BinaryColor, prelude::Point, Drawable, 12 | }; 13 | use futures_core::stream::Stream; 14 | use linkme::distributed_slice; 15 | 16 | use log::info; 17 | use tinybmp::Bmp; 18 | use tokio::time; 19 | 20 | use crate::render::{ 21 | scheduler::{ContentWrapper, CONTENT_PROVIDERS}, 22 | text::{ScrollableBuilder, StatefulScrollable}, 23 | }; 24 | use apex_music::{AsyncPlayer, Metadata, Progress}; 25 | use config::Config; 26 | use embedded_graphics::{ 27 | mono_font::{iso_8859_15, MonoTextStyle}, 28 | text::{Baseline, Text}, 29 | }; 30 | use futures::StreamExt; 31 | use std::{convert::TryInto, sync::Arc}; 32 | use tokio::time::{Duration, MissedTickBehavior}; 33 | 34 | use apex_hardware::FrameBuffer; 35 | use apex_music::PlaybackStatus; 36 | use futures::pin_mut; 37 | use lazy_static::lazy_static; 38 | 39 | static NOTE_ICON: &[u8] = include_bytes!("./../../assets/note.bmp"); 40 | static PAUSE_ICON: &[u8] = include_bytes!("./../../assets/pause.bmp"); 41 | 42 | lazy_static! { 43 | static ref PAUSE_BMP: Bmp<'static, BinaryColor> = 44 | Bmp::::from_slice(PAUSE_ICON).expect("Failed to parse BMP for pause icon!"); 45 | } 46 | 47 | lazy_static! { 48 | static ref NOTE_BMP: Bmp<'static, BinaryColor> = 49 | Bmp::::from_slice(NOTE_ICON).expect("Failed to parse BMP for note icon!"); 50 | } 51 | #[cfg(target_os = "windows")] 52 | lazy_static! { 53 | // Windows doesn't expose the current progress within the song so we don't draw 54 | // it here TODO: Spice this up? 55 | static ref PLAYER_TEMPLATE: FrameBuffer = FrameBuffer::new(); 56 | } 57 | 58 | #[cfg(not(target_os = "windows"))] 59 | lazy_static! { 60 | static ref PLAYER_TEMPLATE: FrameBuffer = { 61 | let mut base = FrameBuffer::new(); 62 | let style = PrimitiveStyle::with_stroke(BinaryColor::On, 1); 63 | 64 | let points = vec![ 65 | (Point::new(0, 39), Point::new(127, 39)), 66 | (Point::new(0, 39), Point::new(0, 39 - 5)), 67 | (Point::new(127, 39), Point::new(127, 39 - 5)), 68 | ]; 69 | 70 | // Draw a border for the progress bar 71 | points 72 | .iter() 73 | .try_for_each(|(first, second)| { 74 | Line::new(*first, *second) 75 | .into_styled(style) 76 | .draw(&mut base) 77 | }) 78 | .expect("Failed to prepare template image for music player!"); 79 | 80 | base 81 | }; 82 | } 83 | lazy_static! { 84 | static ref PLAY_TEMPLATE: FrameBuffer = { 85 | let mut base = *PLAYER_TEMPLATE; 86 | Image::new(&*NOTE_BMP, Point::new(5, 5)) 87 | .draw(&mut base) 88 | .expect("Failed to prepare 'play' template for music player"); 89 | base 90 | }; 91 | } 92 | lazy_static! { 93 | static ref PAUSE_TEMPLATE: FrameBuffer = { 94 | let mut base = *PLAYER_TEMPLATE; 95 | Image::new(&*PAUSE_BMP, Point::new(5, 5)) 96 | .draw(&mut base) 97 | .expect("Failed to prepare 'pause' template for music player"); 98 | base 99 | }; 100 | } 101 | lazy_static! { 102 | static ref IDLE_TEMPLATE: FrameBuffer = { 103 | let mut base = *PAUSE_TEMPLATE; 104 | let style = MonoTextStyle::new(&iso_8859_15::FONT_6X10, BinaryColor::On); 105 | Text::with_baseline( 106 | "No player found", 107 | Point::new(5 + 3 + 24, 3), 108 | style, 109 | Baseline::Top, 110 | ) 111 | .draw(&mut base) 112 | .expect("Failed to prepare 'idle' template for music player"); 113 | base 114 | }; 115 | } 116 | 117 | static UNKNOWN_TITLE: &str = "Unknown title"; 118 | static UNKNOWN_ARTIST: &str = "Unknown artist"; 119 | 120 | const RECONNECT_DELAY: u64 = 5; 121 | 122 | #[distributed_slice(CONTENT_PROVIDERS)] 123 | static PROVIDER_INIT: fn(&Config) -> Result> = register_callback; 124 | 125 | #[allow(clippy::unnecessary_wraps)] 126 | fn register_callback(config: &Config) -> Result> { 127 | info!("Registering MPRIS2 display source."); 128 | 129 | let player = match config.get_str("mpris2.preferred_player") { 130 | Ok(name) => MediaPlayerBuilder::new().with_player_name(name), 131 | Err(_) => MediaPlayerBuilder::new(), 132 | }; 133 | 134 | Ok(Box::new(player)) 135 | } 136 | 137 | #[derive(Debug, Clone, Default)] 138 | pub struct MediaPlayerBuilder { 139 | /// If a preference for the player is wanted specify this field 140 | name: Option>, 141 | } 142 | 143 | // Ok so the plan for the MPRIS2 module is to wait for two DBUS events 144 | // - PropertiesChanged to see if the song changed 145 | // - Seeked to see if the progress was changed manually 146 | // There's an existing mpris2 crate but it doesn't support async operation which 147 | // is kind of painful to use in this architecture. 148 | // When we received these events they should be mapped and put into another 149 | // queue. Upon receiving the event our code should pull the metadata from the 150 | // player. 151 | 152 | #[derive(Debug, Clone)] 153 | pub struct MediaPlayerRenderer { 154 | artist: StatefulScrollable, 155 | title: StatefulScrollable, 156 | } 157 | 158 | impl MediaPlayerRenderer { 159 | fn new() -> Result { 160 | let artist = ScrollableBuilder::new() 161 | .with_text(UNKNOWN_ARTIST) 162 | .with_custom_spacing(10) 163 | .with_position(Point::new(5 + 3 + 24, 3 + 10)) 164 | .with_projection(Size::new(16 * 6, 10)); 165 | let title = ScrollableBuilder::new() 166 | .with_text(UNKNOWN_TITLE) 167 | .with_custom_spacing(10) 168 | .with_position(Point::new(5 + 3 + 24, 3)) 169 | .with_projection(Size::new(16 * 6, 10)); 170 | 171 | Ok(Self { 172 | artist: artist.try_into()?, 173 | title: title.try_into()?, 174 | }) 175 | } 176 | 177 | pub fn update(&mut self, progress: &Progress) -> Result { 178 | let mut display = match progress.status { 179 | PlaybackStatus::Playing => *PLAY_TEMPLATE, 180 | PlaybackStatus::Paused | PlaybackStatus::Stopped => *PAUSE_TEMPLATE, 181 | }; 182 | 183 | let metadata = &progress.metadata; 184 | 185 | #[cfg(not(target_os = "windows"))] 186 | { 187 | let length = metadata.length().unwrap_or(0) as f64; 188 | 189 | let current = progress.position as f64; 190 | 191 | let completion = (current / length).clamp(0_f64, 1_f64); 192 | 193 | let pixels = (128_f64 - 2_f64 * 3_f64) * completion; 194 | let style = PrimitiveStyle::with_stroke(BinaryColor::On, 3); 195 | Line::new(Point::new(3, 35), Point::new(pixels as i32 + 3, 35)) 196 | .into_styled(style) 197 | .draw(&mut display)?; 198 | } 199 | 200 | let artists = metadata.artists()?; 201 | let title = metadata.title()?; 202 | 203 | if let Ok(false) = self.artist.update(&artists) { 204 | if artists.len() > 16 { 205 | self.artist.text.scroll(); 206 | } 207 | } 208 | 209 | if let Ok(false) = self.title.update(&title) { 210 | if title.len() > 16 { 211 | self.title.text.scroll(); 212 | } 213 | } 214 | 215 | self.title.text.draw(&mut display)?; 216 | self.artist.text.draw(&mut display)?; 217 | 218 | Ok(display) 219 | } 220 | } 221 | 222 | impl MediaPlayerBuilder { 223 | pub fn with_player_name(mut self, name: impl Into) -> Self { 224 | self.name = Some(Arc::new(name.into())); 225 | self 226 | } 227 | 228 | pub fn new() -> Self { 229 | Self::default() 230 | } 231 | } 232 | 233 | impl ContentProvider for MediaPlayerBuilder { 234 | type ContentStream<'a> = impl Stream> + 'a; 235 | 236 | // This needs to be enabled until full GAT support is here 237 | #[allow(clippy::needless_lifetimes)] 238 | fn stream<'this>(&'this mut self) -> Result> { 239 | info!( 240 | "Trying to connect to DBUS with player preference: {:?}", 241 | self.name 242 | ); 243 | 244 | let mut renderer = MediaPlayerRenderer::new()?; 245 | 246 | Ok(try_stream! { 247 | #[cfg(target_os = "windows")] 248 | let mpris = apex_windows::Player::new()?; 249 | #[cfg(target_os = "linux")] 250 | let mpris = apex_mpris2::MPRIS2::new().await?; 251 | pin_mut!(mpris); 252 | 253 | let mut interval = time::interval(Duration::from_secs(RECONNECT_DELAY)); 254 | interval.set_missed_tick_behavior(MissedTickBehavior::Skip); 255 | 'outer: loop { 256 | info!( 257 | "Trying to connect to DBUS with player preference: {:?}", 258 | self.name 259 | ); 260 | yield *IDLE_TEMPLATE; 261 | #[cfg(target_os = "windows")] 262 | let player = &mpris; 263 | #[cfg(target_os = "linux")] 264 | let player = mpris.wait_for_player(self.name.clone()).await?; 265 | 266 | info!("Connected to music player: {:?}", player.name().await); 267 | 268 | 269 | let tracker = mpris.stream().await?; 270 | pin_mut!(tracker); 271 | 272 | while let Some(_) = tracker.next().await { 273 | // TODO: We could probably save *some* resources here by making use of the event 274 | // that's being called but I don't see enough of a reason to do so at the moment 275 | if let Ok(progress) = player.progress().await { 276 | if let Ok(image) = renderer.update(&progress) { 277 | yield image; 278 | } 279 | } else { 280 | continue 'outer; 281 | } 282 | } 283 | } 284 | }) 285 | } 286 | 287 | fn name(&self) -> &'static str { 288 | "mpris2" 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /src/providers/sysinfo.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | render::{display::ContentProvider, scheduler::ContentWrapper}, 3 | scheduler::CONTENT_PROVIDERS, 4 | }; 5 | use anyhow::Result; 6 | use apex_hardware::FrameBuffer; 7 | use async_stream::try_stream; 8 | use num_traits::{pow, Pow}; 9 | 10 | use config::Config; 11 | use embedded_graphics::{ 12 | geometry::Point, 13 | mono_font::{iso_8859_15, MonoTextStyle}, 14 | pixelcolor::BinaryColor, 15 | primitives::{Primitive, PrimitiveStyle, Rectangle}, 16 | text::{renderer::TextRenderer, Baseline, Text}, 17 | Drawable, 18 | }; 19 | use futures::Stream; 20 | use linkme::distributed_slice; 21 | use log::{info, warn}; 22 | use tokio::{ 23 | time, 24 | time::{Duration, MissedTickBehavior}, 25 | }; 26 | 27 | use sysinfo::{ 28 | ComponentExt, CpuExt, CpuRefreshKind, NetworkData, NetworkExt, NetworksExt, RefreshKind, 29 | System, SystemExt, 30 | }; 31 | 32 | #[doc(hidden)] 33 | #[distributed_slice(CONTENT_PROVIDERS)] 34 | pub static PROVIDER_INIT: fn(&Config) -> Result> = register_callback; 35 | 36 | fn tick() -> i64 { 37 | chrono::offset::Utc::now().timestamp_millis() 38 | } 39 | 40 | #[doc(hidden)] 41 | #[allow(clippy::unnecessary_wraps)] 42 | fn register_callback(config: &Config) -> Result> { 43 | info!("Registering Sysinfo display source."); 44 | 45 | let refreshes = RefreshKind::new() 46 | .with_cpu(CpuRefreshKind::everything()) 47 | .with_components_list() 48 | .with_components() 49 | .with_networks_list() 50 | .with_networks() 51 | .with_memory(); 52 | let sys = System::new_with_specifics(refreshes); 53 | 54 | let tick = tick(); 55 | let last_tick = 0; 56 | 57 | let net_interface_name = config 58 | .get_str("sysinfo.net_interface_name") 59 | .unwrap_or("eth0".to_string()); 60 | 61 | if sys 62 | .networks() 63 | .iter() 64 | .find(|(name, _)| **name == net_interface_name) 65 | .is_none() 66 | { 67 | warn!("Couldn't find network interface `{}`", net_interface_name); 68 | info!("Instead, found those interfaces:"); 69 | for (interface_name, _) in sys.networks() { 70 | info!("\t{}", interface_name); 71 | } 72 | } 73 | 74 | let sensor_name = config 75 | .get_str("sysinfo.sensor_name") 76 | .unwrap_or("hwmon0 CPU Temperature".to_string()); 77 | 78 | if sys 79 | .components() 80 | .iter() 81 | .find(|component| component.label() == sensor_name) 82 | .is_none() 83 | { 84 | warn!("Couldn't find sensor `{}`", sensor_name); 85 | info!("Instead, found those sensors:"); 86 | for component in sys.components() { 87 | info!("\t{:?}", component); 88 | } 89 | } 90 | 91 | Ok(Box::new(Sysinfo { 92 | sys, 93 | tick, 94 | last_tick, 95 | refreshes, 96 | polling_interval: config.get_int("sysinfo.polling_interval").unwrap_or(2000) as u64, 97 | net_load_max: config.get_float("sysinfo.net_load_max").unwrap_or(100.0), 98 | cpu_frequency_max: config.get_float("sysinfo.cpu_frequency_max").unwrap_or(7.0), 99 | temperature_max: config.get_float("sysinfo.temperature_max").unwrap_or(100.0), 100 | net_interface_name, 101 | sensor_name, 102 | })) 103 | } 104 | 105 | struct Sysinfo { 106 | sys: System, 107 | refreshes: RefreshKind, 108 | 109 | tick: i64, 110 | last_tick: i64, 111 | 112 | polling_interval: u64, 113 | 114 | net_load_max: f64, 115 | cpu_frequency_max: f64, 116 | temperature_max: f64, 117 | 118 | net_interface_name: String, 119 | sensor_name: String, 120 | } 121 | 122 | impl Sysinfo { 123 | pub fn render(&mut self) -> Result { 124 | self.poll(); 125 | 126 | let load = self.sys.global_cpu_info().cpu_usage() as f64; 127 | let freq = self.sys.global_cpu_info().frequency() as f64 / 1000.0; 128 | let mem_used = self.sys.used_memory() as f64 / pow(1024, 3) as f64; 129 | 130 | let mut buffer = FrameBuffer::new(); 131 | 132 | self.render_stat(0, &mut buffer, format!("C: {:>4.0}%", load), load / 100.0)?; 133 | self.render_stat( 134 | 1, 135 | &mut buffer, 136 | format!("F: {:>4.2}G", freq), 137 | freq / self.cpu_frequency_max, 138 | )?; 139 | self.render_stat( 140 | 2, 141 | &mut buffer, 142 | format!("M: {:>4.1}G", mem_used), 143 | self.sys.used_memory() as f64 / self.sys.total_memory() as f64, 144 | )?; 145 | 146 | if let Some(n) = self 147 | .sys 148 | .networks() 149 | .iter() 150 | .find(|(name, _)| **name == self.net_interface_name) 151 | .map(|t| t.1) 152 | { 153 | let net_direction = if n.received() > n.transmitted() { 154 | "I" 155 | } else { 156 | "O" 157 | }; 158 | 159 | let (net_load, net_load_power, net_load_unit) = self.calculate_max_net_rate(n); 160 | let mut adjusted_net_load = format!( 161 | "{:.4}", 162 | (net_load / 1024_f64.pow(net_load_power)).to_string() 163 | ); 164 | 165 | if adjusted_net_load.ends_with(".") { 166 | adjusted_net_load = adjusted_net_load.replace(".", ""); 167 | } 168 | 169 | let _ = self.render_stat( 170 | 3, 171 | &mut buffer, 172 | format!( 173 | "{}: {:>4}{}", 174 | net_direction, adjusted_net_load, net_load_unit 175 | ), 176 | net_load / (self.net_load_max * 1024_f64.pow(2)), 177 | ); 178 | }; 179 | 180 | if let Some(c) = self 181 | .sys 182 | .components() 183 | .iter() 184 | .find(|component| component.label() == self.sensor_name) 185 | { 186 | let _ = self.render_stat( 187 | 4, 188 | &mut buffer, 189 | format!("T: {:>4.1}C", c.temperature()), 190 | c.temperature() as f64 / self.temperature_max, 191 | ); 192 | } 193 | 194 | Ok(buffer) 195 | } 196 | 197 | fn calculate_max_net_rate(&self, net: &NetworkData) -> (f64, i32, &str) { 198 | let max_diff = std::cmp::max(net.received(), net.transmitted()) as f64; 199 | let max_rate = max_diff / ((self.tick - self.last_tick) as f64 / 1000.0); 200 | 201 | match max_rate { 202 | r if r > 1024_f64.pow(3) => (r, 3, "G"), 203 | r if r > 1024_f64.pow(2) => (r, 2, "M"), 204 | r if r > 1024_f64.pow(1) => (r, 1, "k"), 205 | r => (r, 0, "B"), 206 | } 207 | } 208 | 209 | fn poll(&mut self) { 210 | self.sys.refresh_specifics(self.refreshes); 211 | 212 | self.last_tick = self.tick; 213 | self.tick = tick(); 214 | } 215 | 216 | fn render_stat( 217 | &self, 218 | slot: i32, 219 | buffer: &mut FrameBuffer, 220 | text: String, 221 | fill: f64, 222 | ) -> Result<()> { 223 | let style = MonoTextStyle::new(&iso_8859_15::FONT_4X6, BinaryColor::On); 224 | let metrics = style.measure_string(&text, Point::zero(), Baseline::Top); 225 | 226 | let slot_y = slot * 8 + 1; 227 | 228 | Text::with_baseline(&text, Point::new(0, slot_y), style, Baseline::Top).draw(buffer)?; 229 | 230 | let bar_start: i32 = metrics.bounding_box.size.width as i32 + 2; 231 | let border_style = PrimitiveStyle::with_stroke(BinaryColor::On, 1); 232 | let fill_style = PrimitiveStyle::with_fill(BinaryColor::On); 233 | let fill_width = if fill.is_infinite() { 234 | 0 235 | } else { 236 | (fill * (127 - bar_start) as f64).floor() as i32 237 | }; 238 | 239 | Rectangle::with_corners(Point::new(bar_start, slot_y), Point::new(127, slot_y + 6)) 240 | .into_styled(border_style) 241 | .draw(buffer)?; 242 | 243 | Rectangle::with_corners( 244 | Point::new(bar_start + 1, slot_y + 1), 245 | Point::new(bar_start + fill_width, slot_y + 5), 246 | ) 247 | .into_styled(fill_style) 248 | .draw(buffer)?; 249 | 250 | Ok(()) 251 | } 252 | } 253 | 254 | impl ContentProvider for Sysinfo { 255 | type ContentStream<'a> = impl Stream> + 'a; 256 | 257 | fn stream<'this>(&'this mut self) -> Result> { 258 | let mut interval = time::interval(Duration::from_millis(self.polling_interval)); 259 | interval.set_missed_tick_behavior(MissedTickBehavior::Skip); 260 | 261 | Ok(try_stream! { 262 | loop { 263 | if let Ok(image) = self.render() { 264 | yield image; 265 | } 266 | interval.tick().await; 267 | } 268 | }) 269 | } 270 | 271 | fn name(&self) -> &'static str { 272 | "sysinfo" 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/render/debug.rs: -------------------------------------------------------------------------------- 1 | use crate::render::{ 2 | display::{ContentProvider, FrameBuffer}, 3 | scheduler::{ContentWrapper, CONTENT_PROVIDERS}, 4 | }; 5 | use anyhow::Result; 6 | use async_stream::try_stream; 7 | use config::Config; 8 | use embedded_graphics::{ 9 | pixelcolor::BinaryColor, 10 | prelude::Point, 11 | primitives::{Line, Primitive, PrimitiveStyle}, 12 | Drawable, 13 | }; 14 | use futures::Stream; 15 | use linkme::distributed_slice; 16 | use log::info; 17 | use tokio::{ 18 | time, 19 | time::{Duration, MissedTickBehavior}, 20 | }; 21 | 22 | #[distributed_slice(CONTENT_PROVIDERS)] 23 | static PROVIDER_INIT: fn(&Config) -> Result> = register_callback; 24 | 25 | #[allow(clippy::unnecessary_wraps)] 26 | fn register_callback(_config: &Config) -> Result> { 27 | info!("Registering dummy display source."); 28 | let provider = Box::new(DummyProvider {}); 29 | Ok(provider) 30 | } 31 | 32 | struct DummyProvider; 33 | 34 | impl ContentProvider for DummyProvider { 35 | type ContentStream<'a> = impl Stream> + 'a; 36 | 37 | #[allow(clippy::needless_lifetimes)] 38 | fn stream<'this>(&'this mut self) -> Result> { 39 | let mut interval = time::interval(Duration::from_millis(50)); 40 | interval.set_missed_tick_behavior(MissedTickBehavior::Skip); 41 | Ok(try_stream! { 42 | let mut x_index = 0; 43 | let mut y_index = 0; 44 | 45 | let style = PrimitiveStyle::with_stroke(BinaryColor::On, 2); 46 | 47 | loop { 48 | let mut display = FrameBuffer::new(); 49 | Line::new(Point::new(x_index, 0), Point::new(x_index, 39)).into_styled(style).draw(&mut display)?; 50 | Line::new(Point::new(0, y_index), Point::new(127, y_index)).into_styled(style).draw(&mut display)?; 51 | yield display; 52 | interval.tick().await; 53 | x_index = x_index.wrapping_add(1) % 128; 54 | y_index = y_index.wrapping_add(1) % 40; 55 | } 56 | }) 57 | } 58 | 59 | fn name(&self) -> &'static str { 60 | "dummy" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/render/display.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | pub use apex_hardware::FrameBuffer; 4 | use futures_core::Stream; 5 | 6 | pub trait ContentProvider { 7 | type ContentStream<'a>: Stream> + 'a 8 | where 9 | Self: 'a; 10 | 11 | #[allow(clippy::needless_lifetimes)] 12 | fn stream<'this>(&'this mut self) -> Result>; 13 | fn name(&self) -> &'static str; 14 | } 15 | -------------------------------------------------------------------------------- /src/render/image.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cell::RefCell, 3 | fs::File, 4 | io::Read, 5 | rc::Rc, 6 | sync::atomic::{AtomicUsize, Ordering}, 7 | time::{Duration, Instant}, 8 | }; 9 | 10 | use apex_hardware::FrameBuffer; 11 | use embedded_graphics::{ 12 | image::{Image, ImageRaw}, 13 | pixelcolor::BinaryColor, 14 | prelude::Point, 15 | Drawable, 16 | }; 17 | use image::{AnimationDecoder, DynamicImage}; 18 | 19 | static GIF_MISSING: &[u8] = include_bytes!("./../../assets/gif_missing.gif"); 20 | static DISPLAY_HEIGHT: i32 = 40; 21 | static DISPLAY_WIDTH: i32 = 128; 22 | 23 | pub struct ImageRenderer { 24 | stop: Point, 25 | origin: Point, 26 | decoded_frames: Vec>, 27 | current_frame: AtomicUsize, 28 | delays: Vec, 29 | time_frame_last_update: Rc>, 30 | } 31 | 32 | impl ImageRenderer { 33 | pub fn calculate_median_color_value( 34 | image: &image::ImageBuffer, Vec>, 35 | image_height: i32, 36 | image_width: i32, 37 | ) -> u8 { 38 | //NOTE we're using the median to determine wether the pixel should be black or 39 | // white 40 | 41 | let mut colors = (0..=255).into_iter().map(|_| 0).collect::>(); 42 | let mut num_pixels_alpha = 0; 43 | 44 | let height = image.height(); 45 | let width = image.width(); 46 | 47 | for y in 0..image_height { 48 | //if y is outside of the gif width 49 | if y >= height as i32 { 50 | continue; 51 | } 52 | 53 | //if y is outside of the screen 54 | if y >= DISPLAY_HEIGHT { 55 | continue; 56 | } 57 | for x in 0..image_width { 58 | //if x is outside of the gif width 59 | if x >= width as i32 { 60 | continue; 61 | } 62 | 63 | //if x is outside of the screen 64 | if x >= DISPLAY_WIDTH { 65 | continue; 66 | } 67 | 68 | let pixel = image.get_pixel(x as u32, y as u32); 69 | 70 | let avg_pixel_value = 71 | ((u32::from(pixel[0]) + u32::from(pixel[1]) + u32::from(pixel[2])) / 3) 72 | as usize; 73 | 74 | //the value is multiplied by the alpha (a) of said pixel 75 | //the more the pixel is transparent, the less the pixel has an importance 76 | colors[avg_pixel_value] += u32::from(pixel[3]); 77 | 78 | //We need the number of non-transparent pixels 79 | num_pixels_alpha += u32::from(pixel[3]); 80 | } 81 | } 82 | //the alpha are in the 0-255 range 83 | num_pixels_alpha /= 255; 84 | 85 | let mut sum = 0; 86 | for (color_value, count) in colors.iter().enumerate() { 87 | sum += *count / 255; 88 | 89 | if sum >= num_pixels_alpha / 2 { 90 | if color_value == 0 { 91 | return 1; 92 | } 93 | return color_value as u8; 94 | } 95 | } 96 | 97 | 1 98 | } 99 | 100 | pub fn read_image( 101 | image: &image::ImageBuffer, Vec>, 102 | image_height: i32, 103 | image_width: i32, 104 | ) -> Vec { 105 | // We first get the median "color" of the frame 106 | let median_color = Self::calculate_median_color_value(image, image_height, image_width); 107 | 108 | let mut frame_data = Vec::new(); 109 | let mut buf: u8 = 0; 110 | 111 | let height = image.height(); 112 | let width = image.width(); 113 | 114 | for y in 0..image_height { 115 | //if y is outside of the gif width 116 | if y >= height as i32 { 117 | continue; 118 | } 119 | 120 | //if y is outside of the screen 121 | if y >= DISPLAY_HEIGHT { 122 | continue; 123 | } 124 | for x in 0..image_width { 125 | //since we're using an array of u8, every 8 bit we need to start with a new int 126 | if x % 8 == 0 && x != 0 { 127 | frame_data.push(buf); 128 | buf = 0; 129 | } 130 | //if x is outside of the gif width 131 | if x >= width as i32 { 132 | continue; 133 | } 134 | 135 | //if x is outside of the screen 136 | if x >= DISPLAY_WIDTH { 137 | continue; 138 | } 139 | 140 | //getting the value of the pixel 141 | let pixel = image.get_pixel(x as u32, y as u32); 142 | 143 | let mean = (u32::from(pixel[0]) / 3) 144 | + (u32::from(pixel[1]) / 3) 145 | + (u32::from(pixel[2]) / 3); 146 | //I'm not sure if we should do something with the alpha channel of the gif 147 | //I decided not to, but maybe we should 148 | 149 | if mean >= u32::from(median_color) { 150 | //which bit to turn on 151 | let shift = x % 8; 152 | buf += 128 >> shift; 153 | } 154 | } 155 | //we forcibly push the frame to the buffer after each line 156 | frame_data.push(buf); 157 | buf = 0; 158 | } 159 | frame_data 160 | } 161 | 162 | pub fn fit_image(image: DynamicImage, size: Point) -> DynamicImage { 163 | if image.height() > size.y as u32 { 164 | let width = image.width() * size.y as u32 / image.height(); 165 | let height = size.y as u32; 166 | 167 | image.resize(width, height, image::imageops::FilterType::Nearest) 168 | } else if image.width() > size.x as u32 { 169 | let width = size.x as u32; 170 | let height = image.height() * size.x as u32 / image.width(); 171 | 172 | image.resize(width, height, image::imageops::FilterType::Nearest) 173 | } else { 174 | image 175 | } 176 | } 177 | 178 | pub fn read_dynamic_image( 179 | origin: Point, 180 | stop: Point, 181 | image: DynamicImage, 182 | buffer: &[u8], 183 | ) -> Self { 184 | //we first get the dimension of the image 185 | let image_height = stop.y - origin.y; 186 | let image_width = stop.x - origin.x; 187 | 188 | let mut decoded_frames = Vec::new(); 189 | let mut delays = Vec::new(); 190 | 191 | if let Ok(gif) = image::codecs::gif::GifDecoder::new(&buffer[..]) { 192 | //if the image is a gif 193 | //NOTE we do not check for the size of each frame! 194 | //We can avoid doing so since we have the Self::fit_image which will resize the 195 | // frames correctly. 196 | 197 | //we go through each frame 198 | for frame in gif.into_frames() { 199 | //TODO we do not handle if the frame isn't formatted properly! 200 | if let Ok(frame) = frame { 201 | //TODO some gifs do not have delays embedded, we should use a 100 ms in that 202 | // case 203 | delays.push(Duration::from(frame.delay()).as_millis() as u16); 204 | let resized = Self::fit_image( 205 | DynamicImage::ImageRgba8(frame.into_buffer()), 206 | Point::new(DISPLAY_WIDTH, DISPLAY_HEIGHT), 207 | ); 208 | 209 | decoded_frames.push(Self::read_image( 210 | &resized.into_rgba8(), 211 | image_height, 212 | image_width, 213 | )); 214 | } 215 | } 216 | } else { 217 | let resized = Self::fit_image(image, Point::new(DISPLAY_WIDTH, DISPLAY_HEIGHT)); 218 | //if the image is a still image 219 | decoded_frames.push(Self::read_image( 220 | &resized.into_rgba8(), 221 | image_height, 222 | image_width, 223 | )); 224 | delays.push(500); // Add a default delay of 500ms for single image 225 | // rendering 226 | } 227 | 228 | Self { 229 | stop, 230 | origin, 231 | decoded_frames, 232 | current_frame: AtomicUsize::new(0), 233 | delays, 234 | time_frame_last_update: Rc::new(RefCell::new(Instant::now())), 235 | } 236 | } 237 | 238 | pub fn new(origin: Point, stop: Point, mut file: File) -> Self { 239 | let mut buffer = Vec::new(); 240 | if let Ok(_) = file.read_to_end(&mut buffer) { 241 | if let Ok(image) = image::load_from_memory(&buffer) { 242 | Self::read_dynamic_image(origin, stop, image, &buffer) 243 | } else { 244 | log::error!("Failed to decode the image."); 245 | Self::new_error(origin, stop) 246 | } 247 | } else { 248 | log::error!("Failed to read the image file."); 249 | Self::new_error(origin, stop) 250 | } 251 | } 252 | 253 | pub fn new_error(origin: Point, stop: Point) -> Self { 254 | Self::new_u8(origin, stop, GIF_MISSING) 255 | } 256 | 257 | pub fn new_u8(origin: Point, stop: Point, u8_array: &[u8]) -> Self { 258 | if let Ok(image) = image::load_from_memory(u8_array) { 259 | Self::read_dynamic_image(origin, stop, image, u8_array) 260 | } else { 261 | log::error!("Failed to decode the image."); 262 | Self::new_error(origin, stop) 263 | } 264 | } 265 | 266 | pub fn draw(&self, target: &mut FrameBuffer) -> bool { 267 | let frame = self.current_frame.load(Ordering::Relaxed); 268 | 269 | //get the data for the specified frame 270 | let frame_data = &self.decoded_frames[frame]; 271 | 272 | //convert the data to an ImageRaw 273 | let raw_image_frame = 274 | ImageRaw::::new(&frame_data, (self.stop.x - self.origin.x) as u32); 275 | 276 | //draw the ImageRaw on the buffer 277 | let _ = Image::new(&raw_image_frame, self.origin).draw(target); 278 | 279 | //detect if we should change the frame 280 | let last_display_time = self.time_frame_last_update.borrow().clone(); 281 | let current_time = Instant::now(); 282 | let elapsed_time = current_time - last_display_time; 283 | 284 | if elapsed_time >= Duration::from_millis(u64::from(self.delays[frame])) { 285 | //the delays in the image crate isn't in increment of 10ms compared to the gif 286 | // crate! before we had a *10 because of it 287 | 288 | //update the variable only if we update the frame 289 | *self.time_frame_last_update.borrow_mut() = current_time; 290 | 291 | //increment the current_frame using atomic operations 292 | let next_frame = frame + 1; 293 | 294 | let has_gif_ended = next_frame >= self.decoded_frames.len(); 295 | if has_gif_ended { 296 | //reset to frame 0 297 | self.current_frame.store(0, Ordering::Relaxed); 298 | } else { 299 | self.current_frame.store(next_frame, Ordering::Relaxed); 300 | } 301 | return has_gif_ended; 302 | } 303 | false 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /src/render/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "debug")] 2 | pub(crate) mod debug; 3 | pub(crate) mod display; 4 | // This technically doesn't need DBus but nothing else implements it atm 5 | #[cfg(feature = "image")] 6 | pub(crate) mod image; 7 | #[allow(dead_code)] 8 | pub(crate) mod notifications; 9 | pub mod scheduler; 10 | pub(crate) mod stream; 11 | pub(crate) mod text; 12 | pub(crate) mod util; 13 | -------------------------------------------------------------------------------- /src/render/notifications.rs: -------------------------------------------------------------------------------- 1 | use crate::render::display::ContentProvider; 2 | use anyhow::{anyhow, Result}; 3 | use async_stream::try_stream; 4 | use embedded_graphics::{ 5 | geometry::{OriginDimensions, Point, Size}, 6 | image::Image, 7 | pixelcolor::BinaryColor, 8 | Drawable, 9 | }; 10 | use num_traits::AsPrimitive; 11 | 12 | use crate::render::{ 13 | scheduler::{TICKS_PER_SECOND, TICK_LENGTH}, 14 | text::{Scrollable, ScrollableBuilder}, 15 | util::ProgressBar, 16 | }; 17 | use embedded_graphics::{ 18 | mono_font::{iso_8859_15, MonoFont, MonoTextStyle}, 19 | text::Text, 20 | }; 21 | use futures_core::stream::Stream; 22 | 23 | use apex_hardware::FrameBuffer; 24 | use tinybmp::Bmp; 25 | use tokio::{ 26 | time, 27 | time::{Duration, MissedTickBehavior}, 28 | }; 29 | 30 | pub struct Notification { 31 | frame: FrameBuffer, 32 | ticks: u32, 33 | title: Scrollable, 34 | scroll: bool, 35 | content: String, 36 | } 37 | 38 | #[derive(Debug, Clone)] 39 | pub struct Icon<'a>(Bmp<'a, BinaryColor>); 40 | 41 | impl<'a> Icon<'a> { 42 | pub fn new(icon: Bmp<'a, BinaryColor>) -> Self { 43 | Self(icon) 44 | } 45 | } 46 | 47 | #[derive(Debug, Clone, Default)] 48 | pub struct NotificationBuilder<'a> { 49 | title: Option<&'a str>, 50 | content: Option, 51 | icon: Option>, 52 | font: Option<&'a MonoFont<'a>>, 53 | } 54 | 55 | pub trait NotificationProvider { 56 | type NotificationStream<'a>: Stream> + 'a 57 | where 58 | Self: 'a; 59 | 60 | #[allow(clippy::needless_lifetimes)] 61 | fn stream<'this>(&'this mut self) -> Result>; 62 | } 63 | 64 | impl ContentProvider for Notification { 65 | type ContentStream<'a> = impl Stream> + 'a; 66 | 67 | // This needs to be enabled until full GAT support is here 68 | #[allow(clippy::needless_lifetimes)] 69 | fn stream<'this>(&'this mut self) -> Result> { 70 | let mut interval = time::interval(Duration::from_millis(TICK_LENGTH.as_())); 71 | interval.set_missed_tick_behavior(MissedTickBehavior::Skip); 72 | let origin = Point::new(117, 29); 73 | let progress = ProgressBar::new(origin, self.ticks as f32); 74 | 75 | // TODO: Remove hardcoded font 76 | let style = MonoTextStyle::new(&iso_8859_15::FONT_6X10, BinaryColor::On); 77 | 78 | Ok(try_stream! { 79 | for i in 0..self.ticks { 80 | let mut image = self.frame.clone(); 81 | self.title.at_tick(&mut image, if self.scroll { 82 | i 83 | } else { 84 | 0 85 | })?; 86 | Text::new(&self.content, Point::new(3 + 24, 10 + 10), style).draw(&mut image)?; 87 | progress.draw_at(i as f32, &mut image)?; 88 | yield image; 89 | interval.tick().await; 90 | } 91 | }) 92 | } 93 | 94 | fn name(&self) -> &'static str { 95 | "notification" 96 | } 97 | } 98 | 99 | impl<'a> NotificationBuilder<'a> { 100 | pub fn new() -> Self { 101 | NotificationBuilder::default() 102 | } 103 | 104 | pub fn with_content(mut self, content: impl Into) -> Self { 105 | self.content = Some(content.into()); 106 | self 107 | } 108 | 109 | pub fn with_title(mut self, title: &'a str) -> Self { 110 | self.title = Some(title); 111 | self 112 | } 113 | 114 | pub fn with_icon(mut self, icon: Icon<'a>) -> Self { 115 | self.icon = Some(icon); 116 | self 117 | } 118 | 119 | fn title(&self) -> &'a str { 120 | self.title.unwrap_or("Notification") 121 | } 122 | 123 | fn font(&self) -> &'a MonoFont { 124 | self.font.unwrap_or(&iso_8859_15::FONT_6X10) 125 | } 126 | 127 | fn offset(&self) -> Size { 128 | self.icon 129 | .as_ref() 130 | .map_or_else(Size::zero, |icon| icon.0.size()) 131 | + Size::new(3, 10) 132 | } 133 | 134 | fn projection(&self) -> Size { 135 | let offset = self.offset(); 136 | let display_size = Size::new(128, 40); 137 | let height = self.font().character_size.height; 138 | let width = (display_size - offset).width - 3; 139 | 140 | Size::new(width, height) 141 | } 142 | 143 | fn projection_characters(&self) -> u32 { 144 | let font = self.font(); 145 | let projection = self.projection(); 146 | 147 | projection.width / font.character_size.width 148 | } 149 | 150 | fn needs_scroll(&self) -> bool { 151 | let length = self.title().len(); 152 | (self.projection_characters() as usize) < length 153 | } 154 | 155 | fn required_ticks(&self) -> u32 { 156 | let title = self.title(); 157 | let font = self.font(); 158 | let scroll_time = if self.needs_scroll() { 159 | (title.len() - self.projection_characters() as usize + 2) 160 | * font.character_size.width as usize 161 | } else { 162 | 0 163 | }; 164 | 165 | (TICKS_PER_SECOND + scroll_time + TICKS_PER_SECOND).as_() 166 | } 167 | 168 | pub fn build(self) -> Result { 169 | let mut base_image = FrameBuffer::new(); 170 | 171 | // We have an icon so lets draw it 172 | if let Some(icon) = &self.icon { 173 | let Size { width, height } = icon.0.size(); 174 | 175 | if width != 24 || height != 24 { 176 | return Err(anyhow!( 177 | "Notification icons need to be 24x24 for the time being!" 178 | )); 179 | } 180 | 181 | Image::new(&icon.0, Point::zero()).draw(&mut base_image)?; 182 | } 183 | 184 | let size = self.offset(); 185 | let projection = self.projection(); 186 | let offset = Point::new(size.width.as_(), 3); 187 | 188 | let title = ScrollableBuilder::new() 189 | .with_text(self.title()) 190 | .with_position(offset) 191 | .with_projection(projection) 192 | .build()?; 193 | 194 | Ok(Notification { 195 | frame: base_image, 196 | ticks: self.required_ticks(), 197 | title, 198 | scroll: self.needs_scroll(), 199 | content: self.content.unwrap_or_default(), 200 | }) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/render/scheduler.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use std::{ 3 | cell::RefCell, 4 | marker::PhantomData, 5 | rc::Rc, 6 | time::{Duration, Instant}, 7 | }; 8 | 9 | use crate::render::{ 10 | display::ContentProvider, 11 | notifications::{Notification, NotificationProvider}, 12 | stream::multiplex, 13 | }; 14 | use apex_hardware::{AsyncDevice, FrameBuffer}; 15 | use apex_input::Command; 16 | use config::Config; 17 | use futures::{pin_mut, stream, stream::Stream, StreamExt}; 18 | use itertools::Itertools; 19 | use linkme::distributed_slice; 20 | use log::{error, info}; 21 | use std::sync::{ 22 | atomic::{AtomicUsize, Ordering}, 23 | Arc, 24 | }; 25 | use tokio::{ 26 | sync::broadcast, 27 | time::{self, MissedTickBehavior}, 28 | }; 29 | 30 | pub const TICK_LENGTH: usize = 50; 31 | pub const TICKS_PER_SECOND: usize = 1000 / TICK_LENGTH; 32 | 33 | #[distributed_slice] 34 | pub static CONTENT_PROVIDERS: [fn(&Config) -> Result>] = [..]; 35 | 36 | #[distributed_slice] 37 | pub static NOTIFICATION_PROVIDERS: [fn() -> Result>] = [..]; 38 | 39 | pub trait NotificationWrapper { 40 | fn proxy_stream<'a>(&'a mut self) -> Result> + 'a>>; 41 | } 42 | 43 | impl NotificationWrapper for T { 44 | fn proxy_stream<'this>( 45 | &'this mut self, 46 | ) -> Result> + 'this>> { 47 | let x = ::stream(self)?; 48 | Ok(Box::new(x.fuse())) 49 | } 50 | } 51 | 52 | pub trait ContentWrapper { 53 | fn proxy_stream<'a>(&'a mut self) -> Result> + 'a>>; 54 | fn provider_name(&self) -> &'static str; 55 | } 56 | 57 | impl ContentWrapper for T { 58 | fn proxy_stream<'this>( 59 | &'this mut self, 60 | ) -> Result> + 'this>> { 61 | let x = ::stream(self)?; 62 | Ok(Box::new(x.fuse())) 63 | } 64 | 65 | fn provider_name(&self) -> &'static str { 66 | self.name() 67 | } 68 | } 69 | 70 | pub struct Scheduler<'a, T: AsyncDevice + 'a> { 71 | device: T, 72 | _marker: PhantomData<&'a T>, 73 | } 74 | 75 | impl<'a, T: 'a + AsyncDevice> Scheduler<'a, T> { 76 | pub fn new(device: T) -> Self { 77 | Self { 78 | device, 79 | _marker: PhantomData::default(), 80 | } 81 | } 82 | 83 | pub async fn start( 84 | &mut self, 85 | tx: broadcast::Sender, 86 | rx: broadcast::Receiver, 87 | mut config: Config, 88 | ) -> Result<()> { 89 | #[cfg(not(target_os = "macos"))] 90 | let mut providers = CONTENT_PROVIDERS 91 | .iter() 92 | .map(|f| (f)(&mut config)) 93 | .collect::>>()?; 94 | 95 | #[cfg(target_os = "macos")] 96 | let mut providers = [ 97 | crate::providers::clock::PROVIDER_INIT(&mut config)?, 98 | crate::providers::coindesk::PROVIDER_INIT(&mut config)?, 99 | ]; 100 | 101 | let mut notifications = NOTIFICATION_PROVIDERS 102 | .iter() 103 | .map(|f| (f)()) 104 | .collect::>>()?; 105 | 106 | let (notifications, errors): (Vec<_>, Vec<_>) = notifications 107 | .iter_mut() 108 | .map(|s| s.proxy_stream().map(Box::into_pin)) 109 | .partition_result(); 110 | 111 | for e in errors { 112 | error!("{}", e); 113 | } 114 | 115 | let mut notifications = stream::select_all(notifications.into_iter()); 116 | 117 | let current = Arc::new(AtomicUsize::new(0)); 118 | info!("Found {} registered providers", providers.len()); 119 | 120 | pin_mut!(rx); 121 | 122 | let (providers, errors): (Vec<_>, Vec<_>) = providers 123 | .iter_mut() 124 | .map(|i| (i.provider_name(), i.proxy_stream())) 125 | .filter(|(name, _)| { 126 | let key = format!("{}.enabled", name); 127 | config.get_bool(&key).unwrap_or(true) 128 | }) 129 | .map(|(name, i)| { 130 | let key = format!("{}.priority", name); 131 | let prio = config.get_int(&key).unwrap_or(99i64); 132 | (name, i, prio) 133 | }) 134 | .sorted_by_key(|(_, _, prio)| *prio) 135 | .map(|(name, i, _)| { 136 | i.map_err(|e| anyhow!("Failed to initialize provider: {}. Error: {}", name, e)) 137 | }) 138 | .partition_result(); 139 | 140 | for e in errors { 141 | error!("{}", e); 142 | } 143 | 144 | let providers = providers 145 | .into_iter() 146 | .into_iter() 147 | .map(Box::into_pin) 148 | .map(StreamExt::fuse) 149 | .collect::>(); 150 | let size = providers.len(); 151 | let z = current.clone(); 152 | 153 | let mut y = multiplex(providers, move || z.load(Ordering::SeqCst)); 154 | 155 | //get the interval 156 | let interval_between_change = config.get_int("interval.refresh").unwrap_or(30); 157 | //flag to know if auto changer is enabled 158 | let is_auto_change_enabled = interval_between_change != 0; 159 | //the interval to check wether to change the screen or not 160 | let mut change = time::interval(Duration::from_secs(if !is_auto_change_enabled { 161 | // this is done for performance (don't know if it actually has a big impact) 162 | 300 163 | } else { 164 | 1 165 | })); 166 | change.set_missed_tick_behavior(MissedTickBehavior::Skip); 167 | //the last time the screen was changed 168 | let time_last_change = Rc::new(RefCell::new(Instant::now())); 169 | loop { 170 | tokio::select! { 171 | cmd = rx.recv() => { 172 | //update the last time the screen was updated to now 173 | *time_last_change.borrow_mut() = Instant::now(); 174 | match cmd { 175 | Ok(Command::Shutdown) => break, 176 | Ok(Command::NextSource) => { 177 | let new = current.load(Ordering::SeqCst).wrapping_add(1) % size; 178 | current.store(new, Ordering::SeqCst); 179 | self.device.clear().await?; 180 | }, 181 | Ok(Command::PreviousSource) => { 182 | let new = match current.load(Ordering::SeqCst) { 183 | 0 => size - 1, 184 | n => (n - 1) % size 185 | }; 186 | current.store(new, Ordering::SeqCst); 187 | self.device.clear().await?; 188 | }, 189 | _ => {} 190 | } 191 | }, 192 | notification = notifications.next(), if !notifications.is_empty() => { 193 | if let Some(Ok(mut notification)) = notification { 194 | let mut stream = Box::pin(notification.stream()?); 195 | while let Some(display) = stream.next().await { 196 | self.device.draw(&display?).await?; 197 | } 198 | } 199 | } 200 | content = y.next() => { 201 | if let Some(Ok(content)) = &content { 202 | self.device.draw(content).await?; 203 | } 204 | } 205 | _ = change.tick() => { 206 | if is_auto_change_enabled { 207 | //get the time since the last update 208 | let current_time = Instant::now(); 209 | let elapsed_time = current_time - time_last_change.borrow().clone(); 210 | //if the last update is over the choosen interval 211 | if elapsed_time > Duration::from_secs(interval_between_change as u64) { 212 | //change the screen 213 | let _ = tx.send(Command::NextSource); 214 | } 215 | } 216 | } 217 | }; 218 | } 219 | 220 | self.device.clear().await?; 221 | self.device.shutdown().await?; 222 | Ok(()) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/render/stream.rs: -------------------------------------------------------------------------------- 1 | use futures::{ 2 | stream::{FusedStream, StreamExt}, 3 | Stream, 4 | }; 5 | use pin_project_lite::pin_project; 6 | use std::{ 7 | pin::Pin, 8 | task::{Context, Poll}, 9 | }; 10 | 11 | pin_project! { 12 | #[must_use = "streams do nothing unless polled"] 13 | pub struct Multiplexer { 14 | #[pin] 15 | inner: Vec, 16 | f: F 17 | } 18 | } 19 | 20 | pub fn multiplex(streams: I, f: F) -> Multiplexer 21 | where 22 | I: IntoIterator, 23 | I::Item: Stream + Unpin + FusedStream, 24 | F: FnMut() -> usize, 25 | { 26 | let mut set = Vec::new(); 27 | 28 | for stream in streams { 29 | set.push(stream); 30 | } 31 | 32 | Multiplexer { inner: set, f } 33 | } 34 | 35 | impl Stream for Multiplexer 36 | where 37 | St: Stream + Unpin + FusedStream, 38 | F: FnMut() -> usize, 39 | { 40 | type Item = St::Item; 41 | 42 | fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 43 | let this = self.project(); 44 | 45 | let index = (this.f)(); 46 | let inner_vec = this.inner.get_mut(); 47 | inner_vec 48 | .get_mut(index) 49 | .expect("Bad index") 50 | .poll_next_unpin(cx) 51 | } 52 | } 53 | 54 | impl FusedStream for Multiplexer 55 | where 56 | St: Stream + Unpin + FusedStream, 57 | F: FnMut() -> usize, 58 | { 59 | fn is_terminated(&self) -> bool { 60 | self.inner.iter().all(FusedStream::is_terminated) 61 | } 62 | } 63 | 64 | impl Multiplexer 65 | where 66 | St: Stream + Unpin + FusedStream, 67 | F: FnMut() -> usize, 68 | { 69 | #[allow(dead_code)] 70 | pub fn new(futures: Vec, f: F) -> Self { 71 | Self { inner: futures, f } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/render/text.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use apex_hardware::BitVec; 3 | use embedded_graphics::{ 4 | draw_target::DrawTarget, 5 | geometry::{OriginDimensions, Point, Size}, 6 | mono_font::{iso_8859_15::FONT_6X10, MonoFont, MonoTextStyle, MonoTextStyleBuilder}, 7 | pixelcolor::BinaryColor, 8 | text::{renderer::TextRenderer, Baseline, Text}, 9 | Drawable, Pixel, 10 | }; 11 | use num_traits::AsPrimitive; 12 | use std::convert::TryFrom; 13 | 14 | #[derive(Debug, Clone)] 15 | pub struct ScrollableCanvas { 16 | width: u32, 17 | height: u32, 18 | canvas: BitVec, 19 | } 20 | 21 | impl ScrollableCanvas { 22 | pub fn new(width: u32, height: u32) -> Self { 23 | let mut canvas = BitVec::new(); 24 | let pixels = width * height; 25 | canvas.resize(pixels as usize, false); 26 | Self { 27 | width, 28 | height, 29 | canvas, 30 | } 31 | } 32 | } 33 | 34 | impl OriginDimensions for ScrollableCanvas { 35 | fn size(&self) -> Size { 36 | Size::new(self.width, self.height) 37 | } 38 | } 39 | 40 | impl DrawTarget for ScrollableCanvas { 41 | type Color = BinaryColor; 42 | type Error = anyhow::Error; 43 | 44 | fn draw_iter(&mut self, pixels: I) -> Result<(), Self::Error> 45 | where 46 | I: IntoIterator>, 47 | { 48 | for Pixel(coord, color) in pixels { 49 | let (x, y) = (coord.x, coord.y); 50 | if x >= 0 && x < (self.width as i32) && y >= 0 && y < (self.height as i32) { 51 | let index = x + y * self.width as i32; 52 | self.canvas.set(index.as_(), color.is_on()); 53 | } 54 | } 55 | Ok(()) 56 | } 57 | 58 | fn clear(&mut self, color: Self::Color) -> Result<(), Self::Error> { 59 | self.canvas.fill(color.is_on()); 60 | Ok(()) 61 | } 62 | } 63 | 64 | #[derive(Debug, Clone, Default)] 65 | pub struct ScrollableBuilder { 66 | spacing: Option, 67 | position: Option, 68 | projection: Option, 69 | font: Option<&'static MonoFont<'static>>, 70 | text: String, 71 | } 72 | 73 | #[derive(Debug, Clone)] 74 | pub struct StatefulScrollable { 75 | builder: ScrollableBuilder, 76 | pub text: Scrollable, 77 | } 78 | 79 | impl TryFrom for StatefulScrollable { 80 | type Error = anyhow::Error; 81 | 82 | fn try_from(value: ScrollableBuilder) -> Result { 83 | let text = value.build()?; 84 | Ok(StatefulScrollable { 85 | builder: value, 86 | text, 87 | }) 88 | } 89 | } 90 | 91 | impl StatefulScrollable { 92 | /// Re-renders the scrollable text if the text changed. Returns `Ok(true)` 93 | /// if the text was updated, `Ok(false)` if the text was not updated or 94 | /// `Err(_)` if an error occurred during re-rendering. 95 | /// 96 | /// # Arguments 97 | /// 98 | /// * `text`: the new text 99 | /// 100 | /// returns: Result 101 | /// 102 | /// # Examples 103 | /// 104 | /// ``` 105 | /// let mut text: StatefulScrollableText = ScrollableTextBuilder::new() 106 | /// .with_text("foo") 107 | /// .try_into()?; 108 | /// // Text now displays "foo" 109 | /// text.update("bar")?; 110 | /// // Text now displays "bar" 111 | /// ``` 112 | pub fn update(&mut self, text: &str) -> Result { 113 | if self.builder.text != text { 114 | // TODO: Find a better way? 115 | let new_builder = self.builder.clone().with_text(text); 116 | let text = new_builder.build()?; 117 | self.builder = new_builder; 118 | self.text = text; 119 | return Ok(true); 120 | } 121 | Ok(false) 122 | } 123 | } 124 | 125 | impl ScrollableBuilder { 126 | pub fn new() -> Self { 127 | Self::default() 128 | } 129 | 130 | pub fn with_text(mut self, text: impl Into) -> Self { 131 | self.text = text.into(); 132 | self 133 | } 134 | 135 | pub fn with_custom_spacing(mut self, spacing: u32) -> Self { 136 | self.spacing = Some(spacing); 137 | self 138 | } 139 | 140 | pub fn with_position(mut self, position: Point) -> Self { 141 | self.position = Some(position); 142 | self 143 | } 144 | 145 | pub fn with_projection(mut self, projection: Size) -> Self { 146 | self.projection = Some(projection); 147 | self 148 | } 149 | 150 | #[allow(dead_code)] 151 | pub fn with_custom_font(mut self, font: &'static MonoFont<'static>) -> Self { 152 | self.font = Some(font); 153 | self 154 | } 155 | 156 | fn calculate_spacing(&self) -> u32 { 157 | self.spacing.unwrap_or(5) 158 | } 159 | 160 | fn calculate_size(&self, renderer: &MonoTextStyle) -> Size { 161 | let metrics = renderer.measure_string(&self.text, Point::new(0, 0), Baseline::Top); 162 | metrics.bounding_box.size + Size::new(self.calculate_spacing(), 0) 163 | } 164 | 165 | fn default_font() -> &'static MonoFont<'static> { 166 | &FONT_6X10 167 | } 168 | 169 | pub fn build(&self) -> Result { 170 | let renderer = MonoTextStyleBuilder::new() 171 | .font(self.font.unwrap_or_else(Self::default_font)) 172 | .text_color(BinaryColor::On) 173 | .build(); 174 | let size = self.calculate_size(&renderer); 175 | let mut canvas = ScrollableCanvas::new(size.width, size.height); 176 | 177 | Text::with_baseline(&self.text, Point::new(0, 0), renderer, Baseline::Top) 178 | .draw(&mut canvas)?; 179 | 180 | Ok(Scrollable { 181 | canvas, 182 | projection: self.projection.unwrap_or(size), 183 | position: self.position.unwrap_or_default(), 184 | spacing: self.calculate_spacing(), 185 | scroll: 0, 186 | }) 187 | } 188 | } 189 | 190 | #[derive(Debug, Clone)] 191 | pub struct Scrollable { 192 | pub canvas: ScrollableCanvas, 193 | pub projection: Size, 194 | pub position: Point, 195 | pub spacing: u32, 196 | pub scroll: u32, 197 | } 198 | 199 | impl Drawable for Scrollable { 200 | type Color = BinaryColor; 201 | type Output = (); 202 | 203 | fn draw(&self, target: &mut D) -> Result::Error> 204 | where 205 | D: DrawTarget, 206 | { 207 | self.at_tick(target, self.scroll)?; 208 | Ok::::Error>(()) 209 | } 210 | } 211 | 212 | impl Scrollable { 213 | pub fn at_tick(&self, target: &mut D, tick: u32) -> Result<(), ::Error> 214 | where 215 | D: DrawTarget::Color>, 216 | { 217 | // TODO: There's probably some really cool bitwise hacks to do here... 218 | let scroll = tick % self.canvas.width; 219 | let pixels = self.projection.height * self.projection.width; 220 | // We know exactly how many pixels we can push so we can pre-allocate exactly. 221 | let mut pixels = Vec::with_capacity(pixels as usize); 222 | 223 | for n in 0..self.projection.height { 224 | let min = scroll + n * self.canvas.width; 225 | let max = (min + self.projection.width).min((n + 1) * self.canvas.width); 226 | // First draw until we would overflow in the current line 227 | for i in min..max { 228 | let coord = Point::new((i - min) as i32, n as i32); 229 | let color = self.canvas.canvas[i as usize]; 230 | pixels.push(Pixel(self.position + coord, BinaryColor::from(color))); 231 | } 232 | 233 | // We've reached the end and need to render something from the start 234 | // Don't do this though if our projection space is larger than our canvas 235 | // We'd be rendering stuff twice otherwise 236 | if scroll + self.projection.width >= self.canvas.width 237 | && self.projection.width < self.canvas.width 238 | { 239 | let min = n * self.canvas.width; 240 | let overflow = scroll + self.projection.width - self.canvas.width; 241 | let max = min + overflow; 242 | 243 | for i in min..max { 244 | let coord = Point::new( 245 | (i - min + (self.projection.width - overflow)) as i32, 246 | n as i32, 247 | ); 248 | if (i as usize) < self.canvas.canvas.len() { 249 | let color = self.canvas.canvas[i as usize]; 250 | pixels.push(Pixel(self.position + coord, BinaryColor::from(color))); 251 | } 252 | } 253 | } 254 | } 255 | 256 | target.draw_iter(pixels.into_iter())?; 257 | Ok(()) 258 | } 259 | 260 | pub fn scroll(&mut self) { 261 | self.scroll += 1; 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/render/util.rs: -------------------------------------------------------------------------------- 1 | use embedded_graphics::{ 2 | pixelcolor::BinaryColor, 3 | prelude::{Angle, AngleUnit, DrawTarget, Point, Primitive}, 4 | primitives::{Arc, PrimitiveStyle}, 5 | Drawable, 6 | }; 7 | 8 | pub struct ProgressBar { 9 | maximum_value: f32, 10 | origin: Point, 11 | style: PrimitiveStyle, 12 | } 13 | 14 | impl ProgressBar { 15 | const DIAMETER: u32 = 10; 16 | 17 | pub fn new(origin: Point, max: impl Into) -> Self { 18 | let style = PrimitiveStyle::with_stroke(BinaryColor::On, 2); 19 | Self { 20 | maximum_value: max.into(), 21 | origin, 22 | style, 23 | } 24 | } 25 | 26 | fn calculate_progress(&self, current: f32) -> Angle { 27 | (((current / self.maximum_value) * 360.0) * -1.0).deg() 28 | } 29 | 30 | pub fn draw_at>( 31 | &self, 32 | current: impl Into, 33 | target: &mut T, 34 | ) -> Result<(), ::Error> { 35 | let progress = self.calculate_progress(current.into()); 36 | Arc::new(self.origin, Self::DIAMETER, 90.0_f32.deg(), progress) 37 | .into_styled(self.style) 38 | .draw(target)?; 39 | Ok(()) 40 | } 41 | } 42 | --------------------------------------------------------------------------------