#!/bin/sh
#
# eth-powersave - park idle ethernet PHYs to save power
#
# An RTL8211F with no cable in it still autonegotiates, and on photonicat2 that
# costs a measured 273 mW per port (547 mW for both, SE ~22 mW, against a ~5.0 W
# idle draw). Admin-downing the interface sets BMCR_PDOWN and recovers all of it.
#
# The catch is that a powered-down PHY cannot see a cable being inserted, and
# this hardware gives us no way around that: energy-detect-power-down reports
# "Not supported" on both ports, and EEE only advertises 100baseT/Full and only
# does anything once a link is already up. Restricting the advertised link modes
# was measured too and saves nothing (+38 mW at 10/100, +24 mW at 10 only - both
# noise), so there is no low-power-but-still-listening state to sit in.
#
# So "auto" duty-cycles instead: keep the port parked, and raise it briefly every
# so often to look for a partner. That works out cheap, because the probe window
# is short next to any sensible interval:
#
#     interval   duty     saved/port   worst-case detect
#       10 s      23 %      210 mW          13 s
#       30 s       9 %      248 mW          33 s
#       60 s     4.8 %      260 mW          63 s
#      120 s     2.4 %      266 mW         123 s
#
# The curve is flat past 60 s: doubling to 120 s buys 6 mW a port, and every
# doubling after that buys less than the one before while the worst-case detect
# latency keeps growing. 120 s is where that trade stops paying.
#
# Backoff is exponential from 5 s to the cap rather than a fixed period, because
# unplug events cluster - somebody moving a desk or swapping ports replugs within
# seconds, and that is exactly when a fast retry is worth paying for.
#
# Power-up is the exception to all of this: a box that was just switched on very
# often has a cable already in it (or gets one within moments), and parking the
# PHY right out of boot means that cable is not even *seen* for up to a probe
# backoff - to the user the router simply "takes minutes to get ethernet". So an
# "auto" port spends the first boot_grace_secs of uptime behaving exactly like
# "on", and the duty-cycling only starts once the grace has passed. "off" is an
# explicit user choice and is not overridden.

CONF=ethpower
KICK=/var/run/eth-powersave.kick
RUNDIR=/var/run/eth-powersave

MINBO=5

log() { logger -t eth-powersave "$@"; }

uci_get() { uci -q get "${CONF}.@${CONF}[0].$1" 2>/dev/null; }

cfg_policy()   { local v; v=$(uci_get policy);      echo "${v:-auto}"; }
cfg_ports()    { local v; v=$(uci_get ports);       echo "${v:-eth0 eth1}"; }

# Per-port override (policy_eth0 / policy_eth1), so the WAN and LAN ports can
# run different policies. Falls back to the global policy, which keeps existing
# configs and an older web UI working unchanged.
port_policy() {
	local v
	v=$(uci_get "policy_$1")
	case "$v" in auto|on|off) echo "$v" ;; *) cfg_policy ;; esac
}
cfg_probe()    { local v; v=$(uci_get probe_secs);  echo "${v:-4}"; }
cfg_maxbo()    { local v; v=$(uci_get max_backoff); echo "${v:-60}"; }
cfg_grace()    { local v; v=$(uci_get boot_grace_secs); echo "${v:-300}"; }

# Only meaningful on battery. On the adapter the 0.5 W is irrelevant and the
# detection delay is not worth paying, so "auto" behaves exactly like "on".
on_battery() {
	[ "$(cat /sys/class/power_supply/battery/status 2>/dev/null)" = "Discharging" ]
}

carrier()  { [ "$(cat "/sys/class/net/$1/carrier" 2>/dev/null)" = "1" ]; }
exists()   { [ -e "/sys/class/net/$1" ]; }

# Only ever touch the SoC's own ethernet MACs.
#
# The FM350 presents itself as a network interface as well (rndis_host, eth2 on
# current units) and interface numbering is not guaranteed stable across kernels
# or probe order, so a name-based list is not enough on its own. Matching the
# driver makes it structurally impossible for this daemon to park the modem, no
# matter how the ports end up numbered. The modem is never power-managed here -
# it stays entirely under manual control.
cfg_driver() { local v; v=$(uci_get driver); echo "${v:-rk_gmac-dwmac}"; }

is_onboard_phy() {
	local d
	d=$(readlink "/sys/class/net/$1/device/driver" 2>/dev/null)
	[ "${d##*/}" = "$(cfg_driver)" ]
}
is_up()    { local f; f=$(cat "/sys/class/net/$1/flags" 2>/dev/null); [ $(( ${f:-0} & 1 )) -eq 1 ]; }

now() { cut -d. -f1 /proc/uptime; }

sf() { echo "$RUNDIR/$1.$2"; }
get() { cat "$(sf "$1" "$2")" 2>/dev/null || echo "$3"; }
put() { echo "$2" > "$(sf "$1" "$3")"; }

park()  { is_up  "$1" && { ip link set "$1" down 2>/dev/null; log "$1 parked"; }; }
raise() { is_up  "$1" || { ip link set "$1" up   2>/dev/null; }; }

# Something outside wants ports probed right now - typically the web UI, when the
# user opens the network page. That is a strong hint a cable is about to go in,
# and honouring it keeps the UI feeling immediate without raising the duty cycle
# in the steady state.
take_kick() {
	[ -f "$KICK" ] || return 1
	rm -f "$KICK"
	return 0
}

mkdir -p "$RUNDIR"

# Ports are staggered so their probe windows do not overlap; two PHYs coming up
# together is a current spike we can trivially avoid on battery.
stagger=0
for p in $(cfg_ports); do
	is_onboard_phy "$p" || continue
	put "$p" "$stagger" next
	put "$p" "$MINBO" bo
	stagger=$((stagger + 2))
done

trap 'for p in $(cfg_ports); do exists "$p" && is_onboard_phy "$p" && ip link set "$p" up 2>/dev/null; done; exit 0' TERM INT

while :; do
	probe=$(cfg_probe)
	maxbo=$(cfg_maxbo)
	kicked=0
	take_kick && kicked=1

	for p in $(cfg_ports); do
		exists "$p" || continue
		is_onboard_phy "$p" || continue

		case "$(port_policy "$p")" in
		on)
			raise "$p"
			put "$p" "$MINBO" bo
			continue
			;;
		off)
			park "$p"
			continue
			;;
		esac

		# auto: within the boot grace the port is held up like "on" so a
		# cable present at power-up connects immediately; saving starts
		# once the box has been up for boot_grace_secs (uptime-based, so a
		# daemon restart does not re-open the window).
		if [ "$(now)" -lt "$(cfg_grace)" ]; then
			raise "$p"
			put "$p" "$MINBO" bo
			continue
		fi

		if ! on_battery; then
			raise "$p"
			put "$p" "$MINBO" bo
			continue
		fi

		if carrier "$p"; then
			# Linked and carrying traffic - leave it completely alone.
			put "$p" "$MINBO" bo
			put "$p" 0 next
			continue
		fi

		t=$(now)
		nx=$(get "$p" next 0)

		if [ "$kicked" = "1" ]; then
			put "$p" "$MINBO" bo
			nx=0
		fi

		if is_up "$p"; then
			# Mid-probe (or we just lost carrier). If the window has run out
			# without a partner appearing, park it and back off.
			if [ "$t" -ge "$nx" ]; then
				bo=$(get "$p" bo "$MINBO")
				park "$p"
				nbo=$((bo * 2))
				[ "$nbo" -gt "$maxbo" ] && nbo=$maxbo
				put "$p" "$nbo" bo
				put "$p" "$((t + bo))" next
			fi
		else
			if [ "$t" -ge "$nx" ]; then
				raise "$p"
				put "$p" "$((t + probe))" next
			fi
		fi
	done

	sleep 1
done
