├── .github └── workflows │ ├── deploy.yml │ └── test.yml ├── .gitignore ├── .pkg └── aur │ ├── PKGBUILD │ └── update.sh ├── Cargo.lock ├── Cargo.toml ├── README.md ├── bato.yaml ├── build.rs ├── img └── bato.png ├── libnotilus ├── CMakeLists.txt ├── include │ └── notilus.h └── src │ └── notilus.c └── src ├── battery.rs ├── error.rs ├── fsm.rs ├── lib.rs ├── main.rs └── notify.rs /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deployment 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | test: 10 | uses: ./.github/workflows/test.yml 11 | 12 | build: 13 | name: Build 14 | runs-on: ubuntu-latest 15 | needs: test 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | - name: Install Rust toolchain 20 | uses: dtolnay/rust-toolchain@master 21 | with: 22 | toolchain: stable 23 | - name: Install libnotify 24 | run: sudo apt-get install libnotify-dev 25 | - name: Build 26 | run: cargo build --release --locked 27 | - name: Upload binary artifact 28 | uses: actions/upload-artifact@v3 29 | with: 30 | name: bato 31 | path: ./target/release/bato 32 | 33 | gh-release: 34 | name: Publish Github Release 35 | needs: build 36 | runs-on: ubuntu-latest 37 | steps: 38 | - name: Checkout 39 | uses: actions/checkout@v3 40 | - name: Download binary artifact 41 | uses: actions/download-artifact@v3 42 | with: 43 | name: bato 44 | path: ./target/release/ 45 | - name: Release 46 | uses: softprops/action-gh-release@v1 47 | with: 48 | files: target/release/bato 49 | 50 | aur-packaging: 51 | name: Publish AUR package 52 | needs: gh-release 53 | runs-on: ubuntu-latest 54 | env: 55 | PKG_NAME: bato 56 | PKGBUILD: ./.pkg/aur/PKGBUILD 57 | RELEASE_TAG: ${{ github.ref_name }} 58 | REPOSITORY: ${{ github.repository }} 59 | steps: 60 | - name: Checkout 61 | uses: actions/checkout@v3 62 | - name: Download sources 63 | run: curl -LfsSo "$PKG_NAME-$RELEASE_TAG".tar.gz "https://github.com/$REPOSITORY/archive/refs/tags/$RELEASE_TAG.tar.gz" 64 | - name: Update PKGBUILD 65 | run: ./.pkg/aur/update.sh 66 | - name: Show PKGBUILD 67 | run: cat "$PKGBUILD" 68 | - name: Publish 69 | uses: KSXGitHub/github-actions-deploy-aur@v2.6.0 70 | with: 71 | pkgname: ${{ env.PKG_NAME }} 72 | pkgbuild: ${{ env.PKGBUILD }} 73 | commit_username: ${{ secrets.AUR_USERNAME }} 74 | commit_email: ${{ secrets.AUR_EMAIL }} 75 | ssh_private_key: ${{ secrets.AUR_SSH_KEY }} 76 | commit_message: ${{ github.ref_name }} 77 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | workflow_call: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | 13 | jobs: 14 | test: 15 | name: Test 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | - name: Install Rust toolchain 21 | uses: dtolnay/rust-toolchain@master 22 | with: 23 | toolchain: stable 24 | components: clippy 25 | - name: Install libnotify 26 | run: sudo apt-get install libnotify-dev 27 | - name: Lint 28 | run: cargo clippy 29 | - name: Check 30 | run: cargo check 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | cmake-build-debug 4 | -------------------------------------------------------------------------------- /.pkg/aur/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Pierre Dommerc 2 | 3 | pkgname=bato 4 | pkgver=0.1.0 5 | pkgrel=1 6 | pkgdesc='Small program to send battery notifications' 7 | arch=('x86_64') 8 | url='https://github.com/doums/bato' 9 | license=('MPL2') 10 | depends=('libnotify') 11 | makedepends=('rust' 'cargo' 'cmake') 12 | provides=('bato') 13 | conflicts=('bato') 14 | source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz") 15 | sha256sums=('xxx') 16 | 17 | build() { 18 | cd "$pkgname-$pkgver" 19 | cargo build --release --locked 20 | } 21 | 22 | package() { 23 | cd "$pkgname-$pkgver" 24 | install -Dvm 755 "target/release/bato" "$pkgdir/usr/bin/bato" 25 | install -Dvm 644 "bato.yaml" "$pkgdir/usr/share/doc/bato/config/bato.yaml" 26 | } 27 | 28 | -------------------------------------------------------------------------------- /.pkg/aur/update.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | # script to bump version and update sources hash of a PKGBUILD 4 | 5 | set -e 6 | 7 | red="\e[38;5;1m" 8 | green="\e[38;5;2m" 9 | bold="\e[1m" 10 | reset="\e[0m" 11 | 12 | if [ -z "$PKGBUILD" ]; then 13 | >&2 printf " %b%b✕%b PKGBUILD not set\n" "$red" "$bold" "$reset" 14 | exit 1 15 | fi 16 | 17 | if [ -z "$PKG_NAME" ]; then 18 | >&2 printf " %b%b✕%b PKG_NAME not set\n" "$red" "$bold" "$reset" 19 | exit 1 20 | fi 21 | 22 | if [ -z "$RELEASE_TAG" ]; then 23 | >&2 printf " %b%b✕%b RELEASE_TAG not set\n" "$red" "$bold" "$reset" 24 | exit 1 25 | fi 26 | 27 | if ! [ -a "$PKGBUILD" ]; then 28 | >&2 printf " %b%b✕%b no such file $PKGBUILD\n" "$red" "$bold" "$reset" 29 | exit 1 30 | fi 31 | 32 | if ! [[ "$RELEASE_TAG" =~ ^v.*? ]]; then 33 | >&2 printf " %b%b✕%b invalid tag $RELEASE_TAG\n" "$red" "$bold" "$reset" 34 | exit 1 35 | fi 36 | 37 | pkgver="${RELEASE_TAG#v}" 38 | tarball="$PKG_NAME-$RELEASE_TAG".tar.gz 39 | 40 | if ! [ -a "$tarball" ]; then 41 | >&2 printf " %b%b✕%b no such file $tarball\n" "$red" "$bold" "$reset" 42 | exit 1 43 | fi 44 | 45 | # bump package version 46 | sed -i "s/pkgver=.*/pkgver=$pkgver/" "$PKGBUILD" 47 | printf " %b%b✓%b bump version to $RELEASE_TAG\n" "$green" "$bold" "$reset" 48 | 49 | # generate new checksum 50 | sum=$(set -o pipefail && sha256sum "$tarball" | awk '{print $1}') 51 | sed -i "s/sha256sums=('.*')/sha256sums=('$sum')/" "$PKGBUILD" 52 | printf " %b%b✓%b generated checksum $sum\n" "$green" "$bold" "$reset" 53 | 54 | exit 0 55 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "autocfg" 7 | version = "1.1.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 10 | 11 | [[package]] 12 | name = "bato" 13 | version = "0.1.7" 14 | dependencies = [ 15 | "cmake", 16 | "serde", 17 | "serde_yaml", 18 | ] 19 | 20 | [[package]] 21 | name = "cc" 22 | version = "1.0.78" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "a20104e2335ce8a659d6dd92a51a767a0c062599c73b343fd152cb401e828c3d" 25 | 26 | [[package]] 27 | name = "cmake" 28 | version = "0.1.49" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "db34956e100b30725f2eb215f90d4871051239535632f84fea3bc92722c66b7c" 31 | dependencies = [ 32 | "cc", 33 | ] 34 | 35 | [[package]] 36 | name = "hashbrown" 37 | version = "0.12.3" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 40 | 41 | [[package]] 42 | name = "indexmap" 43 | version = "1.9.2" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399" 46 | dependencies = [ 47 | "autocfg", 48 | "hashbrown", 49 | ] 50 | 51 | [[package]] 52 | name = "itoa" 53 | version = "1.0.5" 54 | source = "registry+https://github.com/rust-lang/crates.io-index" 55 | checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440" 56 | 57 | [[package]] 58 | name = "proc-macro2" 59 | version = "1.0.49" 60 | source = "registry+https://github.com/rust-lang/crates.io-index" 61 | checksum = "57a8eca9f9c4ffde41714334dee777596264c7825420f521abc92b5b5deb63a5" 62 | dependencies = [ 63 | "unicode-ident", 64 | ] 65 | 66 | [[package]] 67 | name = "quote" 68 | version = "1.0.23" 69 | source = "registry+https://github.com/rust-lang/crates.io-index" 70 | checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" 71 | dependencies = [ 72 | "proc-macro2", 73 | ] 74 | 75 | [[package]] 76 | name = "ryu" 77 | version = "1.0.12" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" 80 | 81 | [[package]] 82 | name = "serde" 83 | version = "1.0.152" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" 86 | dependencies = [ 87 | "serde_derive", 88 | ] 89 | 90 | [[package]] 91 | name = "serde_derive" 92 | version = "1.0.152" 93 | source = "registry+https://github.com/rust-lang/crates.io-index" 94 | checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" 95 | dependencies = [ 96 | "proc-macro2", 97 | "quote", 98 | "syn", 99 | ] 100 | 101 | [[package]] 102 | name = "serde_yaml" 103 | version = "0.9.16" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "92b5b431e8907b50339b51223b97d102db8d987ced36f6e4d03621db9316c834" 106 | dependencies = [ 107 | "indexmap", 108 | "itoa", 109 | "ryu", 110 | "serde", 111 | "unsafe-libyaml", 112 | ] 113 | 114 | [[package]] 115 | name = "syn" 116 | version = "1.0.107" 117 | source = "registry+https://github.com/rust-lang/crates.io-index" 118 | checksum = "1f4064b5b16e03ae50984a5a8ed5d4f8803e6bc1fd170a3cda91a1be4b18e3f5" 119 | dependencies = [ 120 | "proc-macro2", 121 | "quote", 122 | "unicode-ident", 123 | ] 124 | 125 | [[package]] 126 | name = "unicode-ident" 127 | version = "1.0.6" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" 130 | 131 | [[package]] 132 | name = "unsafe-libyaml" 133 | version = "0.2.5" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "bc7ed8ba44ca06be78ea1ad2c3682a43349126c8818054231ee6f4748012aed2" 136 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bato" 3 | version = "0.1.7" 4 | authors = ["pierre "] 5 | edition = "2021" 6 | links = "notilus" 7 | build = "build.rs" 8 | 9 | [dependencies] 10 | serde = { version = "1.0", features = ["derive"] } 11 | serde_yaml = "0.9" 12 | 13 | [build-dependencies] 14 | cmake = "0.1" 15 | 16 | [profile.release] 17 | strip = true 18 | opt-level = "s" 19 | lto = true 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![bato](https://img.shields.io/github/actions/workflow/status/doums/bato/test.yml?color=0D0D0D&logoColor=BFBFBF&labelColor=404040&logo=github&style=for-the-badge)](https://github.com/doums/bato/actions?query=workflow%3Atest) 2 | [![bato](https://img.shields.io/aur/version/bato?color=0D0D0D&logoColor=BFBFBF&labelColor=404040&logo=arch-linux&style=for-the-badge)](https://aur.archlinux.org/packages/bato/) 3 | 4 | ## bato 5 | 6 | Small program to send **bat**tery n**o**tifications. 7 | 8 | ![bato](https://github.com/doums/bato/blob/master/img/bato.png) 9 | 10 | - [features](#features) 11 | - [prerequisite](#prerequisite) 12 | - [install](#install) 13 | - [AUR](#arch-linux-aur-package) 14 | - [configuration](#configuration) 15 | - [usage](#usage) 16 | - [license](#license) 17 | 18 | ### Features 19 | 20 | Configuration in YAML. 21 | 22 | Notification events: 23 | 24 | - level full 25 | - level low 26 | - level critical 27 | - charging 28 | - discharging 29 | 30 | ### Prerequisite 31 | 32 | - a notification server, like [Dunst](https://dunst-project.org/) 33 | - libnotify 34 | 35 | ### Install 36 | 37 | - latest [release](https://github.com/doums/bato/releases/latest) 38 | - AUR [package](https://aur.archlinux.org/packages/bato) 39 | 40 | ### Configuration 41 | 42 | The binary looks for the config file `bato.yaml` located in `$XDG_CONFIG_HOME/bato/` (default to `$HOME/.config/bato/`).\ 43 | If the config file is not found, bato prints an error and exits.\ 44 | All options are detailed [here](https://github.com/doums/bato/blob/master/bato.yaml). 45 | 46 | Example: 47 | 48 | ```yaml 49 | tick_rate: 1 50 | critical_level: 5 51 | low_level: 30 52 | critical: 53 | summary: Critical battery level! 54 | body: Plug the power cable asap! 55 | icon: battery-caution 56 | low: 57 | summary: Battery low 58 | icon: battery-040 59 | full: 60 | summary: Battery full 61 | icon: battery-full 62 | urgency: Low 63 | charging: 64 | summary: Battery 65 | body: Charging 66 | icon: battery-good-charging 67 | discharging: 68 | summary: Battery 69 | body: Discharging 70 | icon: battery-good 71 | ``` 72 | 73 | ### Usage 74 | 75 | Run bato as a _daemon_. For example launch it from a script 76 | 77 | ``` 78 | #!/usr/bin/bash 79 | 80 | mapfile -t pids <<< "$(pgrep -x bato)" 81 | if [ "${#pids[@]}" -gt 0 ]; then 82 | for pid in "${pids[@]}"; do 83 | if [ -n "$pid" ]; then 84 | kill "$pid" 85 | fi 86 | done 87 | fi 88 | bato & 89 | ``` 90 | 91 | Call this script from your window manager, _autostart_ programs. 92 | 93 | ### License 94 | 95 | Mozilla Public License 2.0 96 | -------------------------------------------------------------------------------- /bato.yaml: -------------------------------------------------------------------------------- 1 | # List of all options 2 | 3 | # If the word "required" is mentioned, the corresponding option is required. 4 | # Otherwise it is optional. 5 | 6 | 7 | # # # # # 8 | # Root # 9 | # # # # # 10 | 11 | # tick_rate: u32, default: 5 12 | # 13 | # The refresh rate in second of bato. 14 | # 15 | tick_rate: 1 16 | 17 | # bat_name: String, default: BAT0 18 | # 19 | # The battery name under /sys/class/power_supply/ 20 | # 21 | bat_name: BAT1 22 | 23 | # critical_level: String, required 24 | # 25 | # The critical level of the battery, as a percentage. 26 | # 27 | critical_level: 5 28 | 29 | # low_level: String, required 30 | # 31 | # The low level of the battery, as a percentage. 32 | # 33 | low_level: 30 34 | 35 | # full_design: bool, default: true 36 | # 37 | # Whether or not the current level is calculated based on the full design value. 38 | # 39 | full_design: true 40 | 41 | # # # # # # # # # 42 | # notifications # 43 | # # # # # # # # # 44 | 45 | # They are all optional. If you omit one, the corresponding notification is disabled. 46 | 47 | critical: 48 | low: 49 | full: 50 | charging: 51 | discharging: 52 | 53 | # Takes the following options: 54 | 55 | # summary: String, required 56 | # 57 | # The summary (title) of the notification. 58 | # 59 | summary: Critical battery level! 60 | 61 | # body: String, default: NULL (no body) 62 | # 63 | # The body of the notification. 64 | # 65 | body: Plug the power cable asap. 66 | 67 | # icon: String, default: NULL (no icon) 68 | # 69 | # The icon name of the notification. 70 | # 71 | icon: battery-caution 72 | 73 | # urgency: String, default: Critical for critical level, Normal for other 74 | # 75 | # enum Urgency { Low, Normal, Critical } 76 | # 77 | # The urgency level of the notification. 78 | # 79 | urgency: Critical 80 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | let dst = cmake::build("libnotilus"); 3 | println!("cargo:rustc-link-search=native={}/lib", dst.display()); 4 | println!("cargo:rustc-link-lib=static=notilus"); 5 | println!("cargo:rustc-link-lib=dylib=notify"); 6 | println!("cargo:rustc-link-lib=dylib=gobject-2.0"); 7 | } 8 | -------------------------------------------------------------------------------- /img/bato.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doums/bato/2b8529f85e2a5a9ea50ca1292930d5df85f7098d/img/bato.png -------------------------------------------------------------------------------- /libnotilus/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.16) 2 | project(notilus C) 3 | 4 | set(CMAKE_EXPORT_COMPILE_COMMANDS ON) 5 | set(CMAKE_C_STANDARD 11) 6 | set(CMAKE_C_FLAGS "-W -Wall -Wextra -Werror") 7 | 8 | include(FindPkgConfig) 9 | 10 | add_library(notilus STATIC src/notilus.c) 11 | 12 | if (PkgConfig_FOUND) 13 | pkg_check_modules(LIBNOTIFY libnotify>=0.7) 14 | endif () 15 | 16 | if (NOT LIBNOTIFY_LINK_LIBRARIES) 17 | message(FATAL_ERROR "libnotify not found") 18 | endif () 19 | 20 | target_include_directories(notilus INTERFACE ${PROJECT_BINARY_DIR}/include) 21 | target_link_libraries(notilus PRIVATE ${LIBNOTIFY_LINK_LIBRARIES}) 22 | target_include_directories(notilus PUBLIC ${LIBNOTIFY_INCLUDE_DIRS}) 23 | 24 | install(TARGETS notilus DESTINATION lib) 25 | install(FILES include/notilus.h DESTINATION include) -------------------------------------------------------------------------------- /libnotilus/include/notilus.h: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ 4 | 5 | #ifndef NOTILUS_H 6 | #define NOTILUS_H 7 | 8 | #include 9 | #include 10 | #include 11 | 12 | NotifyNotification *init(const char *app_name); 13 | int notify(NotifyNotification *notification, const char *summary, const char *body, const char *icon, NotifyUrgency urgency); 14 | void uninit(NotifyNotification *notification); 15 | 16 | #endif //NOTILUS_H 17 | -------------------------------------------------------------------------------- /libnotilus/src/notilus.c: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ 4 | 5 | #include "../include/notilus.h" 6 | 7 | NotifyNotification *init(const char *app_name) { 8 | if (!notify_init(app_name)) { 9 | return NULL; 10 | } 11 | return notify_notification_new(app_name, NULL, NULL); 12 | } 13 | 14 | int notify(NotifyNotification *notification, const char *summary, const char *body, const char *icon, NotifyUrgency urgency) { 15 | if (!notify_notification_update(notification, summary, body, icon)) { 16 | return 1; 17 | } 18 | notify_notification_set_urgency(notification, urgency); 19 | if (!notify_notification_show(notification, NULL)) { 20 | return 2; 21 | } 22 | return 0; 23 | } 24 | 25 | void uninit(NotifyNotification *notification) { 26 | g_object_unref(G_OBJECT(notification)); 27 | notify_uninit(); 28 | } -------------------------------------------------------------------------------- /src/battery.rs: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | use crate::{ 6 | fsm::{Fsm, FsmState, StateMap}, 7 | notify::{send, NotifyNotification}, 8 | Notification, 9 | }; 10 | use std::{collections::HashMap, hash::Hash}; 11 | 12 | struct ChargingState; 13 | struct DischargingState; 14 | struct FullState; 15 | struct LowState; 16 | struct CriticalState; 17 | 18 | #[derive(Hash, Eq, PartialEq)] 19 | pub enum State { 20 | Charging, 21 | Discharging, 22 | Full, 23 | Low, 24 | Critical, 25 | } 26 | 27 | pub struct Data<'data> { 28 | pub current_level: u32, 29 | pub status: String, 30 | pub low_level: u32, 31 | pub critical_level: u32, 32 | pub critical: Option<&'data Notification>, 33 | pub low: Option<&'data Notification>, 34 | pub full: Option<&'data Notification>, 35 | pub charging: Option<&'data Notification>, 36 | pub discharging: Option<&'data Notification>, 37 | pub notification: *mut NotifyNotification, 38 | } 39 | 40 | impl<'data> FsmState> for ChargingState { 41 | fn enter(&mut self, data: &mut Data) { 42 | if let Some(n) = data.charging { 43 | send(data.notification, n); 44 | } 45 | } 46 | 47 | fn next_state(&self, data: &mut Data) -> Option { 48 | match (data.status.as_str(), data.current_level) { 49 | ("Full", _) => Some(State::Full), 50 | ("Discharging", l) if l <= data.critical_level => Some(State::Critical), 51 | ("Discharging", l) if l <= data.low_level => Some(State::Low), 52 | ("Discharging", _) => Some(State::Discharging), 53 | _ => None, 54 | } 55 | } 56 | 57 | fn exit(&mut self, _data: &mut Data) {} 58 | } 59 | 60 | impl<'data> FsmState> for DischargingState { 61 | fn enter(&mut self, data: &mut Data) { 62 | if let Some(n) = data.discharging { 63 | send(data.notification, n); 64 | } 65 | } 66 | 67 | fn next_state(&self, data: &mut Data) -> Option { 68 | match (data.status.as_str(), data.current_level) { 69 | ("Charging", _) => Some(State::Charging), 70 | ("Full", _) => Some(State::Full), // add this just in case 71 | ("Discharging", l) if l <= data.critical_level => Some(State::Critical), 72 | ("Discharging", l) if l <= data.low_level => Some(State::Low), 73 | _ => None, 74 | } 75 | } 76 | 77 | fn exit(&mut self, _data: &mut Data) {} 78 | } 79 | 80 | impl<'data> FsmState> for FullState { 81 | fn enter(&mut self, data: &mut Data) { 82 | if let Some(n) = data.full { 83 | send(data.notification, n); 84 | } 85 | } 86 | 87 | fn next_state(&self, data: &mut Data) -> Option { 88 | match data.status.as_str() { 89 | "Charging" => Some(State::Charging), 90 | "Discharging" => Some(State::Discharging), 91 | _ => None, 92 | } 93 | } 94 | 95 | fn exit(&mut self, _data: &mut Data) {} 96 | } 97 | 98 | impl<'data> FsmState> for LowState { 99 | fn enter(&mut self, data: &mut Data) { 100 | if let Some(n) = data.low { 101 | send(data.notification, n); 102 | } 103 | } 104 | 105 | fn next_state(&self, data: &mut Data) -> Option { 106 | match (data.status.as_str(), data.current_level) { 107 | ("Charging", _) => Some(State::Charging), 108 | ("Discharging", l) if l <= data.critical_level => Some(State::Critical), 109 | _ => None, 110 | } 111 | } 112 | 113 | fn exit(&mut self, _data: &mut Data) {} 114 | } 115 | 116 | impl<'data> FsmState> for CriticalState { 117 | fn enter(&mut self, data: &mut Data) { 118 | if let Some(n) = data.critical { 119 | send(data.notification, n); 120 | } 121 | } 122 | 123 | fn next_state(&self, data: &mut Data) -> Option { 124 | if data.status == "Charging" { 125 | return Some(State::Charging); 126 | } 127 | None 128 | } 129 | 130 | fn exit(&mut self, _data: &mut Data) {} 131 | } 132 | 133 | pub fn create_fsm<'data>() -> Fsm> { 134 | let mut states: StateMap = HashMap::new(); 135 | states.insert(State::Full, Box::new(FullState)); 136 | states.insert(State::Charging, Box::new(ChargingState)); 137 | states.insert(State::Discharging, Box::new(DischargingState)); 138 | states.insert(State::Low, Box::new(LowState)); 139 | states.insert(State::Critical, Box::new(CriticalState)); 140 | Fsm::new(State::Discharging, states) 141 | } 142 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | use std::error::Error as StdError; 6 | use std::fmt::{Display, Formatter, Result as FmtResult}; 7 | use std::io::Error as IoError; 8 | use std::num::TryFromIntError; 9 | use std::num::{ParseFloatError, ParseIntError}; 10 | use std::str::Utf8Error; 11 | use std::string::FromUtf8Error; 12 | use std::sync::mpsc::{RecvError, SendError}; 13 | 14 | #[derive(Debug, Clone)] 15 | pub struct Error(String); 16 | 17 | impl Display for Error { 18 | fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { 19 | write!(f, "{}", self.0) 20 | } 21 | } 22 | 23 | impl StdError for Error {} 24 | 25 | impl Error { 26 | pub fn new(item: impl Into) -> Error { 27 | Error(item.into()) 28 | } 29 | } 30 | 31 | impl From for Error { 32 | fn from(error: String) -> Self { 33 | Error(error) 34 | } 35 | } 36 | 37 | impl From for Error { 38 | fn from(error: IoError) -> Self { 39 | Error(error.to_string()) 40 | } 41 | } 42 | 43 | impl From> for Error { 44 | fn from(error: SendError) -> Self { 45 | Error(error.to_string()) 46 | } 47 | } 48 | 49 | impl From for Error { 50 | fn from(error: RecvError) -> Self { 51 | Error(error.to_string()) 52 | } 53 | } 54 | 55 | impl From<&str> for Error { 56 | fn from(error: &str) -> Self { 57 | Error(error.to_string()) 58 | } 59 | } 60 | 61 | impl From for Error { 62 | fn from(error: Utf8Error) -> Self { 63 | Error(error.to_string()) 64 | } 65 | } 66 | 67 | impl From for Error { 68 | fn from(error: FromUtf8Error) -> Self { 69 | Error(error.to_string()) 70 | } 71 | } 72 | 73 | impl From for Error { 74 | fn from(error: ParseIntError) -> Self { 75 | Error(error.to_string()) 76 | } 77 | } 78 | 79 | impl From for Error { 80 | fn from(error: ParseFloatError) -> Self { 81 | Error(error.to_string()) 82 | } 83 | } 84 | 85 | impl From for Error { 86 | fn from(error: TryFromIntError) -> Self { 87 | Error(error.to_string()) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/fsm.rs: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | use std::{collections::HashMap, hash::Hash}; 6 | 7 | pub type StateMap = HashMap>>; 8 | 9 | pub struct Fsm 10 | where 11 | K: Eq + Hash, 12 | { 13 | current_state: K, 14 | states: StateMap, 15 | } 16 | 17 | impl Fsm 18 | where 19 | K: Eq + Hash, 20 | { 21 | pub fn new(init_state: K, states: StateMap) -> Self { 22 | Fsm { 23 | current_state: init_state, 24 | states, 25 | } 26 | } 27 | 28 | fn set_state(&mut self, new_state: K, data: &mut D) { 29 | self.states.get_mut(&self.current_state).unwrap().exit(data); 30 | self.states.get_mut(&new_state).unwrap().enter(data); 31 | self.current_state = new_state; 32 | } 33 | 34 | pub fn shift(&mut self, data: &mut D) { 35 | if let Some(next_state) = self 36 | .states 37 | .get_mut(&self.current_state) 38 | .unwrap() 39 | .next_state(data) 40 | { 41 | self.set_state(next_state, data); 42 | } 43 | } 44 | } 45 | 46 | pub trait FsmState 47 | where 48 | K: Eq + Hash, 49 | { 50 | fn enter(&mut self, data: &mut D); 51 | fn next_state(&self, data: &mut D) -> Option; 52 | fn exit(&mut self, data: &mut D); 53 | } 54 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | mod battery; 6 | mod error; 7 | mod fsm; 8 | mod notify; 9 | use battery::{Data, State}; 10 | use error::Error; 11 | use fsm::Fsm; 12 | use notify::{close_libnotilus, init_libnotilus, NotifyNotification}; 13 | use serde::{Deserialize, Serialize}; 14 | use std::convert::TryFrom; 15 | use std::env; 16 | use std::ffi::CString; 17 | use std::fs::{self, File}; 18 | use std::io::{self, prelude::*, BufReader}; 19 | use std::ptr; 20 | 21 | const APP_NAME: &str = "bato"; 22 | const SYS_PATH: &str = "/sys/class/power_supply/"; 23 | const BAT_NAME: &str = "BAT0"; 24 | const UEVENT: &str = "uevent"; 25 | const POWER_SUPPLY: &str = "POWER_SUPPLY"; 26 | const CHARGE_PREFIX: &str = "CHARGE"; 27 | const ENERGY_PREFIX: &str = "ENERGY"; 28 | const FULL_ATTRIBUTE: &str = "FULL"; 29 | const FULL_DESIGN_ATTRIBUTE: &str = "FULL_DESIGN"; 30 | const NOW_ATTRIBUTE: &str = "NOW"; 31 | const STATUS_ATTRIBUTE: &str = "POWER_SUPPLY_STATUS"; 32 | 33 | #[derive(Debug, Serialize, Deserialize, Clone, Copy)] 34 | #[repr(C)] 35 | pub enum Urgency { 36 | Low, 37 | Normal, 38 | Critical, 39 | } 40 | 41 | #[derive(Debug, Serialize, Deserialize)] 42 | pub struct NotificationConfig { 43 | summary: String, 44 | body: Option, 45 | icon: Option, 46 | urgency: Option, 47 | } 48 | 49 | #[derive(Debug, Serialize, Deserialize)] 50 | pub struct UserConfig { 51 | pub tick_rate: Option, 52 | bat_name: Option, 53 | low_level: u32, 54 | critical_level: u32, 55 | full_design: Option, 56 | critical: Option, 57 | low: Option, 58 | full: Option, 59 | charging: Option, 60 | discharging: Option, 61 | } 62 | 63 | pub struct Notification { 64 | summary: CString, 65 | body: Option, 66 | icon: Option, 67 | urgency: Option, 68 | } 69 | 70 | impl TryFrom for Notification { 71 | type Error = Error; 72 | 73 | fn try_from(config: NotificationConfig) -> Result { 74 | let summary = CString::new(config.summary).map_err(|_| "NulError")?; 75 | let body = config 76 | .body 77 | .map(|s| CString::new(s).map_err(|_| "NulError")) 78 | .transpose()?; 79 | let icon = config 80 | .icon 81 | .map(|s| CString::new(s).map_err(|_| "NulError")) 82 | .transpose()?; 83 | Ok(Notification { 84 | summary, 85 | body, 86 | icon, 87 | urgency: config.urgency, 88 | }) 89 | } 90 | } 91 | 92 | pub struct Config { 93 | pub tick_rate: Option, 94 | bat_name: Option, 95 | low_level: u32, 96 | critical_level: u32, 97 | full_design: Option, 98 | critical: Option, 99 | low: Option, 100 | full: Option, 101 | charging: Option, 102 | discharging: Option, 103 | } 104 | 105 | impl Config { 106 | pub fn new() -> Result { 107 | let home = env::var("HOME").map_err(|err| format!("environment variable HOME, {}", err))?; 108 | let config_path = 109 | env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| format!("{}/.config", home)); 110 | let content = fs::read_to_string(format!("{}/bato/bato.yaml", config_path)) 111 | .map_err(|err| format!("while reading the config file, {}", err))?; 112 | let user_config: UserConfig = serde_yaml::from_str(&content) 113 | .map_err(|err| format!("while deserializing the config file, {}", err))?; 114 | let critical = user_config 115 | .critical 116 | .map(Notification::try_from) 117 | .transpose()?; 118 | 119 | Ok(Config { 120 | tick_rate: user_config.tick_rate, 121 | bat_name: user_config.bat_name, 122 | low_level: user_config.low_level, 123 | critical_level: user_config.critical_level, 124 | full_design: user_config.full_design, 125 | critical, 126 | low: user_config.low.map(Notification::try_from).transpose()?, 127 | full: user_config.full.map(Notification::try_from).transpose()?, 128 | charging: user_config 129 | .charging 130 | .map(Notification::try_from) 131 | .transpose()?, 132 | discharging: user_config 133 | .discharging 134 | .map(Notification::try_from) 135 | .transpose()?, 136 | }) 137 | } 138 | 139 | pub fn normalize(&mut self) -> &Self { 140 | if let Some(v) = &mut self.critical { 141 | if v.urgency.is_none() { 142 | v.urgency = Some(Urgency::Critical) 143 | } 144 | } 145 | if let Some(v) = &mut self.low { 146 | if v.urgency.is_none() { 147 | v.urgency = Some(Urgency::Normal) 148 | } 149 | } 150 | if let Some(v) = &mut self.full { 151 | if v.urgency.is_none() { 152 | v.urgency = Some(Urgency::Normal) 153 | } 154 | } 155 | self 156 | } 157 | } 158 | 159 | pub struct Bato<'data> { 160 | uevent: String, 161 | now_attribute: String, 162 | full_attribute: String, 163 | notification: *mut NotifyNotification, 164 | fsm: Fsm>, 165 | } 166 | 167 | impl<'data> Bato<'data> { 168 | pub fn with_config(config: &Config) -> Result { 169 | let bat_name = if let Some(v) = &config.bat_name { 170 | String::from(v) 171 | } else { 172 | String::from(BAT_NAME) 173 | }; 174 | let mut full_design = true; 175 | if let Some(v) = config.full_design { 176 | full_design = v; 177 | } 178 | let full_attr = match full_design { 179 | true => FULL_DESIGN_ATTRIBUTE, 180 | false => FULL_ATTRIBUTE, 181 | }; 182 | let uevent = format!("{}{}/{}", SYS_PATH, &bat_name, UEVENT); 183 | let attribute_prefix = find_attribute_prefix(&uevent)?; 184 | let now_attribute = format!("{}_{}_{}", POWER_SUPPLY, attribute_prefix, NOW_ATTRIBUTE); 185 | let full_attribute = format!("{}_{}_{}", POWER_SUPPLY, attribute_prefix, full_attr); 186 | Ok(Bato { 187 | uevent, 188 | now_attribute, 189 | full_attribute, 190 | notification: ptr::null_mut(), 191 | fsm: battery::create_fsm(), 192 | }) 193 | } 194 | 195 | pub fn start(&mut self) -> Result<(), Error> { 196 | let notification = init_libnotilus(APP_NAME); 197 | if notification.is_null() { 198 | return Err(Error::new("libnotilus, fail to init")); 199 | } 200 | self.notification = notification; 201 | Ok(()) 202 | } 203 | 204 | fn parse_attributes(&self) -> Result<(i32, i32, String), Error> { 205 | let file = File::open(&self.uevent)?; 206 | let f = BufReader::new(file); 207 | let mut now = None; 208 | let mut full = None; 209 | let mut status = None; 210 | for line in f.lines() { 211 | if now.is_none() { 212 | now = parse_attribute(&line, &self.now_attribute); 213 | } 214 | if full.is_none() { 215 | full = parse_attribute(&line, &self.full_attribute); 216 | } 217 | if status.is_none() { 218 | status = parse_status(&line); 219 | } 220 | } 221 | if now.is_none() || full.is_none() || status.is_none() { 222 | return Err(Error::new(format!( 223 | "unable to parse the required attributes in {}", 224 | self.uevent 225 | ))); 226 | } 227 | Ok((now.unwrap(), full.unwrap(), status.unwrap())) 228 | } 229 | 230 | pub fn update(&mut self, config: &'data Config) -> Result<(), Error> { 231 | let (energy, capacity, status) = self.parse_attributes()?; 232 | let capacity = capacity as u64; 233 | let energy = energy as u64; 234 | let battery_level = u32::try_from(100_u64 * energy / capacity)?; 235 | let mut data = Data { 236 | current_level: battery_level, 237 | status, 238 | low_level: config.low_level, 239 | critical_level: config.critical_level, 240 | critical: config.critical.as_ref(), 241 | low: config.low.as_ref(), 242 | full: config.full.as_ref(), 243 | charging: config.charging.as_ref(), 244 | discharging: config.discharging.as_ref(), 245 | notification: self.notification, 246 | }; 247 | self.fsm.shift(&mut data); 248 | Ok(()) 249 | } 250 | 251 | pub fn close(&mut self) { 252 | close_libnotilus(self.notification) 253 | } 254 | } 255 | 256 | fn parse_attribute(line: &io::Result, attribute: &str) -> Option { 257 | if let Ok(l) = line { 258 | if l.starts_with(attribute) { 259 | let s = l.split('=').nth(1); 260 | if let Some(v) = s { 261 | return v.parse::().ok(); 262 | } 263 | } 264 | } 265 | None 266 | } 267 | 268 | fn parse_status(line: &io::Result) -> Option { 269 | if let Ok(l) = line { 270 | if l.starts_with(STATUS_ATTRIBUTE) { 271 | return l.split('=').nth(1).map(|s| s.to_string()); 272 | } 273 | } 274 | None 275 | } 276 | 277 | fn find_attribute_prefix<'a, 'b>(path: &'a str) -> Result<&'b str, Error> { 278 | let content = fs::read_to_string(path)?; 279 | let mut unit = None; 280 | if content.contains(&format!( 281 | "{}_{}_{}=", 282 | POWER_SUPPLY, ENERGY_PREFIX, FULL_DESIGN_ATTRIBUTE 283 | )) && content.contains(&format!( 284 | "{}_{}_{}=", 285 | POWER_SUPPLY, ENERGY_PREFIX, FULL_ATTRIBUTE 286 | )) && content.contains(&format!( 287 | "{}_{}_{}=", 288 | POWER_SUPPLY, ENERGY_PREFIX, NOW_ATTRIBUTE 289 | )) { 290 | unit = Some(ENERGY_PREFIX); 291 | } else if content.contains(&format!( 292 | "{}_{}_{}=", 293 | POWER_SUPPLY, CHARGE_PREFIX, FULL_DESIGN_ATTRIBUTE 294 | )) && content.contains(&format!( 295 | "{}_{}_{}=", 296 | POWER_SUPPLY, CHARGE_PREFIX, FULL_ATTRIBUTE 297 | )) && content.contains(&format!( 298 | "{}_{}_{}=", 299 | POWER_SUPPLY, CHARGE_PREFIX, NOW_ATTRIBUTE 300 | )) { 301 | unit = Some(CHARGE_PREFIX); 302 | } 303 | unit.ok_or_else(|| { 304 | Error::new(format!( 305 | "unable to find the required attributes in {}", 306 | path 307 | )) 308 | }) 309 | } 310 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | mod error; 6 | use bato::{Bato, Config}; 7 | use std::io::Error; 8 | use std::process; 9 | use std::thread; 10 | use std::time::Duration; 11 | 12 | const TICK_RATE: Duration = Duration::from_secs(5); 13 | 14 | fn main() -> Result<(), Error> { 15 | let mut config = Config::new().unwrap_or_else(|err| { 16 | eprintln!("bato error: {}", err); 17 | process::exit(1); 18 | }); 19 | config.normalize(); 20 | let mut tick = TICK_RATE; 21 | if let Some(rate) = config.tick_rate { 22 | tick = Duration::from_secs(rate as u64); 23 | } 24 | let mut bato = Bato::with_config(&config).unwrap_or_else(|err| { 25 | eprintln!("bato error: {}", err); 26 | process::exit(1); 27 | }); 28 | bato.start().unwrap_or_else(|err| { 29 | eprintln!("bato error: {}", err); 30 | process::exit(1); 31 | }); 32 | loop { 33 | bato.update(&config).unwrap_or_else(|err| { 34 | eprintln!("bato error: {}", err); 35 | process::exit(1); 36 | }); 37 | thread::sleep(tick); 38 | } 39 | bato.close(); 40 | Ok(()) 41 | } 42 | -------------------------------------------------------------------------------- /src/notify.rs: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | 5 | use crate::{Notification, Urgency}; 6 | use std::ffi::CString; 7 | use std::os::raw::c_char; 8 | use std::ptr; 9 | 10 | #[repr(C)] 11 | // for details see https://doc.rust-lang.org/nomicon/ffi.html#representing-opaque-structs 12 | pub struct NotifyNotification { 13 | _private: [u8; 0], 14 | } 15 | 16 | #[link(name = "notilus", kind = "static")] 17 | extern "C" { 18 | pub fn init(app_name: *const c_char) -> *mut NotifyNotification; 19 | pub fn notify( 20 | notification: *mut NotifyNotification, 21 | summary: *const c_char, 22 | body: *const c_char, 23 | icon: *const c_char, 24 | urgency: Urgency, 25 | ) -> i32; 26 | pub fn uninit(notification: *mut NotifyNotification); 27 | } 28 | 29 | pub fn init_libnotilus(app_name: &str) -> *mut NotifyNotification { 30 | let notification; 31 | let app_name_cstr = CString::new(app_name).expect("CString::new failed"); 32 | unsafe { 33 | notification = init(app_name_cstr.as_ptr()); 34 | } 35 | notification 36 | } 37 | 38 | pub fn send(notification: *mut NotifyNotification, data: &Notification) { 39 | let mut body = ptr::null(); 40 | if let Some(v) = &data.body { 41 | body = v.as_ptr() 42 | } 43 | let mut icon = ptr::null(); 44 | if let Some(v) = &data.icon { 45 | icon = v.as_ptr() 46 | } 47 | let mut urgency = Urgency::Normal; 48 | if let Some(v) = &data.urgency { 49 | urgency = *v 50 | } 51 | let i; 52 | unsafe { 53 | i = notify(notification, data.summary.as_ptr(), body, icon, urgency); 54 | } 55 | match i { 56 | 1 => eprintln!("bato error: in libnotilus, fail to update the notification"), 57 | 2 => eprintln!("bato error: in libnotilus, fail to show the notification"), 58 | _ => {} 59 | }; 60 | } 61 | 62 | pub fn close_libnotilus(notification: *mut NotifyNotification) { 63 | unsafe { uninit(notification) } 64 | } 65 | --------------------------------------------------------------------------------