RSSAmplifier

Morgan Davis · Dec 4, 2025

Optimize Your Battery Charging

0
Sign in to vote or save

Morgan Davis · Morgan Davis

A Shell Script That Charges Batteries Safely and Optimally

VR headsets, a USB multiport charger, external batteries, and a smart plug

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.

quest 2 headset disassembled

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:

    1. Maximum Runtime: Shuts the plug off after a configurable time limit (e.g., 3 hours) to prevent accidental over-charging.
    2. 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.

Read the original on morgandavis.net

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.