Drives on a headless Jellyfin box ran hot 24/7: NAS-class drives (WD Red, IronWolf) ship with no idle timer at all. v0.1.2 stopped tuistream from keeping them awake; this makes them actually go to sleep. Spin-down is a DEFAULT, not a feature: when the inventory shows spinning media drives, the TUI silently installs/syncs the mechanism and flashes what it did. [s] on Setup is the opt-OUT, remembered via /etc/tuistream/spindown-off so the default never fights the user. The mechanism is a tiny systemd service (tuistream --spindown-watch) that samples /proc/diskstats and issues `hdparm -y` to any target drive idle past 3 minutes. We deliberately do NOT use the drive's own standby timer (hdparm -S): the 10TB helium WD Reds advertise it and then ignore it — verified on real hardware (fresh -S 36, zero I/O, four minutes, still active/idle). Forcing standby from outside works on everything. An rc shipped the udev+hdparm -S approach; the plans clean its rule up. Safety: targets are spinning (sysfs rotational), top-level, real disks, never a system disk — same classifier the rest of Setup trusts. The watcher's own probes can't disturb drives: /proc/diskstats and sysfs are kernel memory, and hdparm -C (CHECK POWER MODE) doesn't wake or reset anything. The unit's ExecStart points at the running binary and the auto-sync rewrites it when the content goes stale (e.g. binary moved from a dev path to /usr/local/bin). hdparm joins the dependency pre-flight. Verified end-to-end on moviebox (2x WD Red 10TB SATA + IronWolf 10TB USB): auto-applied on launch, all three drives reached standby after 3 idle minutes, Monitor shows them as "asleep" without waking them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
276 lines
9 KiB
Go
276 lines
9 KiB
Go
// Package spindown puts spinning media drives to sleep after a few idle
|
|
// minutes. NAS-class drives (WD Red, IronWolf) ship with NO idle timer at
|
|
// all — they spin 24/7 and run warm even when nothing touches them.
|
|
//
|
|
// Spin-down is a DEFAULT, not a feature the user enables: whenever the
|
|
// inventory shows spinning media drives, the TUI silently syncs the
|
|
// watcher onto the box (see AutoSyncDue). The Setup tab's [s] key is the
|
|
// opt-OUT — disabling writes a marker file so the default never fights
|
|
// the user.
|
|
//
|
|
// Mechanism: a tiny systemd service runs `tuistream --spindown-watch`,
|
|
// which samples /proc/diskstats and issues `hdparm -y` to any target
|
|
// drive that's been idle past the timeout. We deliberately do NOT use the
|
|
// drive's own standby timer (`hdparm -S`): common NAS drives — the 10TB
|
|
// helium WD Reds included — advertise the timer and then ignore it.
|
|
// Forcing standby from the outside works on everything `hdparm -y`
|
|
// works on, which we can verify per-drive.
|
|
//
|
|
// The watcher's own probes never touch the platters: /proc/diskstats and
|
|
// sysfs are kernel memory, and `hdparm -C` is an ATA CHECK POWER MODE,
|
|
// which neither wakes a sleeping drive nor resets its idle state.
|
|
//
|
|
// System disks are never targeted: Targets() refuses anything the OS
|
|
// lives on, using the same classifier the rest of Setup trusts.
|
|
package spindown
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"tuistream/internal/drives"
|
|
"tuistream/internal/step"
|
|
)
|
|
|
|
// UnitPath is the systemd service that runs the watcher.
|
|
const UnitPath = "/etc/systemd/system/tuistream-spindown.service"
|
|
|
|
const unitName = "tuistream-spindown.service"
|
|
|
|
// legacyRulesPath is the v0.2.0-rc udev/`hdparm -S` approach, removed on
|
|
// sync: the firmware timer it relied on is ignored by common NAS drives.
|
|
const legacyRulesPath = "/etc/udev/rules.d/69-tuistream-spindown.rules"
|
|
|
|
// OptOutPath marks "the user turned spin-down off on purpose" — its
|
|
// presence stops the auto-sync from re-enabling the default behind their
|
|
// back. Written by DisablePlan, removed by EnablePlan.
|
|
const OptOutPath = "/etc/tuistream/spindown-off"
|
|
|
|
// TimeoutMinutes is the idle time before a drive is spun down.
|
|
const TimeoutMinutes = 3
|
|
|
|
// WatchInterval is how often the watcher samples /proc/diskstats.
|
|
const WatchInterval = 30 * time.Second
|
|
|
|
// Target is one spinning, non-system physical disk the watcher may touch.
|
|
type Target struct {
|
|
Device string // /dev/sda
|
|
Name string // sda
|
|
Model string
|
|
}
|
|
|
|
// Targets picks the disks the watcher may touch: spinning (per sysfs),
|
|
// top-level, real hardware, and never a disk the OS lives on.
|
|
func Targets(inv *drives.Inventory) []Target {
|
|
if inv == nil {
|
|
return nil
|
|
}
|
|
var ts []Target
|
|
for _, d := range inv.All {
|
|
if d.Type != "disk" || drives.IsPseudoDisk(d.Name) {
|
|
continue
|
|
}
|
|
if inv.SystemDisks[d.Name] {
|
|
continue
|
|
}
|
|
if !rotational(d.Name) {
|
|
continue
|
|
}
|
|
ts = append(ts, Target{Device: d.Path, Name: d.Name, Model: d.Model})
|
|
}
|
|
return ts
|
|
}
|
|
|
|
// rotational reports whether a disk spins, per sysfs. Anything we can't
|
|
// read is treated as non-rotational so SSDs/NVMe are never targeted by a
|
|
// misread.
|
|
func rotational(name string) bool {
|
|
b, err := os.ReadFile("/sys/block/" + name + "/queue/rotational")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(string(b)) == "1"
|
|
}
|
|
|
|
// Enabled reports whether the watcher service is installed.
|
|
func Enabled() bool {
|
|
_, err := os.Stat(UnitPath)
|
|
return err == nil
|
|
}
|
|
|
|
// OptedOut reports whether the user explicitly disabled the default.
|
|
func OptedOut() bool {
|
|
_, err := os.Stat(OptOutPath)
|
|
return err == nil
|
|
}
|
|
|
|
// AutoSyncDue reports whether the silent default-apply should run: there
|
|
// are spinning media drives, the user hasn't opted out, and the unit on
|
|
// disk doesn't match what we'd write (missing, stale, or pointing at a
|
|
// binary that has since moved — e.g. after a proper install).
|
|
func AutoSyncDue(ts []Target) bool {
|
|
if len(ts) == 0 || OptedOut() {
|
|
return false
|
|
}
|
|
b, err := os.ReadFile(UnitPath)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return string(b) != unitContent()
|
|
}
|
|
|
|
// unitContent renders the service. ExecStart points at the running
|
|
// binary so a dev copy works too; when the binary later moves (proper
|
|
// install), the content no longer matches and AutoSyncDue triggers a
|
|
// rewrite.
|
|
func unitContent() string {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
exe = "/usr/local/bin/tuistream"
|
|
}
|
|
return `[Unit]
|
|
Description=TUISTREAM drive spin-down watcher
|
|
Documentation=https://github.com/28allday/TUISTREAM
|
|
|
|
[Service]
|
|
ExecStart=` + exe + ` --spindown-watch
|
|
Restart=on-failure
|
|
RestartSec=10
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`
|
|
}
|
|
|
|
// EnablePlan installs and starts the watcher, clears any opt-out marker,
|
|
// and removes the legacy udev rule from the rc builds.
|
|
func EnablePlan(ts []Target) []step.Step {
|
|
return []step.Step{
|
|
{
|
|
Title: "Install spin-down watcher service",
|
|
Cmd: exec.Command("bash", "-c",
|
|
"cat > "+UnitPath+" <<'TUISTREAM_EOF'\n"+unitContent()+"TUISTREAM_EOF"),
|
|
},
|
|
{Title: "Clear spin-down opt-out", Cmd: exec.Command("rm", "-f", OptOutPath)},
|
|
{Title: "Remove legacy udev rule", Cmd: exec.Command("rm", "-f", legacyRulesPath)},
|
|
{Title: "Reload systemd units", Cmd: exec.Command("systemctl", "daemon-reload")},
|
|
{Title: "Start spin-down watcher", Cmd: exec.Command("systemctl", "enable", "--now", unitName)},
|
|
}
|
|
}
|
|
|
|
// DisablePlan stops and removes the watcher and records the opt-out so
|
|
// the default never re-applies itself. Drives return to their factory
|
|
// behaviour (NAS drives spin 24/7).
|
|
func DisablePlan(ts []Target) []step.Step {
|
|
return []step.Step{
|
|
{
|
|
Title: "Stop spin-down watcher",
|
|
Cmd: exec.Command("bash", "-c", "systemctl disable --now "+unitName+" 2>/dev/null; true"),
|
|
},
|
|
{Title: "Remove watcher service", Cmd: exec.Command("rm", "-f", UnitPath)},
|
|
{Title: "Remove legacy udev rule", Cmd: exec.Command("rm", "-f", legacyRulesPath)},
|
|
{Title: "Reload systemd units", Cmd: exec.Command("systemctl", "daemon-reload")},
|
|
{
|
|
Title: "Record spin-down opt-out",
|
|
Cmd: exec.Command("bash", "-c",
|
|
"mkdir -p "+filepath.Dir(OptOutPath)+" && touch "+OptOutPath),
|
|
},
|
|
}
|
|
}
|
|
|
|
// AutoSync is the silent path for the default: same work as EnablePlan
|
|
// but run directly (no step-runner UI), used when the TUI notices the
|
|
// watcher is missing or stale. Returns the first error; the TUI surfaces
|
|
// it as a flash rather than a failure screen.
|
|
func AutoSync(ts []Target) error {
|
|
for _, s := range EnablePlan(ts) {
|
|
if err := s.Cmd.Run(); err != nil {
|
|
return fmt.Errorf("%s: %w", s.Title, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------- the watcher itself (`tuistream --spindown-watch`) ----------
|
|
|
|
// Watch is the daemon loop. Every WatchInterval it re-derives the target
|
|
// set (so hotplugged drives are covered without a restart), reads each
|
|
// target's I/O counters, and spins down any drive that has been idle past
|
|
// the timeout and is still spinning. Logs go to stdout → journald.
|
|
func Watch() error {
|
|
timeout := time.Duration(TimeoutMinutes) * time.Minute
|
|
log.Printf("watching for drives idle ≥ %s (sampling every %s)", timeout, WatchInterval)
|
|
|
|
type diskState struct {
|
|
sig string // last-seen I/O counter signature
|
|
last time.Time // when the signature last changed
|
|
}
|
|
states := map[string]*diskState{}
|
|
|
|
for {
|
|
inv, err := drives.Load("")
|
|
if err != nil {
|
|
log.Printf("inventory failed (will retry): %v", err)
|
|
time.Sleep(WatchInterval)
|
|
continue
|
|
}
|
|
for _, t := range Targets(inv) {
|
|
sig := ioSignature(t.Name)
|
|
if sig == "" {
|
|
continue
|
|
}
|
|
st := states[t.Name]
|
|
if st == nil || st.sig != sig {
|
|
states[t.Name] = &diskState{sig: sig, last: time.Now()}
|
|
continue
|
|
}
|
|
if time.Since(st.last) < timeout || !isSpinning(t.Device) {
|
|
continue
|
|
}
|
|
if err := exec.Command("hdparm", "-y", t.Device).Run(); err != nil {
|
|
log.Printf("couldn't spin down %s: %v", t.Device, err)
|
|
// Push last forward so a refusing drive is retried after a
|
|
// full timeout instead of every sample.
|
|
st.last = time.Now()
|
|
continue
|
|
}
|
|
log.Printf("spun down %s (%s) after %s idle", t.Device, t.Model, timeout)
|
|
}
|
|
time.Sleep(WatchInterval)
|
|
}
|
|
}
|
|
|
|
// ioSignature condenses a disk's /proc/diskstats counters that only move
|
|
// on real I/O: reads/writes completed and sectors read/written. Fields
|
|
// like io_ticks and in_flight churn on their own and are excluded.
|
|
func ioSignature(name string) string {
|
|
b, err := os.ReadFile("/proc/diskstats")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) < 10 || f[2] != name {
|
|
continue
|
|
}
|
|
return f[3] + " " + f[5] + " " + f[7] + " " + f[9]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// isSpinning reports whether the drive is in active/idle (as opposed to
|
|
// standby/sleeping). `hdparm -C` issues CHECK POWER MODE, which doesn't
|
|
// wake a sleeping drive. On error we report false — never send a sleep
|
|
// command to a drive we can't read.
|
|
func isSpinning(device string) bool {
|
|
out, err := exec.Command("hdparm", "-C", device).Output()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.Contains(string(out), "active")
|
|
}
|