mirror of
https://github.com/MatMasIt/ligthweight_battery_notify.git
synced 2026-01-20 11:36:52 +01:00
First commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
go.sum
|
||||||
9
README.md
Normal file
9
README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Liwghtweight Battery Monitor
|
||||||
|
|
||||||
|
A clean and simple battery monitor that shows an alert (and optionally plays a sound) when the battery is running out.
|
||||||
|
|
||||||
|
This can be useful in window managers or lightweight desktop environments without this functionality.
|
||||||
|
|
||||||
|
An alert and critical level can be configured (see [battery-monitor.yaml](battery-monitor.yaml)).
|
||||||
|
|
||||||
|
Depends only on dbus for notifications (uses filesystem polling to query battery status).
|
||||||
3
audio/bat-critical.license
Normal file
3
audio/bat-critical.license
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
SPDX-FileCopyrightText: 2008 Nuno Filipe Povoa <npovoa@gmail.com>
|
||||||
|
|
||||||
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
BIN
audio/bat-critical.ogg
Normal file
BIN
audio/bat-critical.ogg
Normal file
Binary file not shown.
3
audio/bat-low.license
Normal file
3
audio/bat-low.license
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
SPDX-FileCopyrightText: 2008 Nuno Filipe Povoa <npovoa@gmail.com>
|
||||||
|
|
||||||
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
BIN
audio/bat-low.ogg
Normal file
BIN
audio/bat-low.ogg
Normal file
Binary file not shown.
16
battery-monitor.yaml
Normal file
16
battery-monitor.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
app_name: "Battery Monitor"
|
||||||
|
poll_interval: 5 # Check battery every N seconds
|
||||||
|
|
||||||
|
low_battery:
|
||||||
|
threshold: 30 # Percentage at which to trigger low battery warning
|
||||||
|
title: "Low Battery"
|
||||||
|
icon: "battery-low"
|
||||||
|
sound: audio/bat-low.ogg
|
||||||
|
message: "Battery is low at %d%%. Please connect charger."
|
||||||
|
|
||||||
|
critical_battery:
|
||||||
|
threshold: 10 # Percentage at which to trigger critical battery warning
|
||||||
|
title: "Critical Battery!"
|
||||||
|
icon: "battery-empty"
|
||||||
|
sound: audio/bat-critical.ogg
|
||||||
|
message: "Battery is critically low at %d%%! Connect charger immediately!"
|
||||||
10
go.mod
Normal file
10
go.mod
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module battery-monitor
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
toolchain go1.24.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
352
main.go
Normal file
352
main.go
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
AppName string `yaml:"app_name"`
|
||||||
|
PollInterval int `yaml:"poll_interval"` // seconds
|
||||||
|
LowBattery BatteryLevel `yaml:"low_battery"`
|
||||||
|
CriticalBattery BatteryLevel `yaml:"critical_battery"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatteryLevel struct {
|
||||||
|
Threshold int `yaml:"threshold"`
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Icon string `yaml:"icon"`
|
||||||
|
Sound string `yaml:"sound"` // Optional
|
||||||
|
Message string `yaml:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Battery interface {
|
||||||
|
Capacity() (int, error)
|
||||||
|
IsCharging() (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SysfsBattery struct {
|
||||||
|
capacityPath string
|
||||||
|
statusPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notifier interface {
|
||||||
|
Send(summary, body, urgency, icon string) (uint32, error)
|
||||||
|
Close(id uint32) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type DBusNotifier struct {
|
||||||
|
conn *dbus.Conn
|
||||||
|
obj dbus.BusObject
|
||||||
|
appName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Monitor struct {
|
||||||
|
config Config
|
||||||
|
battery Battery
|
||||||
|
notifier Notifier
|
||||||
|
currentLevel string // "normal", "low", "critical"
|
||||||
|
notificationID uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var config Config
|
||||||
|
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default poll interval
|
||||||
|
if config.PollInterval == 0 {
|
||||||
|
config.PollInterval = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default app name
|
||||||
|
if config.AppName == "" {
|
||||||
|
config.AppName = "Battery Monitor"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SysfsBattery implementation
|
||||||
|
func NewSysfsBattery() (*SysfsBattery, error) {
|
||||||
|
batteries := []string{
|
||||||
|
"/sys/class/power_supply/BAT0",
|
||||||
|
"/sys/class/power_supply/BAT1",
|
||||||
|
"/sys/class/power_supply/battery",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range batteries {
|
||||||
|
capacityPath := filepath.Join(path, "capacity")
|
||||||
|
statusPath := filepath.Join(path, "status")
|
||||||
|
|
||||||
|
if _, err := os.Stat(capacityPath); err == nil {
|
||||||
|
if _, err := os.Stat(statusPath); err == nil {
|
||||||
|
log.Printf("Found battery at: %s", path)
|
||||||
|
return &SysfsBattery{
|
||||||
|
capacityPath: capacityPath,
|
||||||
|
statusPath: statusPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no battery found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *SysfsBattery) Capacity() (int, error) {
|
||||||
|
data, err := os.ReadFile(b.capacityPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
capacity, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return capacity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *SysfsBattery) IsCharging() (bool, error) {
|
||||||
|
data, err := os.ReadFile(b.statusPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
status := strings.TrimSpace(string(data))
|
||||||
|
return status == "Charging" || status == "Full", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBusNotifier implementation
|
||||||
|
func NewDBusNotifier(appName string) (*DBusNotifier, error) {
|
||||||
|
conn, err := dbus.SessionBus()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := conn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications")
|
||||||
|
|
||||||
|
return &DBusNotifier{
|
||||||
|
conn: conn,
|
||||||
|
obj: obj,
|
||||||
|
appName: appName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *DBusNotifier) Send(summary, body, urgency, icon string) (uint32, error) {
|
||||||
|
var urgencyLevel byte
|
||||||
|
switch urgency {
|
||||||
|
case "critical":
|
||||||
|
urgencyLevel = 2
|
||||||
|
case "normal":
|
||||||
|
urgencyLevel = 1
|
||||||
|
default:
|
||||||
|
urgencyLevel = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
hints := map[string]dbus.Variant{
|
||||||
|
"urgency": dbus.MakeVariant(urgencyLevel),
|
||||||
|
"resident": dbus.MakeVariant(true), // Keep in notification center
|
||||||
|
"transient": dbus.MakeVariant(false), // Don't auto-dismiss
|
||||||
|
}
|
||||||
|
|
||||||
|
call := n.obj.Call("org.freedesktop.Notifications.Notify", 0,
|
||||||
|
n.appName, // app_name
|
||||||
|
uint32(0), // replaces_id (we'll manage this in Monitor)
|
||||||
|
icon, // app_icon
|
||||||
|
summary, // summary
|
||||||
|
body, // body
|
||||||
|
[]string{}, // actions
|
||||||
|
hints, // hints
|
||||||
|
int32(0), // expire_timeout (0 = no auto-dismiss)
|
||||||
|
)
|
||||||
|
|
||||||
|
if call.Err != nil {
|
||||||
|
return 0, call.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
var id uint32
|
||||||
|
err := call.Store(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *DBusNotifier) Close(id uint32) error {
|
||||||
|
if id == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
call := n.obj.Call("org.freedesktop.Notifications.CloseNotification", 0, id)
|
||||||
|
return call.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor implementation
|
||||||
|
func (m *Monitor) playSound(soundFile string) {
|
||||||
|
if soundFile == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand home directory
|
||||||
|
if strings.HasPrefix(soundFile, "~/") {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err == nil {
|
||||||
|
soundFile = filepath.Join(home, soundFile[2:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try paplay first, then aplay
|
||||||
|
go func() {
|
||||||
|
cmd := exec.Command("paplay", soundFile)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
cmd = exec.Command("aplay", "-q", soundFile)
|
||||||
|
_ = cmd.Run()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) check() error {
|
||||||
|
charging, err := m.battery.IsCharging()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read charging status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if charging {
|
||||||
|
// Clear notification if charging
|
||||||
|
if m.currentLevel != "normal" {
|
||||||
|
log.Println("Power connected, clearing notifications")
|
||||||
|
if err := m.notifier.Close(m.notificationID); err != nil {
|
||||||
|
log.Printf("Failed to close notification: %v", err)
|
||||||
|
}
|
||||||
|
m.notificationID = 0
|
||||||
|
m.currentLevel = "normal"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
capacity, err := m.battery.Capacity()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read capacity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Battery: %d%% (discharging)", capacity)
|
||||||
|
|
||||||
|
// Determine current state
|
||||||
|
var newLevel string
|
||||||
|
var level BatteryLevel
|
||||||
|
|
||||||
|
if capacity <= m.config.CriticalBattery.Threshold {
|
||||||
|
newLevel = "critical"
|
||||||
|
level = m.config.CriticalBattery
|
||||||
|
} else if capacity <= m.config.LowBattery.Threshold {
|
||||||
|
newLevel = "low"
|
||||||
|
level = m.config.LowBattery
|
||||||
|
} else {
|
||||||
|
newLevel = "normal"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only notify on state change
|
||||||
|
if newLevel != m.currentLevel && newLevel != "normal" {
|
||||||
|
log.Printf("Battery level changed to: %s (%d%%)", newLevel, capacity)
|
||||||
|
|
||||||
|
title := level.Title
|
||||||
|
message := fmt.Sprintf(level.Message, capacity)
|
||||||
|
|
||||||
|
// Send notification - it will replace previous one if ID exists
|
||||||
|
id, err := m.notifier.Send(title, message, "critical", level.Icon)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to send notification: %v", err)
|
||||||
|
} else {
|
||||||
|
// Close old notification explicitly (cleaner than relying on replace)
|
||||||
|
if m.notificationID != 0 && m.notificationID != id {
|
||||||
|
m.notifier.Close(m.notificationID)
|
||||||
|
}
|
||||||
|
m.notificationID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
m.playSound(level.Sound)
|
||||||
|
m.currentLevel = newLevel
|
||||||
|
} else if newLevel == "normal" && m.currentLevel != "normal" {
|
||||||
|
// Battery recovered above thresholds
|
||||||
|
if err := m.notifier.Close(m.notificationID); err != nil {
|
||||||
|
log.Printf("Failed to close notification: %v", err)
|
||||||
|
}
|
||||||
|
m.notificationID = 0
|
||||||
|
m.currentLevel = "normal"
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) run() error {
|
||||||
|
log.Printf("Starting battery monitor (poll interval: %ds)", m.config.PollInterval)
|
||||||
|
log.Printf("Low threshold: %d%%, Critical threshold: %d%%",
|
||||||
|
m.config.LowBattery.Threshold,
|
||||||
|
m.config.CriticalBattery.Threshold)
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
if err := m.check(); err != nil {
|
||||||
|
log.Printf("Check failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Duration(m.config.PollInterval) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
if err := m.check(); err != nil {
|
||||||
|
log.Printf("Check failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := "battery-monitor.yaml"
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
configPath = os.Args[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand home directory
|
||||||
|
if strings.HasPrefix(configPath, "~/") {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err == nil {
|
||||||
|
configPath = filepath.Join(home, configPath[2:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := loadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
battery, err := NewSysfsBattery()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to find battery: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifier, err := NewDBusNotifier(config.AppName)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create notifier: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor := &Monitor{
|
||||||
|
config: *config,
|
||||||
|
battery: battery,
|
||||||
|
notifier: notifier,
|
||||||
|
currentLevel: "normal",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := monitor.run(); err != nil {
|
||||||
|
log.Fatalf("Monitor failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user