#!/usr/bin/env bash
# install-battery-tooltip.sh
#
# Extends Omarchy's waybar battery tooltip with a full readout:
# icon, percentage, time remaining, power draw, and auto-detected
# battery capacity in Wh. Also adds an accent-colored border and
# padding to the tooltip box so it matches the active theme.
#
# Idempotent — safe to re-run any time (e.g. to refresh the Wh
# number as the battery degrades).
#
# Usage:
# ./install-battery-tooltip.sh # uses design capacity
# ./install-battery-tooltip.sh --current # uses current full-charge capacity
set -euo pipefail
CONFIG="${HOME}/.config/waybar/config.jsonc"
STYLE="${HOME}/.config/waybar/style.css"
die() { printf '✗ %s\n' "$*" >&2; exit 1; }
# --- Laptop detection (skip cleanly on desktops/servers) ---
#
# Strategy: try the most reliable source first, fall back through less reliable
# signals. Any positive match → laptop. All negatives / no data → skip.
# Override with --force for testing or unusual hardware.
is_laptop() {
# 1. systemd's hostnamectl — merges DMI with heuristics, most reliable.
if command -v hostnamectl >/dev/null 2>&1; then
case "$(hostnamectl chassis 2>/dev/null)" in
laptop|portable|convertible|tablet|handset) return 0 ;;
desktop|server|embedded|vm|container) return 1 ;;
# empty / unknown → fall through to next method
esac
fi
# 2. Raw SMBIOS chassis_type. Mobile form factors:
# 8=Portable 9=Laptop 10=Notebook 11=Handheld 14=Sub-Notebook
# 30=Tablet 31=Convertible 32=Detachable
if [[ -r /sys/class/dmi/id/chassis_type ]]; then
case "$(cat /sys/class/dmi/id/chassis_type)" in
8|9|10|11|14|30|31|32) return 0 ;;
3|4|5|6|7|15|16|17|23|24|25|28) return 1 ;;
esac
fi
# 3. Last resort: presence of a battery.
compgen -G '/sys/class/power_supply/BAT*' >/dev/null && return 0
return 1
}
# --- Argument parsing ---
FORCE=false
MODE=design
for arg in "$@"; do
case "$arg" in
--force) FORCE=true ;;
--current|current) MODE=current ;;
design) MODE=design ;;
-h|--help)
sed -n '2,10p' "$0" | sed 's/^# \?//'
exit 0 ;;
*) die "Unknown argument: $arg (use: --current, --force)" ;;
esac
done
if ! $FORCE && ! is_laptop; then
echo "ℹ Not a laptop (per hostnamectl/DMI) — skipping battery tooltip install."
echo " Use --force to install anyway."
exit 0
fi
# --- Pre-flight ---
[[ -f "$CONFIG" ]] || die "No $CONFIG — Omarchy/Waybar not set up?"
[[ -f "$STYLE" ]] || die "No $STYLE — Omarchy/Waybar not set up?"
command -v python3 >/dev/null || die "python3 required."
# --- Detect battery capacity (Wh) ---
BAT=$(find /sys/class/power_supply -maxdepth 1 -name 'BAT*' -printf '%f\n' 2>/dev/null | sort | head -n1)
[[ -n "$BAT" ]] || die "No battery found under /sys/class/power_supply/."
P="/sys/class/power_supply/$BAT"
read_sysfs() { [[ -r "$1" ]] || die "Can't read $1"; cat "$1"; }
# Design mode (default) = rated spec, never changes.
# Current mode = current full-charge capacity, reflects degradation.
if [[ "$MODE" == "design" ]]; then
if [[ -r "$P/energy_full_design" ]]; then
WH=$(awk -v e="$(read_sysfs "$P/energy_full_design")" 'BEGIN { printf "%.0f", e/1000000 }')
else
charge=$(read_sysfs "$P/charge_full_design")
voltage=$(cat "$P/voltage_min_design" 2>/dev/null || read_sysfs "$P/voltage_now")
WH=$(awk -v c="$charge" -v v="$voltage" 'BEGIN { printf "%.0f", (c*v)/1e12 }')
fi
else
if [[ -r "$P/energy_full" ]]; then
WH=$(awk -v e="$(read_sysfs "$P/energy_full")" 'BEGIN { printf "%.0f", e/1000000 }')
else
charge=$(read_sysfs "$P/charge_full")
voltage=$(cat "$P/voltage_min_design" 2>/dev/null || read_sysfs "$P/voltage_now")
WH=$(awk -v c="$charge" -v v="$voltage" 'BEGIN { printf "%.0f", (c*v)/1e12 }')
fi
fi
[[ "$WH" =~ ^[0-9]+$ && "$WH" -gt 0 ]] || die "Couldn't compute a sensible Wh value (got '$WH')."
echo "→ $BAT: ${WH}Wh (${MODE} capacity)"
# --- Patch config.jsonc and style.css ---
TS=$(date +%s)
cp "$CONFIG" "${CONFIG}.bak.${TS}"
cp "$STYLE" "${STYLE}.bak.${TS}"
python3 - "$CONFIG" "$STYLE" "$WH" << 'PY'
import re, sys
from pathlib import Path
config_path = Path(sys.argv[1])
style_path = Path(sys.argv[2])
wh = int(sys.argv[3])
# =========================================================================
# config.jsonc — battery module tooltip keys
# =========================================================================
src = config_path.read_text()
m = re.search(r'"battery"\s*:\s*\{', src)
if not m:
sys.exit('✗ No battery block in config.jsonc')
start = m.end()
depth, j = 1, start
while j < len(src) and depth > 0:
if src[j] == '{': depth += 1
elif src[j] == '}': depth -= 1
j += 1
end = j - 1
block = src[start:end]
indent_m = re.search(r'\n([ \t]+)"', block)
indent = indent_m.group(1) if indent_m else ' '
desired = [
('format-time',
'"format-time": "{H:02}h:{M:02}m"'),
('tooltip-format-discharging',
f'"tooltip-format-discharging": " Battery{{capacity:>3}}% · {{time}} left · ↓{{power:>4.1f}}W/{wh}Wh"'),
('tooltip-format-charging',
f'"tooltip-format-charging": " Battery{{capacity:>3}}% · {{time}} to full · ↑{{power:>4.1f}}W/{wh}Wh"'),
('tooltip-format-full',
f'"tooltip-format-full": " Battery {{capacity}}% · Full · {wh}Wh"'),
('tooltip-format-plugged',
f'"tooltip-format-plugged": " Battery {{capacity}}% · Plugged in · {wh}Wh"'),
]
for key, new_line in desired:
key_re = re.compile(rf'^([ \t]*)"{re.escape(key)}"\s*:\s*"[^"]*"(,?)\s*$',
re.MULTILINE)
if key_re.search(block):
block = key_re.sub(lambda m: f'{m.group(1)}{new_line}{m.group(2) or ","}', block, count=1)
else:
trailing_m = re.search(r'\s*$', block)
ip = trailing_m.start() if trailing_m else len(block)
block = (block[:ip].rstrip() + '\n' + indent + new_line + ',' + block[ip:])
config_path.write_text(src[:start] + block + src[end:])
print(f' · config.jsonc: patched {len(desired)} battery keys')
# =========================================================================
# style.css — walker.css import + tooltip rules in a marker block
# =========================================================================
style = style_path.read_text()
# 1. Ensure walker.css is imported (for @selected-text)
if 'walker.css' not in style:
new_style, n = re.subn(
r'(@import\s+"[^"]*/waybar\.css";)(\s*\n)',
r'\1\2@import "../omarchy/current/theme/walker.css";\n',
style,
count=1,
)
if n == 0:
# No existing waybar.css import — prepend both.
new_style = ('@import "../omarchy/current/theme/waybar.css";\n'
'@import "../omarchy/current/theme/walker.css";\n\n' + style)
style = new_style
# 2. Tooltip rules inside a marker block (idempotent replace)
MARKER_START = '/* >>> battery-tooltip */'
MARKER_END = '/* <<< battery-tooltip */'
tooltip_block = (
f'{MARKER_START}\n'
'tooltip,\n'
'tooltip box,\n'
'tooltip label,\n'
'tooltip * {\n'
' margin: 0;\n'
' padding: 0;\n'
'}\n\n'
'tooltip {\n'
' padding: 7px;\n'
' border: 2px solid @selected-text;\n'
'}\n'
f'{MARKER_END}\n'
)
block_re = re.compile(re.escape(MARKER_START) + r'.*?' + re.escape(MARKER_END) + r'\n?', re.DOTALL)
if block_re.search(style):
style = block_re.sub(tooltip_block, style)
else:
style = style.rstrip() + '\n\n' + tooltip_block
style_path.write_text(style)
print(' · style.css: walker.css import + tooltip rules in place')
PY
# --- Reload ---
if command -v omarchy-restart-waybar >/dev/null; then
if omarchy-restart-waybar >/dev/null 2>&1; then
echo "→ Waybar restarted."
else
echo "⚠ omarchy-restart-waybar failed — reload manually."
fi
fi
cat << EOF
✓ Done. Hover the battery icon to see:
Battery XX% · HHh:MMm left · ↓X.XW/${WH}Wh
Inside an accent-colored border matching the active theme.
Backups (this run): ${CONFIG}.bak.${TS}
${STYLE}.bak.${TS}
Re-run with --current to use the degraded (current full-charge) capacity
instead of design capacity.
EOF