A Shell Script That Charges Batteries Safely and Optimally
I play VR for fun and fitness, rotating between two headsets with slap-on batteries to extend playtime. After each session, I plug in the headset and batteries to top them up for next time. But …
I've Been Doing It Wrong
With multiple USB chargers and devices to manage, I got into the habit of leaving everything plugged in all the time. Not ideal. Even when fully charged, batteries slowly drain and trigger tiny top-ups that can shorten their lifespan. Eventually, they stop accepting a charge altogether, needing replacement. I've already done open headset surgery on both headsets to replace internal batteries, and I'd rather not go through that hassle again.
The best practice is to disconnect them from the charger as soon as they are full—something I'm likely to forget.
Attempt With Home Assistant
To address this, I first tried using Home Assistant with an energy-monitoring smart plug and a 6-port USB charger. All devices stayed plugged into the charger, while Home Assistant watched the plug's power draw. It promised to be the perfect solution—until I ran into Home Assistant's limitations with tracking real-time sensor states and many complicated edge cases.
After days of complex tinkering, testing, and failing attempts, I gave up. But then it hit me: a shell script could do this by using the plug's web API. That led to a simple background daemon that now runs my battery charging station perfectly. With no Home Assistant limitations, I could make it do everything I wanted.
Building a Script
Designed for use with FreeBSD and any Tasmota-upgraded smart plug that can measure active energy usage, this robust shell script provides intelligent monitoring and automatic safety cutoffs, turning a basic smart plug and a charger into a sophisticated power manager designed for optimal, unattended battery charging. I no longer have to worry about manually turning off the charger or checking its status—the script handles it all.
This runs as a terminal foreground application or as a background system daemon as a service. A FreeBSD service startup script is included as well. But, understand this is not a replacement for device-level battery management. The goal here is to optimize unattended USB charging behavior, reducing float time and micro-cycling, while leaving per-cell state-of-charge control entirely to the device itself.
Key Features and Operation
The script monitors device power consumption in real-time and implements multiple safety and convenience features:
-
Dual Safety Cutoffs: It enforces two primary rules to protect equipment:
- Maximum Runtime: Shuts the plug off after a configurable time limit (e.g., 3 hours) to prevent accidental over-charging.
- Idle Detection (Charge Complete): It monitors the current draw (Amps) and shuts the plug off only after the current has remained below a tiny, user-defined threshold (e.g., < 0.05A) for a specific duration (e.g., 10 seconds). This ensures the connected battery has finished its slow charge cycle and isn't just fluctuating.
-
Auto Power-On Scheduling: A convenience feature to automatically force the plug ON if it happens to be currently off. This allows for one-a-day top-offs.
-
Command-Line Flexibility: Set key parameters when starting the script, such as the device hostname/IP and whether file logging (
-l) should be enabled to track all major events and safety cutoffs. -
Interactive Status Display: Interact with the script when running it on a terminal. Pressing Control-T will instantly print a full status report—including current draw, remaining runtime, and idle counter—directly to the console. This action also toggles the script's verbose mode for granular debugging without requiring a restart.
-
Detailed Logging: A running log keeps track of all the events and statuses. While charging, the log updates with the current amperage draw every 15 minutes (or once a second in verbose mode).
2025-12-18 16:01:18: Starting daemon PID=61695
2025-12-18 16:01:19: +----- Status Information -----
2025-12-18 16:01:19: | Device: battery-charger
2025-12-18 16:01:19: | Auto-On Time: 19:00
2025-12-18 16:01:19: | State: UNKNOWN with plug OFF
2025-12-18 16:01:19: | Stabilizing: No
2025-12-18 16:01:19: | Current Draw: N/A
2025-12-18 16:01:19: | Runtime: 00:00:00 / 03:00:00
2025-12-18 16:01:19: | Idle Counter: 0s / 10s
2025-12-18 16:01:19: | Update Mode: Quiet with 3s polling while charging (otherwise 10s)
2025-12-18 16:01:19: +------------------------------
2025-12-18 16:01:19: Plug is OFF - waiting for power-on or auto-on at 19:00 ...
2025-12-18 19:00:09: AUTO-ON TIME REACHED AT 19:00. Turning plug ON.
2025-12-18 19:00:10: Stabilizing at 0.000A (0/15s)
2025-12-18 19:00:26: Stabilization complete - starting monitoring
2025-12-18 19:00:26: Active: 0.242A (runtime: 00:00:16)
2025-12-18 19:15:28: Active: 0.096A (runtime: 00:15:18)
2025-12-18 19:20:11: Idle: 0.045A (3/10s)
2025-12-18 19:20:17: Active: 0.055A (idle counter reset)
2025-12-18 19:20:21: Active: 0.085A (runtime: 00:20:11)
2025-12-18 19:20:27: Idle: 0.000A (3/10s)
2025-12-18 19:20:37: Turning OFF plug - Reason: Idle current (<0.05A) for 10s
2025-12-18 19:20:37: Charging Complete. Total ON time: 00:20:27
2025-12-18 19:20:37: Plug is OFF - waiting for power-on or auto-on at 19:00 ...
Now I get reliable, automated charging management that protects batteries and reduces phantom power consumption. After a VR session, I plug everything in and let the script turn on the juice at 7PM, knowing it will turn it off after everything is recharged.
Application Script
Install in /usr/local/bin as battery_charger.sh
#!/bin/sh
#
# battery_charger.sh - Monitor current draw from a smart plug and enforce safety cutoffs.
#
# Usage: battery_charger.sh [-h] [-q] [-d] [-l] [-v] [-a HH:MM|off] [-r HH:MM|off] [-c hostname]
# -h Display this help/usage message and exit.
# -q Quiet mode - suppress console output (only log major events to file)
# -d Daemon mode - fork to background, detach from terminal
# -l Enable file logging to /var/log/battery_charger.log
# -v Verbose mode - log every poll (overrides -q)
# -a HH:MM|off Auto-On time to force plug ON if OFF, or 'off' to disable (Default: 04:00)
# -r HH:MM|off Max Runtime to override default 2 hour limit, or 'off' for unlimited (Default: 02:00)
# -c device Hostname or IP of the smart plug device (Default: battery-charger)
#
# Interactive commands (when run in foreground):
# Ctrl-T Display current status AND toggle verbose mode (SIGINFO)
# On Linux, send SIGUSR1 instead: kill -USR1 <pid>
#
# Compatible with Tasmota-upgraded smart plugs.
# https://www.morgandavis.net/post/optimized-battery-charging
# ============================================================================
# CONFIGURATION CONSTANTS
# ============================================================================
POLL_INTERVAL_DEFAULT=3
POLL_INTERVAL_VERBOSE=1
MAX_RUNTIME_DEFAULT=7200
IDLE_THRESHOLD=0.01 # fast cutoff: true zero/dead-load only; float taper handled by FLOAT_THRESHOLD
IDLE_EXIT_THRESHOLD=0.03 # deadband above IDLE_THRESHOLD
IDLE_CONSECUTIVE_TIME=10
FLOAT_THRESHOLD=0.08
FLOAT_CONSECUTIVE_TIME=900
STABILIZATION_TIME=15
WAIT_OFF_INTERVAL=10
FETCH_TIMEOUT=10
CHARGING_UPDATE_INTERVAL=900
DEFAULT_AUTO_ON_TIME="04:00"
DEFAULT_DEVICE="battery-charger"
# ============================================================================
# HTTP FETCHER SHIM
# ============================================================================
# Detect available HTTP client once at load time.
# Preference order: fetch (FreeBSD) > curl > wget
_detect_fetcher() {
if command -v fetch > /dev/null 2>&1; then HTTP_CLIENT="fetch"
elif command -v curl > /dev/null 2>&1; then HTTP_CLIENT="curl"
elif command -v wget > /dev/null 2>&1; then HTTP_CLIENT="wget"
else printf 'ERROR: No HTTP client found (fetch, curl, or wget required).\n' >&2; exit 1
fi
}
# http_fetch URL [dest] -- dest defaults to stdout; pass /dev/null to discard.
http_fetch() {
_hf_out="${2:--}"
case "$HTTP_CLIENT" in
fetch) fetch -o "$_hf_out" -a -q -T "$FETCH_TIMEOUT" "$1" 2>/dev/null ;;
curl) curl -sf --max-time "$FETCH_TIMEOUT" -o "$_hf_out" "$1" 2>/dev/null ;;
wget) wget -qO "$_hf_out" --timeout="$FETCH_TIMEOUT" "$1" 2>/dev/null ;;
esac
}
# ============================================================================
# USAGE / HELPERS
# ============================================================================
usage() {
cat <<EOF >&2
Usage: $(basename "$0") [-h] [-q] [-d] [-l] [-v] [-a HH:MM|off] [-r HH:MM|off] [-c hostname]
Monitor current draw from a smart plug and enforce safety cutoffs. Options:
-h Display this help/usage message and exit.
-q Quiet mode - suppress console output.
-d Daemon mode - fork to background and detach.
-l Enable file logging to /var/log/battery_charger.log.
-v Verbose mode - log every poll (overrides -q).
-a HH:MM|off Auto-On time to force plug ON if OFF, or 'off' to disable (Default: ${DEFAULT_AUTO_ON_TIME}).
-r HH:MM|off Max Runtime limit, or 'off' for unlimited (Default: $(seconds_to_hhmm "$MAX_RUNTIME_DEFAULT")).
-c device Hostname or IP of the smart plug device (Default: ${DEFAULT_DEVICE}).
EOF
exit 0
}
# Strip leading zeros before arithmetic to avoid octal interpretation.
convert_time_to_seconds() {
_h=$(printf '%s' "$1" | cut -d: -f1 | sed 's/^0*//')
_m=$(printf '%s' "$1" | cut -d: -f2 | sed 's/^0*//')
echo $(( ${_h:-0} * 3600 + ${_m:-0} * 60 ))
}
seconds_to_hhmm() { printf "%02d:%02d" $(( $1 / 3600 )) $(( ($1 % 3600) / 60 )); }
format_time() { printf "%02d:%02d:%02d" $(( $1 / 3600 )) $(( ($1 % 3600) / 60 )) $(( $1 % 60 )); }
get_current_time() { date +%s; }
log_msg() {
_msg="$(date '+%Y-%m-%d %T'): $*"
[ "$QUIET_MODE" -eq 0 ] && printf '%s\n' "$_msg"
[ "$ENABLE_FILE_LOGGING" -eq 1 ] && printf '%s\n' "$_msg" >> "$LOG_FILE"
}
# ============================================================================
# COMMAND LINE ARGUMENT PARSING
# ============================================================================
parse_options() {
QUIET_MODE=0 DAEMON_MODE=0 ENABLE_FILE_LOGGING=0 VERBOSE_MODE=0
DEVICE="$DEFAULT_DEVICE"
AUTO_ON_TIME="$DEFAULT_AUTO_ON_TIME" AUTO_ON_ENABLED=1
MAX_RUNTIME=$MAX_RUNTIME_DEFAULT MAX_RUNTIME_TIME=$(seconds_to_hhmm "$MAX_RUNTIME_DEFAULT") MAX_RUNTIME_ENABLED=1
while [ $# -gt 0 ]; do
case "$1" in
-h) usage ;;
-q) QUIET_MODE=1; shift ;;
-d) DAEMON_MODE=1; shift ;;
-l) ENABLE_FILE_LOGGING=1; shift ;;
-v) VERBOSE_MODE=1; shift ;;
-a)
[ -z "${2:-}" ] && { printf 'Error: -a requires HH:MM or off.\n' >&2; exit 1; }
case "$2" in
[Oo][Ff][Ff]) AUTO_ON_ENABLED=0; AUTO_ON_TIME="off" ;;
[0-9][0-9]:[0-9][0-9]) AUTO_ON_TIME="$2"; AUTO_ON_ENABLED=1 ;;
*) printf 'Error: -a requires a valid HH:MM argument or "off".\n' >&2; exit 1 ;;
esac
shift 2 ;;
-r)
[ -z "${2:-}" ] && { printf 'Error: -r requires HH:MM or off.\n' >&2; exit 1; }
case "$2" in
[Oo][Ff][Ff]) MAX_RUNTIME_ENABLED=0; MAX_RUNTIME_TIME="off"; MAX_RUNTIME=0 ;;
[0-9][0-9]:[0-9][0-9]) MAX_RUNTIME_TIME="$2"; MAX_RUNTIME=$(convert_time_to_seconds "$2"); MAX_RUNTIME_ENABLED=1 ;;
*) printf 'Error: -r requires a valid HH:MM argument or "off".\n' >&2; exit 1 ;;
esac
shift 2 ;;
-c)
[ -z "${2:-}" ] && { printf 'Error: -c requires a hostname or IP.\n' >&2; exit 1; }
DEVICE="$2"; shift 2 ;;
-*) printf 'Unknown option: %s\n' "$1" >&2; usage ;;
*) printf 'Error: Positional arguments are not supported.\n' >&2; usage ;;
esac
done
# Resolve absolute path for safe daemon re-exec
SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
_base=$(basename "$0" .sh)
PID_FILE="/var/run/${_base}.pid"
STATE_FILE="/var/run/${_base}.state"
LOG_FILE="/var/log/${_base}.log"
POLL_INTERVAL=$([ "$VERBOSE_MODE" -eq 1 ] && echo "$POLL_INTERVAL_VERBOSE" || echo "$POLL_INTERVAL_DEFAULT")
}
# ============================================================================
# STATE MANAGEMENT
# ============================================================================
load_state() {
# Guard against sourcing a state file we don't own -- /var/run may be
# world-writable on some systems and sourcing an attacker-written file
# would execute arbitrary code.
[ -O "$STATE_FILE" ] || { log_msg "ERROR: State file not owned by current user -- ignoring."; reset_state; return; }
# shellcheck source=/dev/null
. "$STATE_FILE"
}
save_state() {
_prev_umask=$(umask)
umask 077 # Restrict to owner; state file contains device hostname path component
cat <<EOF > "$STATE_FILE"
runtime_start_time=${runtime_start_time:-0}
idle_start_time=${idle_start_time:-0}
idle_consecutive_seconds=${idle_consecutive_seconds:-0}
float_start_time=${float_start_time:-0}
stabilizing=${stabilizing:-0}
last_state=${last_state:-UNKNOWN}
last_charging_update=${last_charging_update:-0}
EOF
umask "$_prev_umask"
}
reset_state() {
runtime_start_time=$(get_current_time)
idle_start_time=0 idle_consecutive_seconds=0 float_start_time=0
stabilizing=1 last_state="STABILIZING" last_charging_update=0
rm -f "$STATE_FILE"
}
display_status() {
_runtime=$(get_runtime)
_now=$(get_current_time)
# Status 10 carries no POWER key; always use a dedicated Power query here.
_power_state=$(is_plug_on && printf 'ON' || printf 'OFF')
# LOG_QUIET=0: verbose ON; LOG_QUIET=1: quiet
if [ "${LOG_QUIET:-1}" -eq 0 ]; then
_cur_mode="Verbose"; _nxt_mode="Quiet"; _nxt_poll="$POLL_INTERVAL_DEFAULT"
else
_cur_mode="Quiet"; _nxt_mode="Verbose"; _nxt_poll="$POLL_INTERVAL_VERBOSE"
fi
_runtime_disp="$(format_time "$_runtime")"
[ "$MAX_RUNTIME_ENABLED" -eq 1 ] \
&& _runtime_disp="$_runtime_disp / $(format_time "$MAX_RUNTIME")" \
|| _runtime_disp="$_runtime_disp (unlimited)"
log_msg "+----- Status Information -----"
log_msg "| Device: $DEVICE"
log_msg "| Auto-On Time: $([ "$AUTO_ON_ENABLED" -eq 1 ] && printf '%s' "$AUTO_ON_TIME" || printf 'Disabled')"
log_msg "| State: ${last_state:-UNKNOWN} with plug $_power_state"
log_msg "| Stabilizing: $([ "${stabilizing:-0}" -eq 1 ] && printf 'Yes' || printf 'No')"
log_msg "| Current Draw: $([ -n "${current:-}" ] && printf '%sA' "$current" || printf 'N/A')"
log_msg "| Runtime: $_runtime_disp"
[ "${idle_start_time:-0}" -gt 0 ] && log_msg "| *** IDLE STAGE: $(( _now - idle_start_time ))s / ${IDLE_CONSECUTIVE_TIME}s ***"
[ "${float_start_time:-0}" -gt 0 ] && log_msg "| *** FLOAT STAGE: $(( _now - float_start_time ))s / ${FLOAT_CONSECUTIVE_TIME}s ***"
log_msg "| Update Mode: $_cur_mode with ${POLL_INTERVAL}s polling (toggle with Ctrl-T for $_nxt_mode at ${_nxt_poll}s)"
log_msg "+------------------------------"
save_state
}
# ============================================================================
# DEVICE COMMUNICATION
# ============================================================================
fetch_status() { http_fetch "http://${DEVICE}/cm?cmnd=Status+10"; }
extract_current() { printf '%s' "$1" | sed -n 's/.*"Current":\([0-9.]*\).*/\1/p'; }
# Standalone power check -- used only where no payload is already in hand
is_plug_on() {
_pw=$(http_fetch "http://${DEVICE}/cm?cmnd=Power" | sed -n 's/.*"POWER":"\([^"]*\)".*/\1/p')
[ "$_pw" = "ON" ]
}
turn_plug() {
_cmd="$1"; _reason="$2"; _last_current="${3:-}"
if [ "$_cmd" = "OFF" ]; then
log_msg "Turning OFF plug${_last_current:+ at ${_last_current}A} - Reason: $_reason"
[ "${runtime_start_time:-0}" -gt 0 ] && \
log_msg "Charging cycle ended. Total ON time: $(format_time "$(( $(get_current_time) - runtime_start_time ))")"
fi
http_fetch "http://${DEVICE}/cm?cmnd=Power+${_cmd}" /dev/null
[ "$_cmd" = "OFF" ] && reset_state
}
# ============================================================================
# MONITORING LOGIC
# ============================================================================
# Auto-on: fires when HH:MM matches AUTO_ON_TIME; debounces within the same
# minute via last_auto_on_time floored to the nearest 60s boundary.
check_auto_on() {
[ "$AUTO_ON_ENABLED" -eq 0 ] && return 1
[ "$(date '+%H:%M')" = "$AUTO_ON_TIME" ] || return 1
_minute=$(( $(get_current_time) / 60 * 60 ))
[ "${last_auto_on_time:-0}" -eq "$_minute" ] && return 1
last_auto_on_time=$_minute
log_msg "AUTO-ON TIME REACHED AT $AUTO_ON_TIME. Turning plug ON."
http_fetch "http://${DEVICE}/cm?cmnd=Power+ON" /dev/null
# turn_plug is not used here because its OFF branch calls reset_state;
# ON requires an explicit reset_state call to initialize the runtime anchor.
reset_state
return 0
}
wait_for_power_on() {
log_msg "Plug is OFF - waiting for power-on$([ "$AUTO_ON_ENABLED" -eq 1 ] \
&& printf ' or auto-on at %s ...' "$AUTO_ON_TIME" \
|| printf ' (auto-on disabled) ...')"
_ticks=0
while true; do
# Check auto-on and plug state every WAIT_OFF_INTERVAL seconds via 1s ticks
# so INT/TERM signals are delivered promptly rather than blocked in a long sleep.
if [ "$(( _ticks % WAIT_OFF_INTERVAL ))" -eq 0 ]; then
check_auto_on && break
if is_plug_on; then
_ic=$(fetch_status | extract_current)
log_msg "Plug is ON${_ic:+ at ${_ic}A} - stabilizing ..."
reset_state
break
fi
fi
sleep 1
_ticks=$(( _ticks + 1 ))
done
}
get_runtime() {
[ "${runtime_start_time:-0}" -gt 0 ] \
&& echo $(( $(get_current_time) - runtime_start_time )) \
|| echo 0
}
# ============================================================================
# DAEMON MODE
# ============================================================================
check_daemon_mode() {
[ "$DAEMON_MODE" -eq 0 ] && return
# Reconstruct arg list from non-default options only
_args=""
[ "$QUIET_MODE" -eq 1 ] && _args="$_args -q"
[ "$ENABLE_FILE_LOGGING" -eq 1 ] && _args="$_args -l"
[ "$VERBOSE_MODE" -eq 1 ] && _args="$_args -v"
if [ "$AUTO_ON_ENABLED" -eq 0 ]; then _args="$_args -a off"
elif [ "$AUTO_ON_TIME" != "$DEFAULT_AUTO_ON_TIME" ]; then _args="$_args -a $AUTO_ON_TIME"
fi
if [ "$MAX_RUNTIME_ENABLED" -eq 0 ]; then _args="$_args -r off"
elif [ "$MAX_RUNTIME_TIME" != "$(seconds_to_hhmm "$MAX_RUNTIME_DEFAULT")" ]; then _args="$_args -r $MAX_RUNTIME_TIME"
fi
[ "$DEVICE" != "$DEFAULT_DEVICE" ] && _args="$_args -c $DEVICE"
# Spawn child in its own process group, fully detached
# shellcheck disable=SC2086
(exec "$SELF" $_args < /dev/null > /dev/null 2>&1) &
# Wait briefly for child to write PID file, then report and exit
_i=0
while [ "$_i" -lt 10 ]; do
[ -f "$PID_FILE" ] && { printf 'Daemon PID: %s\n' "$(cat "$PID_FILE")" >&2; exit 0; }
sleep 0.1
_i=$(( _i + 1 ))
done
printf 'Daemon PID: Could not confirm (PID file not found)\n' >&2
exit 0
}
check_pid_lock() {
[ ! -f "$PID_FILE" ] && return
_pid=$(cat "$PID_FILE")
if kill -0 "$_pid" 2>/dev/null; then
printf '%s: ERROR: Script already running with PID %s. Exiting.\n' \
"$(date '+%Y-%m-%d %T')" "$_pid" >&2
exit 1
else
log_msg "Warning: Found stale PID file ($_pid), removing."
rm -f "$PID_FILE"
fi
}
# ============================================================================
# MAIN MONITORING LOOP
# ============================================================================
start_monitoring() {
# noclobber makes the redirect fail atomically if the file exists,
# closing the TOCTOU window between check_pid_lock and write.
(set -C; printf '%s' "$$" > "$PID_FILE") 2>/dev/null || {
printf '%s: ERROR: Could not write PID file (race condition or permission denied).\n' \
"$(date '+%Y-%m-%d %T')" >&2
exit 1
}
# LOG_QUIET=0: verbose ON (every poll); LOG_QUIET=1: quiet (periodic only)
LOG_QUIET=$([ "$VERBOSE_MODE" -eq 1 ] && echo 0 || echo 1)
last_auto_on_time=0 status_shown=0 current=""
log_msg "Starting $([ -t 0 ] \
&& printf 'interactive mode -- [Ctrl-T] toggles verbose status' \
|| printf 'daemon PID=%s' "$$")"
if is_plug_on; then
if [ -f "$STATE_FILE" ]; then
_mtime=$(stat -f %m "$STATE_FILE" 2>/dev/null || stat -c %Y "$STATE_FILE" 2>/dev/null)
_age=$(( $(get_current_time) - ${_mtime:-$(get_current_time)} ))
if [ "$_age" -lt 60 ]; then
log_msg "State: Resuming previous session (state age: ${_age}s)"
load_state
[ -z "${runtime_start_time:-}" ] || [ "$runtime_start_time" -eq 0 ] \
&& runtime_start_time=$(get_current_time)
else
log_msg "State: Starting fresh monitoring (previous state too old: ${_age}s)"
reset_state
fi
else
log_msg "State: Starting fresh monitoring"
reset_state
fi
else
display_status
status_shown=1
wait_for_power_on
fi
while true; do
data=$(fetch_status)
if [ -z "$data" ]; then
[ "$LOG_QUIET" -eq 0 ] || [ "${last_state}" != "FETCH_ERROR" ] \
&& log_msg "WARNING: Failed to fetch data from device"
last_state="FETCH_ERROR"
sleep "$POLL_INTERVAL"
continue
fi
current=$(extract_current "$data")
if [ -z "$current" ]; then
[ "$LOG_QUIET" -eq 0 ] || [ "${last_state}" != "PARSE_ERROR" ] \
&& log_msg "WARNING: Failed to parse current value"
last_state="PARSE_ERROR"
sleep "$POLL_INTERVAL"
continue
fi
runtime_seconds=$(get_runtime)
# Skip max runtime check during stabilization to avoid false cutoffs on resumed state
if [ "${stabilizing:-0}" -eq 0 ] && [ "$MAX_RUNTIME_ENABLED" -eq 1 ] \
&& [ "$runtime_seconds" -ge "$MAX_RUNTIME" ]; then
turn_plug "OFF" "Maximum runtime $(format_time "$MAX_RUNTIME") exceeded" "$current"
wait_for_power_on
continue
fi
# Wait fixed wall-clock time before trusting current readings
if [ "${stabilizing:-0}" -eq 1 ]; then
if [ "$runtime_seconds" -ge "$STABILIZATION_TIME" ]; then
stabilizing=0
log_msg "Stabilization complete - starting monitoring"
last_state="ACTIVE"
else
[ "$LOG_QUIET" -eq 0 ] || [ "${last_state}" != "STABILIZING" ] \
&& log_msg "Stabilizing at ${current}A"
last_state="STABILIZING"
save_state
sleep "$POLL_INTERVAL"
continue
fi
fi
# Single awk call emits is_idle, is_float, is_zero as space-separated tokens.
# awk handles 0, 0.0, 0.000 -- string equality would miss non-integer zeros.
_now=$(get_current_time)
read -r is_idle is_float _is_zero is_idle_exit << EOF
$(awk -v c="$current" -v ti="$IDLE_THRESHOLD" -v tf="$FLOAT_THRESHOLD" -v te="$IDLE_EXIT_THRESHOLD" \
'BEGIN { print (c+0 < ti+0) ? 1 : 0, (c+0 < tf+0) ? 1 : 0, (c+0 == 0) ? 1 : 0, (c+0 > te+0) ? 1 : 0 }')
EOF
# Status 10 carries no POWER key; issue a dedicated Power query only when
# current reads zero post-stabilization to detect an external power-off
# without adding an extra HTTP call on every normal poll.
if [ "$_is_zero" -eq 1 ] && ! is_plug_on; then
log_msg "Plug turned OFF externally"
reset_state
wait_for_power_on
continue
fi
# Secondary cutoff: sustained near-idle current for an extended period
if [ "$is_float" -eq 1 ]; then
[ "${float_start_time:-0}" -eq 0 ] && float_start_time=$_now
if [ "$(( _now - float_start_time ))" -ge "$FLOAT_CONSECUTIVE_TIME" ]; then
turn_plug "OFF" "Current <${FLOAT_THRESHOLD}A sustained for $(format_time "$FLOAT_CONSECUTIVE_TIME")" "$current"
wait_for_power_on
continue
fi
else
float_start_time=0
fi
if [ "$is_idle" -eq 1 ]; then
[ "${idle_start_time:-0}" -eq 0 ] && idle_start_time=$_now
idle_consecutive_seconds=$(( _now - idle_start_time ))
if [ "$idle_consecutive_seconds" -ge "$IDLE_CONSECUTIVE_TIME" ]; then
turn_plug "OFF" "Current <${IDLE_THRESHOLD}A for ${IDLE_CONSECUTIVE_TIME}s" "$current"
wait_for_power_on
continue
fi
if [ "$LOG_QUIET" -eq 0 ] || [ "${last_state}" != "IDLE" ]; then
log_msg "Idle: ${current}A"
last_state="IDLE"
elif [ $(( _now - last_charging_update )) -ge "$CHARGING_UPDATE_INTERVAL" ]; then
log_msg "Idle: ${current}A (runtime: $(format_time "$runtime_seconds"))"
last_charging_update=$_now
fi
elif [ "$is_idle_exit" -eq 1 ]; then
# Current has cleared the exit threshold -- fully leave idle state
if [ "${idle_start_time:-0}" -gt 0 ]; then
log_msg "Active: ${current}A (idle counter reset)"
idle_start_time=0 idle_consecutive_seconds=0
last_state="ACTIVE_RESET"
last_charging_update=$_now
elif [ "$LOG_QUIET" -eq 0 ] || [ "${last_state}" != "ACTIVE" ]; then
log_msg "Active: ${current}A (runtime: $(format_time "$runtime_seconds"))"
last_state="ACTIVE"
elif [ $(( _now - last_charging_update )) -ge "$CHARGING_UPDATE_INTERVAL" ]; then
log_msg "Active: ${current}A (runtime: $(format_time "$runtime_seconds"))"
last_charging_update=$_now
fi
fi
[ "$status_shown" -eq 0 ] && { display_status; status_shown=1; }
save_state
sleep "$POLL_INTERVAL"
done
}
# ============================================================================
# SIGNAL HANDLERS
# ============================================================================
handle_info_signal() {
# Toggle verbose: LOG_QUIET=0 is verbose ON, 1 is quiet
LOG_QUIET=$(( 1 - ${LOG_QUIET:-1} ))
POLL_INTERVAL=$([ "$LOG_QUIET" -eq 0 ] && echo "$POLL_INTERVAL_VERBOSE" || echo "$POLL_INTERVAL_DEFAULT")
display_status
}
handle_term_signal() {
_msg="$(date '+%Y-%m-%d %T'): Stopping"
printf '%s\n' "$_msg"
[ "$ENABLE_FILE_LOGGING" -eq 1 ] && printf '%s\n' "$_msg" >> "$LOG_FILE"
[ -f "$PID_FILE" ] && [ "$(cat "$PID_FILE")" = "$$" ] && rm -f "$PID_FILE"
exit 0
}
trap_signals() {
# SIGINFO is BSD/macOS-specific; SIGUSR1 is the cross-platform fallback
trap handle_info_signal INFO USR1
trap handle_term_signal INT TERM
}
# ============================================================================
# MAIN
# ============================================================================
parse_options "$@"
_detect_fetcher
check_pid_lock
trap_signals
check_daemon_mode
start_monitoring
Service Configuration
Install in /usr/local/etc/rc.d as battery_charger
#!/bin/sh
#
# PROVIDE: battery_charger
# REQUIRE: NETWORKING
# KEYWORD: shutdown
#
# Add the following to /etc/rc.conf to enable:
# battery_charger_enable="YES"
# battery_charger_flags="-d -l -c battery-charger -a 04:00 -r 02:00"
#
# NOTE: The -d (daemon mode) flag is required and included in the default flags.
#
# shellcheck disable=SC2034
. /etc/rc.subr
# --- Configuration Variables ---
name="battery_charger"
rcvar="${name}_enable"
# The command that will be executed as the daemon.
command="/usr/local/bin/${name}.sh"
# Since the script is run by /bin/sh, we check for the interpreter path.
procname="/bin/sh"
# Define command_args for execution.
command_args="${battery_charger_flags}"
# The default device name (used only for setting a default value for the -c flag).
DEFAULT_DEVICE="battery-charger"
# --- rc.d Logic ---
load_rc_config $name
# Set the script's default flags if none are provided in /etc/rc.conf.
# shellcheck disable=SC2223
: ${battery_charger_flags:="-d -l -c ${DEFAULT_DEVICE} -a off -r off"}
# --- Set the PID file path based on the script name only. ---
pidfile="/var/run/${name}.pid"
# Now call the main rc.subr routine
run_rc_command "$1"
Add these settings to /etc/rc.conf and change as needed:
battery_charger_enable="YES"
battery_charger_flags="-l -d"
To start the service:
# service battery_charger start
Check /var/log/battery_charger.log for runtime details and power plug events.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.